Compare commits

...

24 Commits

Author SHA1 Message Date
Aarnav Tale 272233ae62 fix: add patch command to docker build 2026-04-09 22:42:51 -04:00
Aarnav Tale 974c3b0e48 chore: v0.7.0-beta.2 2026-04-09 22:08:47 -04:00
github-actions[bot] acd3ff3403 chore: update nix pnpm deps hash 2026-04-10 02:06:23 +00:00
Aarnav Tale 1961608e12 docs: get rid of deleted png 2026-04-09 22:01:34 -04:00
Aarnav Tale c4ac28f90b fix: correct patch permissions for nix 2026-04-09 21:58:39 -04:00
Aarnav Tale a0b2077c7b chore: remove faker-js dependency 2026-04-09 21:57:46 -04:00
Aarnav Tale 0f19fdf0da feat: rebuild browser ssh from the ground up 2026-04-09 21:56:42 -04:00
Aarnav Tale d255098128 fix: make the ssh button look nicer 2026-04-07 00:36:04 -04:00
Aarnav Tale 98e0806e5a fix: handle the tag owner username 2026-04-07 00:32:59 -04:00
Aarnav Tale 44dffeaff0 fix: patch tailscale to support a custom DERP port 2026-04-07 00:24:50 -04:00
Aarnav Tale 33f7bbb0cf fix: handle empty ACLs 2026-04-07 00:24:50 -04:00
Aarnav Tale 10278d0cc9 fix: correctly expire pre-auth-keys in 0.28.0+ 2026-04-07 00:24:50 -04:00
Aarnav Tale bdcd4c5bad Merge pull request #518 from Kroppeb/fix/wrong-url-in-help-message 2026-04-06 21:42:48 -04:00
Aarnav Tale 43cff2f4b7 feat: i guess we're undoing agent work 2026-04-06 21:19:27 -04:00
Aarnav Tale 61e7303363 fix: handle URI components in provider ID 2026-04-06 21:19:27 -04:00
Aarnav Tale dc44cc4155 Merge pull request #521 from sinanmohd/chore/deprecated-package-attribute 2026-04-06 20:37:17 -04:00
Aarnav Tale 30ce5e2727 Merge pull request #514 from tale/update_flake_lock_action 2026-04-06 20:34:29 -04:00
sinanmohd 55a09476ab chore: deprecated package attributes 2026-04-05 17:50:37 +05:30
github-actions[bot] ac90b4e8bb flake.lock: Update
Flake lock file updates:

• Updated input 'nixpkgs':
    'github:nixos/nixpkgs/9cf7092' (2026-03-18)
  → 'github:nixos/nixpkgs/8d8c1fa' (2026-04-02)
2026-04-05 08:45:30 +00:00
Aarnav Tale 4b47b1bbed chore: update auth-service to not be class based 2026-04-03 16:57:39 -04:00
Aarnav Tale 003985d192 docs: document the modular architecture for services 2026-04-03 16:36:52 -04:00
Aarnav Tale 1259642f8a feat: replace openid-client with clean-room oidc system 2026-04-03 16:36:27 -04:00
Aarnav Tale 4cd0c1e206 fix: use headscale.api_key where possible 2026-04-03 16:34:50 -04:00
Robbe Pincket b2dfa773b0 fix: change oidc config error notice url 2026-04-01 22:17:11 +02:00
81 changed files with 4072 additions and 2200 deletions
+1
View File
@@ -13,3 +13,4 @@ node_modules
/docs/.vitepress/dist/
/docs/.vitepress/cache/
/.direnv
/vendor
+31 -4
View File
@@ -1,11 +1,19 @@
# 0.7.0-beta.1 (March 27, 2026)
# 0.7.0-beta.2 (April 9, 2026)
> This is a beta release. Please report any issues you encounter.
- **Rebuilt the Browser SSH feature**
- Should now work with custom DERP ports and properly handle sessions.
- Switched to using `libghostty` for a proper, modern terminal experience (closes [#515](https://github.com/tale/headplane/issues/515)).
- Added more resilient error handling and state handling when initiating connections.
- **Migrated all UI components from react-aria/react-stately to @base-ui-components/react.**
- Removed `react-aria`, `react-stately`, and `tailwindcss-react-aria-components` as dependencies.
- **Rearchitected the Headplane Agent** from a long-running stdin/stdout daemon to a one-shot sync model (closes [#350](https://github.com/tale/headplane/issues/350), closes [#455](https://github.com/tale/headplane/issues/455)).
- The Go binary connects to the Tailnet, fetches all peer hostinfo as JSON, and exits.
- **Replaced `openid-client` with a clean-room OIDC implementation.**
- Removed the `openid-client` dependency entirely.
- Fixed `client_secret_basic` auth method not working with Google SSO and other providers (closes [#493](https://github.com/tale/headplane/issues/493)).
- Fixed OIDC connector initialization failures on beta.1 (closes [#516](https://github.com/tale/headplane/issues/516)).
- **Rearchitected the Headplane Agent** with a new sync model (closes [#350](https://github.com/tale/headplane/issues/350), closes [#455](https://github.com/tale/headplane/issues/455)).
- The Go binary connects to the Tailnet and fetches all peer hostinfo as JSON.
- The Node.js manager auto-generates ephemeral tag-only pre-auth keys (requires Headscale 0.28+).
- Deprecated `integration.agent.pre_authkey` and `integration.agent.cache_path` config fields.
- Added `integration.agent.executable_path` config field.
@@ -16,15 +24,34 @@
- Added an agent status page at `/settings/agent` showing sync status, node count, errors, and a "Sync Now" button.
- Added additional machine list filters for user, tag, status, and route (via [#507](https://github.com/tale/headplane/pull/507), closes [#506](https://github.com/tale/headplane/issues/506)).
- Added self-service pre-auth key creation for auditor role users (via [#478](https://github.com/tale/headplane/pull/478), closes [#453](https://github.com/tale/headplane/issues/453)).
- Fetch OIDC profile pictures server-side when the URL requires authentication (via [#510](https://github.com/tale/headplane/pull/510), closes [#326](https://github.com/tale/headplane/issues/326)).
- Store OIDC profile pictures in the database to prevent cookie header overload (closes [#326](https://github.com/tale/headplane/issues/326), via [#510](https://github.com/tale/headplane/pull/510)).
- Fixed pre-auth key expiration on Headscale 0.28+ (closes [#519](https://github.com/tale/headplane/issues/519)).
- Fixed OIDC subject matching for providers that use special characters in user IDs (e.g. Auth0 `github|12345`) (closes [#428](https://github.com/tale/headplane/issues/428)).
- Fixed `headscale.api_key` not being used consistently across all code paths.
- Fixed intermittent SSR crash on the Access Control page caused by client-only CodeMirror imports.
- Fixed first user not being assigned the owner role on OIDC login (via [#480](https://github.com/tale/headplane/pull/480), closes [#266](https://github.com/tale/headplane/issues/266)).
- Fixed login errors throwing an unexpected server error instead of showing form validation (via [#475](https://github.com/tale/headplane/pull/475), closes [#474](https://github.com/tale/headplane/issues/474)).
- Fixed agent HostInfo not refreshing periodically using `cache_ttl` (via [#477](https://github.com/tale/headplane/pull/477), closes [#427](https://github.com/tale/headplane/issues/427)).
- Fixed agent working directory being wiped on restart.
- Fixed a race condition where the SSE controller could be used after being closed.
- **Rewrote the WebSSH WASM module** to match Tailscale's proven `tsconnect` init sequence.
- Switched the terminal renderer from xterm.js to [restty](https://restty.dev) (Ghostty WASM).
- Bundled self-hosted JetBrains Mono Nerd Font with Nerd Fonts symbol fallback — no CDN dependency.
- Fixed SSH sessions failing with EOF: the SSH channel multiplexer was not receiving server traffic.
- Fixed terminal resize sending swapped rows/cols, causing garbled output on window resize.
- Fixed `log.Fatal()` calls in the WASM bridge killing the entire runtime on recoverable errors.
- Fixed `Close()` returning `true` on error and `false` on success.
- Fixed stale closure bug in the NodeKey tracking callback.
- Removed unnecessary `LoginDefault` and `LocalBackendStartKeyOSNeutral` control flags.
- Added cancellation support for in-flight SSH connections on close.
- Fixed WebSSH dropping DERP port information on non-standard ports (e.g. `:8443`), which caused connections to fail (closes [#515](https://github.com/tale/headplane/issues/515)).
- Fixed WebSSH WASM prefix paths for correct asset loading (closes [#386](https://github.com/tale/headplane/issues/386)).
- Fixed Nix WASM build applying DERP patch to wrong vendor directory.
- Fixed Dockerfile WASM copy paths.
- Fixed CodeMirror version mismatch override in the ACL editor.
- Fixed cookie secret generation using incorrect byte length (via [#501](https://github.com/tale/headplane/pull/501)).
- Fixed OIDC configuration error troubleshooting link (via [#518](https://github.com/tale/headplane/pull/518), closes [#517](https://github.com/tale/headplane/issues/517)).
- Fixed deprecated Nix package attributes (via [#521](https://github.com/tale/headplane/pull/521)).
- Detect unsupported Docker API versions early with a clear error message (via [#497](https://github.com/tale/headplane/pull/497)).
- Updated NixOS module options: removed deprecated agent fields, added `headscale.api_key_path` and `integration.agent.executable_path`.
+2
View File
@@ -1,7 +1,9 @@
FROM --platform=$BUILDPLATFORM golang:1.25.1 AS go-base
WORKDIR /run
RUN apt-get update && apt-get install -y --no-install-recommends patch && rm -rf /var/lib/apt/lists/*
COPY go.mod go.sum build.sh ./
COPY patches/ ./patches/
RUN go mod download
COPY cmd/ ./cmd/
+2 -4
View File
@@ -2,7 +2,6 @@ import { Outlet, redirect, type ShouldRevalidateFunction } from "react-router";
import { ErrorBanner } from "~/components/error-banner";
import StatusBanner from "~/components/status-banner";
import { pruneEphemeralNodes } from "~/server/db/pruner";
import { isDataUnauthorizedError } from "~/server/headscale/api/error-client";
import { usersResource } from "~/server/headscale/live-store";
import { Capabilities } from "~/server/web/roles";
@@ -30,11 +29,11 @@ export const shouldRevalidate: ShouldRevalidateFunction = ({
return false;
};
export async function loader({ request, context, ...rest }: Route.LoaderArgs) {
export async function loader({ request, context }: Route.LoaderArgs) {
try {
const principal = await context.auth.require(request);
const apiKey = context.auth.getHeadscaleApiKey(principal, context.oidc?.apiKey);
const apiKey = context.auth.getHeadscaleApiKey(principal);
const api = context.hsApi.getRuntimeClient(apiKey);
const user =
@@ -53,7 +52,6 @@ export async function loader({ request, context, ...rest }: Route.LoaderArgs) {
if (isHealthy) {
try {
await api.getApiKeys();
await pruneEphemeralNodes({ context, request, ...rest });
} catch (error) {
if (isDataUnauthorizedError(error)) {
const displayName =
+1 -1
View File
@@ -13,7 +13,7 @@ export default [
route("/logout", "routes/auth/logout.ts"),
route("/oidc/callback", "routes/auth/oidc-callback.ts"),
route("/oidc/start", "routes/auth/oidc-start.ts"),
route("/ssh", "routes/ssh/console.tsx"),
route("/ssh/:id", "routes/ssh/page.tsx"),
// All the main logged-in routes
layout("layout/app.tsx", [
+1 -1
View File
@@ -26,7 +26,7 @@ export async function aclAction({ request, context }: Route.ActionArgs) {
});
}
const apiKey = context.auth.getHeadscaleApiKey(principal, context.oidc?.apiKey);
const apiKey = context.auth.getHeadscaleApiKey(principal);
const api = context.hsApi.getRuntimeClient(apiKey);
try {
const { policy, updatedAt } = await api.setPolicy(policyData);
+5 -2
View File
@@ -29,7 +29,7 @@ export async function aclLoader({ request, context }: Route.LoaderArgs) {
};
// Try to load the ACL policy from the API.
const apiKey = context.auth.getHeadscaleApiKey(principal, context.oidc?.apiKey);
const apiKey = context.auth.getHeadscaleApiKey(principal);
const api = context.hsApi.getRuntimeClient(apiKey);
try {
const { policy, updatedAt } = await api.getPolicy();
@@ -38,8 +38,11 @@ export async function aclLoader({ request, context }: Route.LoaderArgs) {
return flags;
} catch (error) {
if (isDataWithApiError(error)) {
// Headscale returns "acl policy not found" when the policy mode is
// set to file but no file exists, and returns a 500 when database
// mode is used but the policies table is empty.
// https://github.com/juanfont/headscale/blob/c4600346f9c29b514dc9725ac103efb9d0381f23/hscontrol/types/policy.go#L10
if (error.data.rawData.includes("acl policy not found")) {
if (error.data.rawData.includes("acl policy not found") || error.data.statusCode === 500) {
flags.policy = "";
flags.writable = true;
return flags;
+15 -4
View File
@@ -1,5 +1,5 @@
import { AlertCircle, Construction, Eye, FlaskConical, Pencil } from "lucide-react";
import { useEffect, useState } from "react";
import { Suspense, lazy, useEffect, useState } from "react";
import { isRouteErrorResponse, useFetcher, useRevalidator } from "react-router";
import Button from "~/components/button";
@@ -15,7 +15,14 @@ import toast from "~/utils/toast";
import type { Route } from "./+types/overview";
import { aclAction } from "./acl-action";
import { aclLoader } from "./acl-loader";
import { Differ, Editor } from "./components/cm.client";
import Fallback from "./components/fallback";
const LazyEditor = lazy(() =>
import("./components/cm.client").then((m) => ({ default: m.Editor })),
);
const LazyDiffer = lazy(() =>
import("./components/cm.client").then((m) => ({ default: m.Differ })),
);
export const loader = aclLoader;
export const action = aclAction;
@@ -101,10 +108,14 @@ export default function Page({ loaderData: { access, writable, policy } }: Route
</TabsTab>
</TabsList>
<TabsPanel value="edit">
<Editor isDisabled={disabled} onChange={setCodePolicy} value={codePolicy} />
<Suspense fallback={<Fallback acl={codePolicy} />}>
<LazyEditor isDisabled={disabled} onChange={setCodePolicy} value={codePolicy} />
</Suspense>
</TabsPanel>
<TabsPanel value="diff">
<Differ left={policy} right={codePolicy} />
<Suspense fallback={<Fallback acl={codePolicy} />}>
<LazyDiffer left={policy} right={codePolicy} />
</Suspense>
</TabsPanel>
<TabsPanel value="preview">
<div className="flex flex-col items-center py-8">
+14 -40
View File
@@ -3,7 +3,7 @@ import { AlertCircle, CloudOff } from "lucide-react";
import Card from "~/components/card";
import Code from "~/components/code";
import Link from "~/components/link";
import type { OidcConnectorError } from "~/server/web/oidc-connector";
import type { OidcErrorCode } from "~/server/oidc/provider";
export function OidcDiscoveryFailedNotice() {
return (
@@ -20,7 +20,7 @@ export function OidcDiscoveryFailedNotice() {
);
}
export function OidcConfigErrorNotice({ errors }: { errors: OidcConnectorError[] }) {
export function OidcConfigErrorNotice({ errors }: { errors: OidcErrorCode[] }) {
return (
<Card className="m-4 mb-4 max-w-md border border-red-500 sm:m-0 sm:mb-4">
<div className="flex items-center justify-between gap-4">
@@ -34,7 +34,7 @@ export function OidcConfigErrorNotice({ errors }: { errors: OidcConnectorError[]
<li key={code.key}>{code.node}</li>
))}
</ul>{" "}
<Link external styled to="https://headplane.net/configuration/sso#troubleshooting">
<Link external styled to="https://headplane.net/features/sso#troubleshooting">
Learn more
</Link>
</Card.Text>
@@ -42,7 +42,7 @@ export function OidcConfigErrorNotice({ errors }: { errors: OidcConnectorError[]
);
}
function mapOidcErrorsToMessages(errors: OidcConnectorError[]) {
function mapOidcErrorsToMessages(errors: OidcErrorCode[]) {
const messages: {
key: string;
node: React.ReactNode;
@@ -50,7 +50,7 @@ function mapOidcErrorsToMessages(errors: OidcConnectorError[]) {
for (const error of errors) {
switch (error) {
case "INVALID_API_KEY": {
case "invalid_api_key": {
messages.push({
key: error,
node: (
@@ -63,65 +63,39 @@ function mapOidcErrorsToMessages(errors: OidcConnectorError[]) {
break;
}
case "MISSING_AUTHORIZATION_ENDPOINT": {
case "missing_endpoints": {
messages.push({
key: error,
node: (
<Card.Text className="inline">
The OIDC provided does not have a configured <Code>authorization_endpoint</Code>.
Ensure discovery URL or manual configuration is correct.
The OIDC provider is missing required endpoints. Ensure the discovery URL is correct
or provide manual endpoint overrides in your configuration.
</Card.Text>
),
});
break;
}
case "MISSING_TOKEN_ENDPOINT": {
case "discovery_failed": {
messages.push({
key: error,
node: (
<Card.Text className="inline">
The OIDC provided does not have a configured <Code>token_endpoint</Code>. Ensure
discovery URL or manual configuration is correct.
Unable to reach the OIDC provider for discovery. SSO will retry on the next login
attempt.
</Card.Text>
),
});
break;
}
case "MISSING_USERINFO_ENDPOINT": {
default: {
messages.push({
key: error,
node: (
<Card.Text className="inline">
The OIDC provided does not have a configured <Code>user_endpoint</Code>. Ensure
discovery URL or manual configuration is correct.
</Card.Text>
),
});
break;
}
case "MISSING_REQUIRED_CLAIMS": {
messages.push({
key: error,
node: (
<Card.Text className="inline">
The OIDC provider does not support the <Code>sub</Code> claim, which is required for
authentication. Your OIDC provider may be misconfigured.
</Card.Text>
),
});
break;
}
case "UNKNOWN_ERROR": {
messages.push({
key: error,
node: (
<Card.Text className="inline">
An unknown error occurred during OIDC configuration. Please check the Headplane logs
for more information.
An unknown OIDC configuration error occurred. Please check the Headplane logs for more
information.
</Card.Text>
),
});
+11 -7
View File
@@ -24,16 +24,20 @@ export async function loader({ request, context }: Route.LoaderArgs) {
const qp = new URL(request.url).searchParams;
const urlState = qp.get("s") ?? undefined;
const oidcConnector = await context.oidc?.connector.get();
const oidcService = context.oidc?.service;
const oidcStatus = oidcService
? await oidcService.discover().then(
(r) => (r.ok ? oidcService.status() : oidcService.status()),
() => oidcService.status(),
)
: undefined;
// MARK: This works because the OIDC connector will always return false
// For `isExclusive` if the OIDC config isn't usable.
if (oidcConnector?.isExclusive && urlState !== "logout") {
if (context.oidc?.disableApiKeyLogin && oidcStatus?.state === "ready" && urlState !== "logout") {
return redirect("/oidc/start");
}
const isOidcConnectorEnabled = oidcConnector?.isValid;
const oidcErrorCodes = !isOidcConnectorEnabled ? (oidcConnector?.errors ?? []) : [];
const isOidcConnectorEnabled = oidcStatus?.state === "ready";
const oidcErrorCodes = oidcStatus?.state === "error" ? [oidcStatus.error.code] : [];
return {
isCookieSecureEnabled: context.config.server.cookie_secure,
@@ -88,7 +92,7 @@ export default function Page({ loaderData, actionData }: Route.ComponentProps) {
<div>
{urlState?.startsWith("error_") ? (
<OidcErrorNotice code={urlState} />
) : oidcErrorCodes.includes("DISCOVERY_FAILED") ? (
) : oidcErrorCodes.includes("discovery_failed") ? (
<OidcDiscoveryFailedNotice />
) : oidcErrorCodes.length > 0 ? (
<OidcConfigErrorNotice errors={oidcErrorCodes} />
+42 -135
View File
@@ -1,6 +1,3 @@
import { createHash } from "node:crypto";
import * as oidc from "openid-client";
import { data, redirect } from "react-router";
import { findHeadscaleUserBySubject } from "~/server/web/headscale-identity";
@@ -10,8 +7,8 @@ import { createOidcStateCookie } from "~/utils/oidc-state";
import type { Route } from "./+types/oidc-callback";
export async function loader({ request, context }: Route.LoaderArgs) {
const oidcConnector = await context.oidc?.connector.get();
if (!oidcConnector?.isValid) {
const service = context.oidc?.service;
if (!service) {
throw data("OIDC is not enabled or misconfigured", { status: 501 });
}
@@ -34,138 +31,48 @@ export async function loader({ request, context }: Route.LoaderArgs) {
return redirect("/login?s=error_invalid_session");
}
try {
const callbackUrl = new URL(redirect_uri);
const currentUrl = new URL(request.url);
callbackUrl.search = currentUrl.search;
const flowState = {
state,
nonce,
codeVerifier: verifier,
redirectUri: redirect_uri,
};
const tokens = await oidc.authorizationCodeGrant(oidcConnector.client, callbackUrl, {
expectedState: state,
expectedNonce: nonce,
...(oidcConnector.usePKCE ? { pkceCodeVerifier: verifier } : {}),
});
const claims = tokens.claims();
if (claims?.sub == null) {
log.warn("auth", "No subject found in OIDC claims");
return redirect("/login?s=error_no_sub");
}
const userInfo = await oidc.fetchUserInfo(
oidcConnector.client,
tokens.access_token,
claims.sub,
);
// We have defaults that closely follow what Headscale uses, maybe we
// can make it configurable in the future, but for now we only need the
// `sub` claim.
const username = userInfo.preferred_username ?? userInfo.email?.split("@")[0] ?? "user";
const name =
userInfo.name ??
(userInfo.given_name && userInfo.family_name
? `${userInfo.given_name} ${userInfo.family_name}`
: (userInfo.preferred_username ?? "SSO User"));
const picture = await (async () => {
if (context.config.oidc?.profile_picture_source === "gravatar") {
if (!userInfo.email) {
return undefined;
}
const emailHash = userInfo.email.trim().toLowerCase();
const hash = createHash("sha256").update(emailHash).digest("hex");
return `https://www.gravatar.com/avatar/${hash}?s=200&d=identicon&r=x`;
}
if (!userInfo.picture) {
return undefined;
}
try {
const response = await fetch(userInfo.picture, {
headers: { Authorization: `Bearer ${tokens.access_token}` },
});
if (response.ok) {
const contentType = response.headers.get("content-type");
if (contentType?.startsWith("image/")) {
const buffer = await response.arrayBuffer();
const base64 = Buffer.from(buffer).toString("base64");
return `data:${contentType};base64,${base64}`;
}
}
} catch {}
return userInfo.picture;
})();
const userId = await context.auth.findOrCreateUser(claims.sub, {
name,
email: userInfo.email,
picture,
});
try {
const hsApi = context.hsApi.getRuntimeClient(context.oidc!.apiKey);
const hsUsers = await hsApi.getUsers();
const hsUser = findHeadscaleUserBySubject(hsUsers, claims.sub, userInfo.email);
if (hsUser) {
await context.auth.linkHeadscaleUser(userId, hsUser.id);
}
} catch (error) {
log.warn("auth", "Failed to link Headscale user: %s", String(error));
}
return redirect("/", {
headers: {
"Set-Cookie": await context.auth.createOidcSession(userId, {
name,
email: userInfo.email,
username,
}),
},
});
} catch (error) {
if (error instanceof oidc.ResponseBodyError) {
log.error("auth", "Got an OIDC response error body: %s", JSON.stringify(error.cause));
// Check for PKCE-related errors
if (
error.error.toLowerCase().includes("code_verifier") ||
error.error.toLowerCase().includes("code verifier") ||
error.error.toLowerCase().includes("pkce")
) {
log.error(
"auth",
"PKCE error detected. Your OIDC provider may require PKCE to be enabled. Current setting: use_pkce=%s",
oidcConnector.usePKCE,
);
if (!oidcConnector.usePKCE) {
log.error(
"auth",
"Consider setting oidc.use_pkce=true in your configuration if your provider requires PKCE",
);
}
}
} else if (error instanceof oidc.AuthorizationResponseError) {
log.error("auth", "Got an OIDC authorization response error: %s", error.error);
} else if (error instanceof oidc.WWWAuthenticateChallengeError) {
log.error("auth", "Got an OIDC WWW-Authenticate challenge error");
} else if (error instanceof oidc.ClientError) {
log.error(
"auth",
"Got an OIDC authorization client error: %s",
error.cause instanceof Error ? error.cause.message : String(error.cause),
);
} else {
log.error(
"auth",
"Got an OIDC error: %s",
error instanceof Error && error.cause ? JSON.stringify(error.cause) : String(error),
);
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);
}
return redirect("/login?s=error_auth_failed");
}
const identity = result.value;
const userId = await context.auth.findOrCreateUser(identity.subject, {
name: identity.name,
email: identity.email,
picture: identity.picture,
});
try {
const hsApi = context.hsApi.getRuntimeClient(context.headscaleApiKey!);
const hsUsers = await hsApi.getUsers();
const hsUser = findHeadscaleUserBySubject(hsUsers, identity.subject, identity.email);
if (hsUser) {
await context.auth.linkHeadscaleUser(userId, hsUser.id);
}
} catch (error) {
log.warn("auth", "Failed to link Headscale user: %s", String(error));
}
return redirect("/", {
headers: {
"Set-Cookie": await context.auth.createOidcSession(userId, {
name: identity.name,
email: identity.email,
username: identity.username,
}),
},
});
}
+13 -57
View File
@@ -1,7 +1,5 @@
import * as oidc from "openid-client";
import { data, redirect } from "react-router";
import { HeadplaneConfig } from "~/server/config/config-schema";
import { createOidcStateCookie } from "~/utils/oidc-state";
import type { Route } from "./+types/oidc-start";
@@ -12,70 +10,28 @@ export async function loader({ request, context }: Route.LoaderArgs) {
return redirect("/");
} catch {}
const oidcConnector = await context.oidc?.connector.get();
if (!oidcConnector?.isValid) {
const service = context.oidc?.service;
if (!service) {
throw data("OIDC is not enabled or misconfigured", { status: 501 });
}
const result = await service.startFlow();
if (!result.ok) {
return redirect(`/login?s=${result.error.code}`);
}
const { url, flowState } = result.value;
const cookie = createOidcStateCookie(context.config);
const redirect_uri = getRedirectUri(context.config, request);
const nonce = oidc.randomNonce();
const verifier = oidc.randomPKCECodeVerifier();
const state = oidc.randomState();
const url = oidc.buildAuthorizationUrl(oidcConnector.client, {
...oidcConnector.extraParams,
scope: oidcConnector.scope,
redirect_uri,
state,
nonce,
...(oidcConnector.usePKCE
? {
code_challenge_method: "S256",
code_challenge: await oidc.calculatePKCECodeChallenge(verifier),
}
: {}),
});
return redirect(url.href, {
return redirect(url, {
status: 302,
headers: {
"Set-Cookie": await cookie.serialize({
state,
nonce,
verifier,
redirect_uri,
state: flowState.state,
nonce: flowState.nonce,
verifier: flowState.codeVerifier,
redirect_uri: flowState.redirectUri,
}),
},
});
}
function getRedirectUri(config: HeadplaneConfig, req: Request): string {
if (config.server.base_url != null) {
const url = new URL(`${__PREFIX__}/oidc/callback`, config.server.base_url);
return url.href;
}
if (config.oidc?.redirect_uri != null) {
const url = new URL(`${__PREFIX__}/oidc/callback`, config.oidc.redirect_uri);
return url.href;
}
const url = new URL(`${__PREFIX__}/oidc/callback`, req.url);
let host = req.headers.get("Host");
if (!host) {
host = req.headers.get("X-Forwarded-Host");
}
if (!host) {
throw data("Cannot determine redirect URI: no Host or X-Forwarded-Host header", {
status: 500,
});
}
const proto = req.headers.get("X-Forwarded-Proto");
url.protocol = proto ?? "http:";
url.host = host;
return url.href;
}
+2 -2
View File
@@ -28,7 +28,7 @@ export async function loader({ request, context }: Route.LoaderArgs) {
principal.kind === "oidc" &&
!principal.user.headscaleUserId
) {
const apiKey = context.auth.getHeadscaleApiKey(principal, context.oidc?.apiKey);
const apiKey = context.auth.getHeadscaleApiKey(principal);
const api = context.hsApi.getRuntimeClient(apiKey);
let headscaleUsers: { id: string; name: string }[] = [];
@@ -64,7 +64,7 @@ export async function loader({ request, context }: Route.LoaderArgs) {
}
// No UI access — show the download/connect page
const apiKey = context.auth.getHeadscaleApiKey(principal, context.oidc?.apiKey);
const apiKey = context.auth.getHeadscaleApiKey(principal);
const api = context.hsApi.getRuntimeClient(apiKey);
let linkedUserName: string | undefined;
@@ -50,8 +50,8 @@ export default function MachineRow({
}, [magic, node.ipAddresses]);
return (
<tr className="group hover:bg-mist-50 dark:hover:bg-mist-950" key={node.id}>
<td className="py-2 pl-0.5 focus-within:ring-3">
<tr className="group hover:bg-mist-100 dark:hover:bg-mist-800" key={node.id}>
<td className="py-2 pl-2 focus-within:ring-3">
<Link className={cn("group/link h-full focus:outline-hidden")} to={`/machines/${node.id}`}>
<p
className={cn(
+4 -7
View File
@@ -107,7 +107,7 @@ export default function MachineMenu({
// in a new WINDOW since href can only
// do a new TAB.
window.open(
`${__PREFIX__}/ssh?hostname=${node.givenName}`,
`${__PREFIX__}/ssh/${node.givenName}`,
"_blank",
"noopener,noreferrer,width=800,height=600",
);
@@ -120,17 +120,14 @@ export default function MachineMenu({
) : (
<Button
className={cn(
"py-0.5",
"py-0.5 rounded-lg",
"opacity-0 pointer-events-none group-hover:opacity-100",
"group-hover:pointer-events-auto",
)}
variant="ghost"
variant="light"
onClick={() => {
// We need to use JS to open the SSH URL
// in a new WINDOW since href can only
// do a new TAB.
window.open(
`${__PREFIX__}/ssh?hostname=${node.givenName}`,
`${__PREFIX__}/ssh/${node.givenName}`,
"_blank",
"noopener,noreferrer,width=800,height=600",
);
+1 -3
View File
@@ -10,9 +10,7 @@ export async function machineAction({ request, context }: Route.ActionArgs) {
const principal = await context.auth.require(request);
const formData = await request.formData();
const api = context.hsApi.getRuntimeClient(
context.auth.getHeadscaleApiKey(principal, context.oidc?.apiKey),
);
const api = context.hsApi.getRuntimeClient(context.auth.getHeadscaleApiKey(principal));
const action = formData.get("action_id")?.toString();
if (!action) {
+1 -3
View File
@@ -38,9 +38,7 @@ export async function loader({ request, params, context }: Route.LoaderArgs) {
}
}
const api = context.hsApi.getRuntimeClient(
context.auth.getHeadscaleApiKey(principal, context.oidc?.apiKey),
);
const api = context.hsApi.getRuntimeClient(context.auth.getHeadscaleApiKey(principal));
const [nodesSnap, usersSnap] = await Promise.all([
context.hsLive.get(nodesResource, api),
context.hsLive.get(usersResource, api),
+1 -3
View File
@@ -30,9 +30,7 @@ export async function loader({ request, context }: Route.LoaderArgs) {
const writablePermission = context.auth.can(principal, Capabilities.write_machines);
const api = context.hsApi.getRuntimeClient(
context.auth.getHeadscaleApiKey(principal, context.oidc?.apiKey),
);
const api = context.hsApi.getRuntimeClient(context.auth.getHeadscaleApiKey(principal));
const [nodesSnap, usersSnap] = await Promise.all([
context.hsLive.get(nodesResource, api),
context.hsLive.get(usersResource, api),
+8 -5
View File
@@ -2,12 +2,13 @@ import { data } from "react-router";
import { getOidcSubject } from "~/server/web/headscale-identity";
import { Capabilities } from "~/server/web/roles";
import type { PreAuthKey } from "~/types";
import type { Route } from "./+types/overview";
export async function authKeysAction({ request, context }: Route.ActionArgs) {
const principal = await context.auth.require(request);
const apiKey = context.auth.getHeadscaleApiKey(principal, context.oidc?.apiKey);
const apiKey = context.auth.getHeadscaleApiKey(principal);
const api = context.hsApi.getRuntimeClient(apiKey);
const canGenerateAny = context.auth.can(principal, Capabilities.generate_authkeys);
@@ -95,10 +96,12 @@ export async function authKeysAction({ request, context }: Route.ActionArgs) {
return data({ success: true as const, key: key.key });
}
case "expire_preauthkey": {
const keyId = formData.get("key_id")?.toString();
const key = formData.get("key")?.toString();
if (!key) {
return data("Missing `key` in the form data.", {
if (!keyId || !key) {
return data("Missing `key_id` or `key` in the form data.", {
status: 400,
});
}
@@ -111,10 +114,10 @@ export async function authKeysAction({ request, context }: Route.ActionArgs) {
}
await checkSelfServiceOwnership(user);
await api.expirePreAuthKey(user, key);
await api.expirePreAuthKey(user, { id: keyId, key } as unknown as PreAuthKey);
return data("Pre-auth key expired");
}
default:
return data("Invalid action", {
status: 400,
@@ -29,7 +29,8 @@ function findCurrentUser(users: User[], subject: string | undefined): User | und
if (u.provider !== "oidc" || !u.providerId) {
return false;
}
return u.providerId.split("/").pop() === subject;
const segment = u.providerId.split("/").pop();
return segment ? decodeURIComponent(segment) === subject : false;
});
}
@@ -17,6 +17,7 @@ export default function ExpireAuthKey({ authKey, user }: ExpireAuthKeyProps) {
<Title>Expire auth key?</Title>
<input name="action_id" type="hidden" value="expire_preauthkey" />
<input name="user_id" type="hidden" value={user.id} />
<input name="key_id" type="hidden" value={authKey.id} />
<input name="key" type="hidden" value={authKey.key} />
<Text>
Expiring this authentication key will immediately prevent it from being used to
+1 -1
View File
@@ -20,7 +20,7 @@ import AddAuthKey from "./dialogs/add-auth-key";
export async function loader({ request, context }: Route.LoaderArgs) {
const principal = await context.auth.require(request);
const apiKey = context.auth.getHeadscaleApiKey(principal, context.oidc?.apiKey);
const apiKey = context.auth.getHeadscaleApiKey(principal);
const api = context.hsApi.getRuntimeClient(apiKey);
const usersSnap = await context.hsLive.get(usersResource, api);
+1 -2
View File
@@ -6,10 +6,9 @@ import PageError from "~/components/page-error";
import type { Route } from "./+types/overview";
export async function loader({ context }: Route.LoaderArgs) {
const oidcConnector = await context.oidc?.connector.get();
return {
config: context.hs.writable(),
isOidcEnabled: oidcConnector?.isValid ?? false,
isOidcEnabled: context.oidc?.service.status().state === "ready",
};
}
-278
View File
@@ -1,278 +0,0 @@
import { faker } from "@faker-js/faker";
import { eq } from "drizzle-orm";
import { Loader2 } from "lucide-react";
import { useEffect, useState } from "react";
import { data, type ShouldRevalidateFunction, useSubmit } from "react-router";
import { ExternalScriptsHandle } from "remix-utils/external-scripts";
import { EphemeralNodeInsert, ephemeralNodes } from "~/server/db/schema";
import { findHeadscaleUserBySubject } from "~/server/web/headscale-identity";
import { useLiveData } from "~/utils/live-data";
import log from "~/utils/log";
import type { Route } from "./+types/console";
import UserPrompt from "./user-prompt";
import XTerm from "./xterm.client";
export const shouldRevalidate: ShouldRevalidateFunction = () => {
return false;
};
export async function loader({ request, context }: Route.LoaderArgs) {
const origin = new URL(request.url).origin;
const assets = [`${__PREFIX__}/wasm_exec.js`, `${__PREFIX__}/hp_ssh.wasm`];
const missing: string[] = [];
for (const file of assets) {
const res = await fetch(`${origin}${file}`, { method: "HEAD" });
if (!res.ok) missing.push(file);
}
if (missing.length > 0) {
throw data("WebSSH is not configured in this build.", 405);
}
if (!context.agents) {
throw data("WebSSH is only available with the Headplane agent integration", 400);
}
const principal = await context.auth.require(request);
if (principal.kind === "api_key") {
throw data("Only OAuth users are allowed to use WebSSH", 403);
}
const apiKey = context.auth.getHeadscaleApiKey(principal, context.oidc?.apiKey);
const api = context.hsApi.getRuntimeClient(apiKey);
const users = await api.getUsers();
// MARK: This assumes that a user has authenticated with Headscale first
// Since the only way to enforce permissions via ACLs is to generate a
// pre-authkey which REQUIRES a user ID, meaning the user has to have
// authenticated with Headscale first.
const lookup = findHeadscaleUserBySubject(users, principal.user.subject, principal.profile.email);
if (!lookup) {
throw data(`User with subject ${principal.user.subject} not found within Headscale`, 404);
}
const preAuthKey = await api.createPreAuthKey(
lookup.id,
true, // ephemeral
false, // reusable
new Date(Date.now() + 60 * 1000), // expiration: 1 minute
null, // aclTags
);
// TODO: Enable config to enforce generate_authkeys capability
// For now, any user is capable of WebSSH connections
// const check = await context.sessions.check(
// request,
// Capabilities.generate_authkeys,
// );
const qp = new URL(request.url).searchParams;
const username = qp.get("username") || undefined;
const hostname = qp.get("hostname") || undefined;
if (!hostname) {
throw data("Missing required parameter: hostname", 400);
}
if (!username) {
return {
ipnDetails: undefined,
sshDetails: {
username,
hostname,
},
};
}
// We're making a request to <url>/key?v=116 to check the CORS headers
const u = context.config.headscale.public_url ?? context.config.headscale.url;
// const res = await fetch(`${u}/key?v=116`, {
// method: 'GET',
// });
// const corsOrigin = res.headers.get('Access-Control-Allow-Origin');
// const corsMethods = res.headers.get('Access-Control-Allow-Methods');
// const corsHeaders = res.headers.get('Access-Control-Allow-Headers');
// console.log(corsOrigin, corsMethods, corsHeaders);
// if (!corsOrigin || !corsMethods || !corsHeaders) {
// throw data(
// 'Headscale server does not have the required CORS headers for WebSSH',
// 500,
// );
// }
const nodes = await api.getNodes();
const lookupNode = nodes.find((n) => n.givenName === hostname);
if (!lookupNode) {
throw data(`Node with hostname ${hostname} not found`, 404);
}
// Last thing is keeping track of the ephemeral node in the database
// because Headscale doesn't automatically delete ephemeral nodes???
const [_ephemeralNode] = await context.db
.insert(ephemeralNodes)
.values({
auth_key: preAuthKey.key,
} satisfies EphemeralNodeInsert)
.returning();
return {
ipnDetails: {
PreAuthKey: preAuthKey.key,
Hostname: generateHostname(username),
ControlURL: u,
},
sshDetails: {
username,
hostname,
},
};
}
function generateHostname(username: string) {
const adjective = faker.word.adjective({
length: {
min: 3,
max: 6,
},
});
const noun = faker.word.noun({
length: {
min: 3,
max: 6,
},
});
return `ssh-${adjective}-${noun}-${username}`;
}
export async function action({ request, context }: Route.ActionArgs) {
await context.auth.require(request);
if (!context.agents) {
throw data("WebSSH is only available with the Headplane agent integration", 400);
}
const form = await request.formData();
const nodeKey = form.get("node_key");
const authKey = form.get("auth_key");
if (nodeKey === null || typeof nodeKey !== "string") {
throw data("Missing node_key", 400);
}
if (authKey === null || typeof authKey !== "string") {
throw data("Missing auth_key", 400);
}
await context.db
.update(ephemeralNodes)
.set({
node_key: nodeKey,
})
.where(eq(ephemeralNodes.auth_key, authKey));
context.agents?.triggerSync().catch((err) => {
log.debug("agent", "Background agent sync failed: %s", err);
});
}
export const links: Route.LinksFunction = () => [
{
rel: "preload",
href: `${__PREFIX__}/hp_ssh.wasm`,
as: "fetch",
type: "application/wasm",
crossOrigin: "anonymous",
},
];
export const handle: ExternalScriptsHandle = {
scripts: [
{
src: `${__PREFIX__}/wasm_exec.js`,
crossOrigin: "anonymous",
preload: true,
},
],
};
export default function Page({ loaderData: { ipnDetails, sshDetails } }: Route.ComponentProps) {
const submit = useSubmit();
const { pause } = useLiveData();
const [ipn, setIpn] = useState<TsWasmNet | null>(null);
const [nodeKey, setNodeKey] = useState<string | null>(null);
useEffect(() => {
if (!ipnDetails) {
return;
}
pause();
const go = new Go(); // Go is defined by wasm_exec.js
WebAssembly.instantiateStreaming(fetch(`${__PREFIX__}/hp_ssh.wasm`), go.importObject).then(
(value) => {
go.run(value.instance);
const handle = TsWasmNet(ipnDetails, {
NotifyState: (state) => {
console.log("State changed:", state);
if (state === "Running") {
setIpn(handle);
}
},
NotifyNetMap: (netmap) => {
// Only set NodeKey if it is not already set and then
// also dispatch that to the backend to track the
// ephemeral node.
//
// We open an SSE connection to the backend
// so that when the connection is closed,
// the backend can delete the ephemeral node.
if (nodeKey === null) {
setNodeKey(netmap.NodeKey);
submit(
{
node_key: netmap.NodeKey,
auth_key: ipnDetails.PreAuthKey,
},
{ method: "POST" },
);
}
},
NotifyBrowseToURL: (url) => {
console.log("Browse to URL:", url);
},
NotifyPanicRecover: (message) => {
console.error("Panic recover:", message);
},
});
handle.Start();
},
);
}, []);
if (!sshDetails.username) {
return <UserPrompt hostname={sshDetails.hostname} />;
}
return (
<div className="h-screen w-screen bg-mist-900">
{ipn === null ? (
<div className="mx-auto flex h-screen items-center justify-center">
<Loader2 className="size-10 animate-spin text-mist-50" />
</div>
) : (
<div className="flex h-screen flex-col">
<XTerm hostname={sshDetails.hostname} ipn={ipn} username={sshDetails.username} />
</div>
)}
</div>
);
}
+77
View File
@@ -0,0 +1,77 @@
import { AlertCircle } from "lucide-react";
import Card from "~/components/card";
import Link from "~/components/link";
export const sshErrors = {
wasm_missing: {
title: "Browser SSH is not available",
message: "This version of Headplane was not built with browser SSH support.",
anchor: "#ssh-not-available",
},
agent_required: {
title: "Browser SSH requires the Headplane agent",
message: "Browser SSH is only available when the Headplane agent integration is enabled.",
anchor: "#agent-required",
},
oidc_required: {
title: "Browser SSH requires OIDC authentication",
message: "Browser SSH is only available when OIDC authentication is enabled.",
anchor: "#oidc-required",
},
node_not_found: (hostname: string) => ({
title: "Node not found",
message: `No node found with hostname ${hostname}.`,
anchor: "#node-not-found",
}),
user_not_linked: {
title: "User account not linked",
message:
"You'll need to link your user account to a Headscale user before you can use Browser SSH.",
anchor: "#user-not-linked",
},
} as const;
interface SSHErrorBoundaryProps {
title: string;
message: string;
anchor: string;
}
export function isSSHError(error: unknown): error is SSHErrorBoundaryProps {
return (
typeof error === "object" &&
error !== null &&
"title" in error &&
"message" in error &&
"anchor" in error &&
typeof error.title === "string" &&
typeof error.message === "string" &&
typeof error.anchor === "string"
);
}
const DOCS_BASE = "https://headplane.net/features/ssh";
export function SSHErrorBoundary({ title, message, anchor }: SSHErrorBoundaryProps) {
return (
<Card className="w-screen" variant="flat">
<div className="flex items-center justify-between gap-4">
<Card.Title>{title}</Card.Title>
<AlertCircle className="mb-2 h-6 w-6 text-red-500" />
</div>
<Card.Text>
{message}
<br />
<br />
<Link to={`${DOCS_BASE}${anchor}`} external styled>
Headplane SSH Documentation
</Link>{" "}
</Card.Text>
</Card>
);
}
+137
View File
@@ -0,0 +1,137 @@
import { useEffect, useRef } from "react";
import { Restty } from "restty";
import type { GhosttyTheme } from "restty";
import type { PtyTransport } from "restty/internal";
import type { HeadplaneSSH, TunnelSession } from "./wasm.client";
const FONT_BASE = `${__PREFIX__}/fonts`;
// Ghostty's default canvas background is rgb(20,23,26) — a dark gray, not black.
// Override it so the terminal matches the page and pane container backgrounds.
const HEADPLANE_THEME: GhosttyTheme = {
colors: {
background: { r: 0, g: 0, b: 0 },
foreground: { r: 235, g: 237, b: 242 },
palette: [],
},
raw: {},
};
function createSSHTransport(ssh: HeadplaneSSH, ipAddress: string, username: string): PtyTransport {
let session: TunnelSession | null = null;
return {
connect(options) {
session = ssh.openTunnel({
ipAddress,
username,
onData: (data) => options.callbacks.onData?.(data),
onConnect: () => options.callbacks.onConnect?.(),
onDisconnect: () => {
options.callbacks.onDisconnect?.();
session = null;
},
});
if (options.cols && options.rows) {
session.resize(options.cols, options.rows);
}
},
disconnect() {
session?.close();
session = null;
},
sendInput(data) {
session?.writeInput(data);
return session != null;
},
resize(cols, rows) {
session?.resize(cols, rows);
return session != null;
},
isConnected() {
return session != null;
},
destroy() {
session?.close();
session = null;
},
};
}
interface GhosttyProps {
ssh: HeadplaneSSH;
ipAddress: string;
username: string;
onConnected: () => void;
}
export default function Ghostty({ ssh, ipAddress, username, onConnected }: GhosttyProps) {
const divRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!divRef.current) return;
const transport = createSSHTransport(ssh, ipAddress, username);
const restty = new Restty({
root: divRef.current,
createInitialPane: true,
defaultContextMenu: false,
shortcuts: false,
searchUi: false,
paneStyles: {
inactivePaneOpacity: 1,
activePaneOpacity: 1,
},
appOptions: {
fontSize: 20,
ligatures: true,
fontPreset: "none",
fontSources: [
{
type: "url",
url: `${FONT_BASE}/JetBrainsMonoNLNerdFontMono-Regular.ttf`,
label: "JetBrains Mono Nerd Font",
},
{
type: "url",
url: `${FONT_BASE}/JetBrainsMonoNLNerdFontMono-Bold.ttf`,
label: "JetBrains Mono Nerd Font Bold",
},
{
type: "url",
url: `${FONT_BASE}/JetBrainsMonoNLNerdFontMono-Italic.ttf`,
label: "JetBrains Mono Nerd Font Italic",
},
{
type: "url",
url: `${FONT_BASE}/JetBrainsMonoNLNerdFontMono-BoldItalic.ttf`,
label: "JetBrains Mono Nerd Font Bold Italic",
},
{
type: "url",
url: `${FONT_BASE}/SymbolsNerdFontMono-Regular.ttf`,
label: "Symbols Nerd Font",
},
],
ptyTransport: transport,
callbacks: {
onPtyStatus: (status) => {
if (status === "connected") onConnected();
},
},
},
});
restty.applyTheme(HEADPLANE_THEME);
restty.updateSize(true);
restty.connectPty();
return () => {
restty.destroy();
};
}, [ssh, ipAddress, username]);
return <div className="min-h-0 min-w-0 flex-1 overflow-hidden bg-black" ref={divRef} />;
}
-57
View File
@@ -1,57 +0,0 @@
declare function TsWasmNet(
options: TsWasmNetOptions,
callbacks: TsWasmNetCallbacks,
): TsWasmNet;
interface TsWasmNetOptions {
ControlURL: string;
PreAuthKey: string;
Hostname: string;
}
interface TsWasmNetCallbacks {
NotifyState: (state: IPNState) => void;
NotifyNetMap: (netmap: TsWasmNetMap) => void;
NotifyBrowseToURL: (url: string) => void;
NotifyPanicRecover: (err: string) => void;
}
interface TsWasmNetMap {
NodeKey: string;
}
interface TsWasmNet {
Start: () => void;
OpenSSH: (
hostname: string,
username: string,
options: XtermConfig,
) => SSHSession;
}
type IPNState =
| 'NoState'
| 'InUseOtherUser'
| 'NeedsLogin'
| 'NeedsMachineAuth'
| 'Stopped'
| 'Starting'
| 'Running';
interface XtermConfig {
rows: number;
cols: number;
timeout?: number;
onStdout: (data: Uint8Array) => void;
onStderr: (data: Uint8Array) => void;
onStdin: (func: (input: Uint8Array) => void) => void;
onConnect: () => void;
onDisconnect: () => void;
}
interface SSHSession {
Close(): boolean;
Resize(rows: number, cols: number): boolean;
}
+240
View File
@@ -0,0 +1,240 @@
import { Loader2, WifiOff } from "lucide-react";
import { useEffect, useState } from "react";
import { data, isRouteErrorResponse, type ShouldRevalidateFunction } from "react-router";
import { ExternalScriptsHandle } from "remix-utils/external-scripts";
import Button from "~/components/button";
import Card from "~/components/card";
import Code from "~/components/code";
import { findHeadscaleUserBySubject } from "~/server/web/headscale-identity";
import type { Route } from "./+types/page";
import { isSSHError, SSHErrorBoundary, sshErrors } from "./errors";
import Ghostty from "./ghostty.client";
import UserPrompt from "./user-prompt";
import type { HeadplaneSSH } from "./wasm.client";
import { loadHeadplaneWASM } from "./wasm.client";
const WASM_MODULE_URL = `${__PREFIX__}/hp_ssh.wasm`;
const WASM_HELPER_URL = `${__PREFIX__}/wasm_exec.js`;
export const shouldRevalidate: ShouldRevalidateFunction = () => {
return false;
};
export async function loader({ request, params, context }: Route.LoaderArgs) {
const origin = new URL(request.url).origin;
const assets = [WASM_HELPER_URL, WASM_MODULE_URL];
const missing: string[] = [];
for (const file of assets) {
const res = await fetch(`${origin}${file}`, { method: "HEAD" });
if (!res.ok) {
missing.push(file);
}
}
if (missing.length > 0) {
throw data(sshErrors.wasm_missing, 405);
}
if (context.agents == null) {
throw data(sshErrors.agent_required, 400);
}
const principal = await context.auth.require(request);
if (principal.kind === "api_key") {
throw data(sshErrors.oidc_required, 403);
}
const apiKey = context.auth.getHeadscaleApiKey(principal);
const api = context.hsApi.getRuntimeClient(apiKey);
const hostname = params.id;
const username = new URL(request.url).searchParams.get("user") || undefined;
const nodes = await api.getNodes();
const node = nodes.find((n) => n.givenName === hostname);
if (!node) {
throw data(sshErrors.node_not_found(hostname), 404);
}
if (!node.online) {
return { hostname, username, offline: true, node: undefined };
}
if (!username) {
return { hostname, username: undefined, offline: false, node: undefined };
}
// The user must exist within Headscale to generate a pre-auth key
const users = await api.getUsers();
const hsUser = findHeadscaleUserBySubject(users, principal.user.subject, principal.profile.email);
if (!hsUser) {
throw data(sshErrors.user_not_linked, 404);
}
const preAuthKey = await api.createPreAuthKey(
hsUser.id,
true,
false,
new Date(Date.now() + 60 * 1000), // 1 minute expiry
null,
);
const controlURL = context.config.headscale.public_url ?? context.config.headscale.url;
return {
hostname,
username,
offline: false,
node: {
ipAddress: node.ipAddresses[0],
controlURL,
preAuthKey: preAuthKey.key,
ephemeralHostname: generateHostname(username),
},
};
}
function generateHostname(username: string) {
const hex = crypto.randomUUID().replace(/-/g, "").slice(0, 8);
return `ssh-${hex}-${username}`;
}
export const links: Route.LinksFunction = () => [
{
rel: "preload",
href: WASM_MODULE_URL,
as: "fetch",
type: "application/wasm",
crossOrigin: "anonymous",
},
];
export const handle: ExternalScriptsHandle = {
scripts: [
{
src: WASM_HELPER_URL,
crossOrigin: "anonymous",
preload: true,
},
],
};
export default function Page({ loaderData }: Route.ComponentProps) {
const { hostname, username, offline, node } = 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>
);
}
if (!username || !node) {
return <UserPrompt hostname={hostname} />;
}
return <SSHConsole hostname={hostname} username={username} node={node} />;
}
function SSHConsole({
hostname,
username,
node,
}: {
hostname: string;
username: string;
node: { ipAddress: string; controlURL: string; preAuthKey: string; ephemeralHostname: string };
}) {
const [ssh, setSsh] = useState<HeadplaneSSH | null>(null);
const [connected, setConnected] = useState(false);
const [status, setStatus] = useState("Starting tunnel…");
useEffect(() => {
let cancelled = false;
console.log("[ssh] Loading WASM factory");
loadHeadplaneWASM().then((create) => {
console.log("[ssh] Factory loaded, creating IPN", create);
if (cancelled) {
return;
}
setStatus("Joining Tailnet…");
const instance = create({
controlURL: node.controlURL,
preAuthKey: node.preAuthKey,
hostname: node.ephemeralHostname,
onReady: () => {
console.log("[ssh] IPN ready (Running)");
if (!cancelled) {
setStatus(`Connecting to ${hostname}`);
setSsh(instance);
}
},
onError: (msg) => console.error("[ssh] IPN error:", msg),
});
console.log("[ssh] IPN instance created", instance);
});
return () => {
cancelled = true;
};
}, [node]);
return (
<div className="fixed inset-0 flex flex-col bg-black">
{!connected && (
<div className="absolute inset-0 z-50 flex items-center justify-center">
<div className="flex flex-col items-center gap-3">
<Loader2 className="size-8 animate-spin text-mist-200" />
<p className="text-sm text-mist-400">{status}</p>
</div>
</div>
)}
{ssh && (
<Ghostty
ssh={ssh}
username={username}
ipAddress={node.ipAddress}
onConnected={() => setConnected(true)}
/>
)}
</div>
);
}
export function ErrorBoundary({ error }: Route.ErrorBoundaryProps) {
const routeError = isRouteErrorResponse(error) ? error.data : null;
if (routeError == null || !isSSHError(routeError)) {
// Pass through further down the tree to the global error boundary
throw error;
}
return (
<div className="flex h-screen w-screen items-center justify-center">
<SSHErrorBoundary
title={routeError.title}
message={routeError.message}
anchor={routeError.anchor}
/>
</div>
);
}
+40 -21
View File
@@ -1,17 +1,16 @@
import { useState } from "react";
import { Form } from "react-router";
import Button from "~/components/button";
import Card from "~/components/card";
import Code from "~/components/code";
import Input from "~/components/input";
import Link from "~/components/link";
interface UserPromptProps {
hostname: string;
}
export default function UserPrompt({ hostname }: UserPromptProps) {
const [username, setUsername] = useState("");
return (
<div className="flex h-screen items-center justify-center">
<Card>
@@ -19,27 +18,47 @@ export default function UserPrompt({ hostname }: UserPromptProps) {
<Card.Text className="mb-4">
Enter the username you want to use to connect to <Code>{hostname}</Code>
{". "}
WebSSH follows the Headscale ACLs, so only permitted usernames will be able to connect.
SSH via the web follows the same ACL rules as regular SSH access in Headscale, so only
permitted usernames will work.
<br />
<br />
See the{" "}
<Link external styled to="https://headplane.net/features/ssh#troubleshooting">
troubleshooting guide
</Link>{" "}
for common errors.
</Card.Text>
<Input
labelHidden
type="text"
label="Username"
placeholder="Username"
className="mb-2"
onChange={setUsername}
/>
<Button
variant="heavy"
className="w-full"
onClick={() => {
// We can't use the navigate hook here as we need to do a
// full page reload to ensure the SSH connection is established
window.location.href = `${__PREFIX__}/ssh?hostname=${hostname}&username=${username}`;
<Form
method="GET"
onSubmit={(e) => {
const formData = new FormData(e.currentTarget);
const username = formData.get("user");
if (!username) {
e.preventDefault();
return;
}
// We have to do a full navigation, since the page needs a full
// reload to initialize the SSH connection due to us disabling the
// revalidator.
const url = new URL(window.location.href);
url.searchParams.set("user", username.toString());
window.location.assign(url.toString());
}}
>
Connect
</Button>
<Input
labelHidden
type="text"
label="Username"
name="user"
placeholder="Username"
className="mb-2"
required
/>
<Button type="submit" variant="heavy" className="w-full">
Connect
</Button>
</Form>
</Card>
</div>
);
+64
View File
@@ -0,0 +1,64 @@
const WASM_MODULE_URL = `${__PREFIX__}/hp_ssh.wasm`;
declare global {
type HeadplaneSSHFactory = (config: HeadplaneSSHConfig) => HeadplaneSSH;
var __hp_ssh_resolve: ((factory: HeadplaneSSHFactory) => void) | undefined;
var Go: {
new (): {
importObject: WebAssembly.Imports;
run(instance: WebAssembly.Instance): Promise<void>;
argv?: string[];
env?: Record<string, string>;
exit?: (code: number) => void;
};
};
}
interface HeadplaneSSHConfig {
controlURL: string;
preAuthKey: string;
hostname: string;
onReady: () => void;
onError?: (message: string) => void;
}
export interface HeadplaneSSH {
openTunnel(config: TunnelConfig): TunnelSession;
}
interface TunnelConfig {
ipAddress: string;
username: string;
timeout?: number;
onData: (data: string) => void;
onConnect: () => void;
onDisconnect: () => void;
}
export interface TunnelSession {
writeInput(data: string): void;
resize(cols: number, rows: number): void;
close(): void;
}
let resolvedFactory: Promise<HeadplaneSSHFactory> | null = null;
/**
* One-shot function that loads the Go WASM binary and returns the SSH factory.
* It expects the Go WASM helper to be loaded, and will error if called before.
*/
export async function loadHeadplaneWASM(): Promise<HeadplaneSSHFactory> {
if (!resolvedFactory) {
const go = new Go();
const result = await WebAssembly.instantiateStreaming(fetch(WASM_MODULE_URL), go.importObject);
resolvedFactory = new Promise<HeadplaneSSHFactory>((resolve) => {
globalThis.__hp_ssh_resolve = resolve;
});
go.run(result.instance);
}
return resolvedFactory;
}
-7
View File
@@ -1,7 +0,0 @@
declare class Go {
importObject: WebAssembly.Imports;
run(instance: WebAssembly.Instance): Promise<void>;
argv?: string[];
env?: Record<string, string>;
exit?: (code: number) => void;
}
-218
View File
@@ -1,218 +0,0 @@
import { ClipboardAddon } from "@xterm/addon-clipboard";
import { FitAddon } from "@xterm/addon-fit";
import { Unicode11Addon } from "@xterm/addon-unicode11";
import { WebLinksAddon } from "@xterm/addon-web-links";
import * as xterm from "@xterm/xterm";
import { Loader2 } from "lucide-react";
import { useEffect, useRef, useState } from "react";
import cn from "~/utils/cn";
import { useLiveData } from "~/utils/live-data";
import toast from "~/utils/toast";
import "@xterm/xterm/css/xterm.css";
interface XTermProps {
ipn: TsWasmNet;
username: string;
hostname: string;
}
// Go's WASM -> JS crosses realms so we might have to normalize the data under
// certain conditions. This also enforces bytes instead of strings being sent.
function normU8(data: unknown) {
if (data instanceof Uint8Array) {
return data;
}
if (data && typeof data === "object") {
const any = data as {
buffer?: ArrayBufferLike;
byteOffset?: number;
byteLength?: number;
};
if (any.buffer instanceof ArrayBuffer && typeof any.byteLength === "number") {
return new Uint8Array(
any.buffer.slice(any.byteOffset ?? 0, (any.byteOffset ?? 0) + any.byteLength),
);
}
}
throw new Error("Data is not a Uint8Array or ArrayBuffer-like object");
}
export default function XTerm({ ipn, username, hostname }: XTermProps) {
const { pause } = useLiveData();
const genRef = useRef(0);
const termRef = useRef<xterm.Terminal>(null);
const roRef = useRef<ResizeObserver>(null);
const inputRef = useRef<(input: Uint8Array) => void>(null);
const sshRef = useRef<SSHSession>(null);
const divRef = useRef<HTMLDivElement>(null);
const [isResizing, setIsResizing] = useState(false);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
pause();
});
useEffect(() => {
if (!divRef.current) {
return;
}
const currentGen = ++genRef.current;
const term = new xterm.Terminal({
allowProposedApi: true,
cursorBlink: true,
convertEol: true,
fontSize: 14,
});
const fit = new FitAddon();
term.loadAddon(fit);
term.loadAddon(new Unicode11Addon());
term.loadAddon(new ClipboardAddon());
term.loadAddon(
new WebLinksAddon((event, uri) => {
event.view?.open(uri, "_blank", "noopener noreferrer");
}),
);
term.unicode.activeVersion = "11";
termRef.current = term;
term.open(divRef.current!);
fit.fit();
term.focus();
const session = ipn.OpenSSH(hostname, username, {
rows: term.rows,
cols: term.cols,
onStdout: (data) => {
if (currentGen !== genRef.current || term !== termRef.current) {
console.warn("Stale terminal instance, ignoring stdout");
return;
}
const text = normU8(data);
term.write(text);
},
onStderr: (data) => {
if (currentGen !== genRef.current || term !== termRef.current) {
console.warn("Stale terminal instance, ignoring stderr");
return;
}
const text = normU8(data);
term.write(text);
const str = new TextDecoder().decode(text);
setError(str);
},
onStdin: (func) => {
inputRef.current = func;
},
onConnect: () => {
if (currentGen !== genRef.current) {
console.warn("Stale terminal instance, ignoring onConnect");
return;
}
setIsLoading(false);
},
onDisconnect: () => {
if (currentGen !== genRef.current) {
console.warn("Stale terminal instance, ignoring onDisconnect");
return;
}
roRef.current?.disconnect();
term.dispose();
termRef.current = null;
inputRef.current = null;
sshRef.current = null;
setIsLoading(false);
},
});
sshRef.current = session;
const enc = new TextEncoder();
term.onData((data) => {
if (currentGen !== genRef.current) {
console.warn("Stale terminal instance, ignoring onData");
return;
}
const bytes = enc.encode(data);
inputRef.current?.(bytes);
});
const ro = new ResizeObserver(() => {
if (currentGen !== genRef.current || term !== termRef.current) {
console.warn("Stale terminal instance, ignoring resize");
return;
}
setIsResizing(true);
fit.fit();
sshRef.current?.Resize(term.cols, term.rows);
setTimeout(() => setIsResizing(false), 100);
});
roRef.current = ro;
ro.observe(divRef.current!);
return () => {
++genRef.current;
roRef.current?.disconnect();
roRef.current = null;
sshRef.current?.Close();
sshRef.current = null;
term.dispose();
if (termRef.current === term) {
termRef.current = null;
}
inputRef.current = null;
};
}, [ipn, username, hostname]);
return (
<>
{isLoading ? (
<div className="absolute z-50 mx-auto flex h-screen w-screen items-center justify-center">
<Loader2 className="size-10 animate-spin text-mist-50" />
</div>
) : undefined}
<div className={cn("w-full h-full", isLoading ? "opacity-0" : "opacity-100")} ref={divRef} />
{termRef.current && isResizing ? (
<div
className={cn(
"absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2",
"px-4 py-2 bg-mist-800 text-white rounded-full shadow z-50",
)}
>
{termRef.current.cols}x{termRef.current.rows}
</div>
) : undefined}
{error !== null ? (
<div
className={cn(
"absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 text-center",
"px-4 py-2 bg-mist-800 text-white rounded-full shadow z-50",
)}
>
Failed to connect to SSH session
{error}
</div>
) : undefined}
</>
);
}
@@ -30,8 +30,8 @@ export default function HeadplaneUserRow({
const displayEmail = user.linkedHeadscaleUser?.email ?? user.email;
return (
<tr className="group hover:bg-mist-50 dark:hover:bg-mist-950" key={user.id}>
<td className="py-2 pl-0.5">
<tr className="group hover:bg-mist-100 dark:hover:bg-mist-800" key={user.id}>
<td className="py-2 pl-2">
<div className="flex items-center">
{user.profilePicUrl ? (
<img alt={displayName} className="h-10 w-10 rounded-full" src={user.profilePicUrl} />
+1 -1
View File
@@ -54,7 +54,7 @@ export async function loader({ request, context }: Route.LoaderArgs) {
let apiError: string | undefined;
try {
const apiKey = context.auth.getHeadscaleApiKey(principal, context.oidc?.apiKey);
const apiKey = context.auth.getHeadscaleApiKey(principal);
const api = context.hsApi.getRuntimeClient(apiKey);
const [nodesSnap, usersSnap] = await Promise.all([
context.hsLive.get(nodesResource, api),
+1 -1
View File
@@ -24,7 +24,7 @@ export async function userAction({ request, context }: Route.ActionArgs) {
});
}
const apiKey = context.auth.getHeadscaleApiKey(principal, context.oidc?.apiKey);
const apiKey = context.auth.getHeadscaleApiKey(principal);
const api = context.hsApi.getRuntimeClient(apiKey);
switch (action) {
case "create_user": {
+1 -1
View File
@@ -5,7 +5,7 @@ import type { Route } from "./+types/live";
export async function loader({ request, context }: Route.LoaderArgs) {
const principal = await context.auth.require(request);
const apiKey = context.auth.getHeadscaleApiKey(principal, context.oidc?.apiKey);
const apiKey = context.auth.getHeadscaleApiKey(principal);
const api = context.hsApi.getRuntimeClient(apiKey);
// Ensure resources are loaded before streaming
-53
View File
@@ -1,53 +0,0 @@
import { eq, isNotNull } from "drizzle-orm";
import { nodesResource } from "~/server/headscale/live-store";
import log from "~/utils/log";
import type { Route } from "../../layout/+types/app";
import { ephemeralNodes } from "./schema";
export async function pruneEphemeralNodes({ context, request }: Route.LoaderArgs) {
const principal = await context.auth.require(request);
const ephemerals = await context.db
.select()
.from(ephemeralNodes)
.where(isNotNull(ephemeralNodes.node_key));
if (ephemerals.length === 0) {
log.debug("api", "No ephemeral nodes to prune");
return;
}
const apiKey = context.auth.getHeadscaleApiKey(principal, context.oidc?.apiKey);
const api = context.hsApi.getRuntimeClient(apiKey);
const nodes = await api.getNodes();
const toPrune = nodes.filter((node) => {
if (node.online) {
return false;
}
return ephemerals.some((ephemeral) => node.nodeKey === ephemeral.node_key);
});
if (toPrune.length === 0) {
log.debug("api", "No SSH nodes to prune");
return;
}
// Delete from the Headscale nodes list and then from the database
const promises = toPrune.map((node) => {
return async () => {
log.debug("api", `Pruning node ${node.name}`);
await api.deleteNode(node.id);
await context.db.delete(ephemeralNodes).where(eq(ephemeralNodes.node_key, node.nodeKey));
log.debug("api", `Node ${node.name} pruned successfully`);
};
});
await Promise.all(promises.map((p) => p()));
if (toPrune.length > 0) {
await context.hsLive.refresh(nodesResource, api);
}
}
-8
View File
@@ -2,14 +2,6 @@ import { integer, sqliteTable, text } from "drizzle-orm/sqlite-core";
import { HostInfo } from "~/types";
export const ephemeralNodes = sqliteTable("ephemeral_nodes", {
auth_key: text("auth_key").primaryKey(),
node_key: text("node_key"),
});
export type EphemeralNode = typeof ephemeralNodes.$inferSelect;
export type EphemeralNodeInsert = typeof ephemeralNodes.$inferInsert;
export const hostInfo = sqliteTable("host_info", {
host_id: text("host_id").primaryKey(),
payload: text("payload", { mode: "json" }).$type<HostInfo>(),
@@ -34,7 +34,7 @@ export interface PreAuthKeyEndpoints {
* @param user The user associated with the pre-authentication key.
* @param key The pre-authentication key to expire.
*/
expirePreAuthKey(user: string, key: string): Promise<void>;
expirePreAuthKey(user: string, key: PreAuthKey): Promise<void>;
}
export default defineApiEndpoints<PreAuthKeyEndpoints>((client, apiKey) => ({
@@ -77,9 +77,17 @@ export default defineApiEndpoints<PreAuthKeyEndpoints>((client, apiKey) => ({
},
expirePreAuthKey: async (user, key) => {
if (client.isAtleast("0.28.0")) {
await client.apiFetch<void>("POST", "v1/preauthkey/expire", apiKey, {
id: key.id,
});
return;
}
await client.apiFetch<void>("POST", "v1/preauthkey/expire", apiKey, {
user,
key,
key: key.key,
});
},
}));
+115 -59
View File
@@ -1,7 +1,7 @@
import { execFile } from "node:child_process";
import { type ChildProcess, spawn } from "node:child_process";
import { access, constants, mkdir, rm, stat } from "node:fs/promises";
import { join } from "node:path";
import { promisify } from "node:util";
import { createInterface } from "node:readline";
import { inArray, notInArray } from "drizzle-orm";
import { NodeSQLiteDatabase } from "drizzle-orm/node-sqlite";
@@ -10,11 +10,9 @@ import { HostInfo } from "~/types";
import log from "~/utils/log";
import { HeadplaneConfig } from "./config/config-schema";
import { ephemeralNodes, hostInfo } from "./db/schema";
import { hostInfo } from "./db/schema";
import { RuntimeApiClient } from "./headscale/api/endpoints";
const execFileAsync = promisify(execFile);
export interface AgentManager {
lookup(nodeKeys: string[]): Promise<Record<string, HostInfo>>;
lastSync(): { syncedAt: Date | null; nodeCount: number; error?: string };
@@ -26,6 +24,7 @@ export interface AgentManager {
interface AgentOutput {
self: string;
hosts: Record<string, HostInfo>;
error?: string;
}
interface SyncState {
@@ -33,8 +32,6 @@ interface SyncState {
nodeCount: number;
selfKey?: string;
error?: string;
isSyncing: boolean;
pendingResync: boolean;
}
async function hasExistingState(workDir: string): Promise<boolean> {
@@ -95,10 +92,13 @@ export async function createAgentManager(
const state: SyncState = {
syncedAt: null,
nodeCount: 0,
isSyncing: false,
pendingResync: false,
};
let proc: ChildProcess | null = null;
let responseHandler: ((line: string) => void) | null = null;
let disposed = false;
let consecutiveErrors = 0;
async function generateAuthKey(): Promise<string> {
const expiration = new Date(Date.now() + 5 * 60_000);
const pak = await apiClient.createPreAuthKey(null, false, false, expiration, [
@@ -107,7 +107,7 @@ export async function createAgentManager(
return pak.key;
}
async function runAgent(authKey: string): Promise<string> {
function spawnAgent(authKey: string): ChildProcess {
const env: Record<string, string> = {
HOME: process.env.HOME ?? "",
HEADPLANE_AGENT_WORK_DIR: workDir,
@@ -120,53 +120,105 @@ export async function createAgentManager(
env.HEADPLANE_AGENT_TS_AUTHKEY = authKey;
}
const { stdout } = await execFileAsync(executablePath, [], {
timeout: 60_000,
const child = spawn(executablePath, [], {
env,
stdio: ["pipe", "pipe", "pipe"],
});
return stdout;
child.stderr?.on("data", (chunk: Buffer) => {
const text = chunk.toString().trim();
if (text) {
log.debug("agent", "%s", text);
}
});
const rl = createInterface({ input: child.stdout! });
rl.on("line", (line) => {
if (responseHandler) {
const handler = responseHandler;
responseHandler = null;
handler(line);
}
});
child.on("exit", (code, signal) => {
if (!disposed) {
log.warn("agent", "Agent process exited (code=%s, signal=%s)", code, signal);
}
proc = null;
// Reject any pending sync request
if (responseHandler) {
const handler = responseHandler;
responseHandler = null;
handler("");
}
});
proc = child;
return child;
}
async function ensureProcess(): Promise<ChildProcess> {
if (proc && proc.exitCode === null) {
return proc;
}
const stateExists = await hasExistingState(workDir);
if (stateExists) {
log.debug("agent", "Reusing existing tsnet identity");
return spawnAgent("");
}
log.info("agent", "No tsnet state found, generating pre-auth key");
return spawnAgent(await generateAuthKey());
}
function sendSync(child: ChildProcess): Promise<string> {
return new Promise((resolve) => {
responseHandler = resolve;
child.stdin?.write("sync\n");
});
}
async function requestSync(child: ChildProcess): Promise<AgentOutput> {
const line = await sendSync(child);
if (!line) {
throw new Error("Agent process closed unexpectedly");
}
return JSON.parse(line) as AgentOutput;
}
let isSyncing = false;
let pendingResync = false;
async function sync() {
if (state.isSyncing) {
state.pendingResync = true;
if (isSyncing) {
pendingResync = true;
log.debug("agent", "Sync already in progress, queued resync");
return;
}
state.isSyncing = true;
isSyncing = true;
try {
const stateExists = await hasExistingState(workDir);
const authKey = stateExists ? "" : await generateAuthKey();
const child = await ensureProcess();
const output = await requestSync(child);
if (stateExists) {
log.debug("agent", "Reusing existing tsnet identity");
}
if (output.error) {
consecutiveErrors++;
state.error = output.error;
log.error("agent", "Sync error from agent (%d/5): %s", consecutiveErrors, output.error);
let stdout: string;
try {
stdout = await runAgent(authKey);
} catch (err) {
if (!stateExists) {
throw err;
}
// Retry once with existing state (e.g. stale lock from a previous run)
log.info("agent", "Agent failed with existing state, retrying");
try {
const retryKey = await generateAuthKey();
stdout = await runAgent(retryKey);
} catch {
// Only clear identity as a last resort
log.warn("agent", "Retry failed, clearing state and starting fresh");
if (consecutiveErrors >= 5 && proc) {
log.warn("agent", "Too many consecutive errors, killing agent and clearing state");
proc.kill("SIGTERM");
proc = null;
await rm(join(workDir, "tailscaled.state"), { force: true });
const freshKey = await generateAuthKey();
stdout = await runAgent(freshKey);
}
return;
}
const output = JSON.parse(stdout) as AgentOutput;
consecutiveErrors = 0;
const keys = Object.keys(output.hosts);
for (const [nodeKey, payload] of Object.entries(output.hosts)) {
@@ -196,18 +248,28 @@ export async function createAgentManager(
log.info("agent", "Sync complete: %d nodes updated", keys.length);
} catch (error) {
consecutiveErrors++;
const message = error instanceof Error ? error.message : String(error);
state.error = message;
log.error("agent", "Sync failed: %s", message);
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 });
}
} finally {
state.isSyncing = false;
if (state.pendingResync) {
state.pendingResync = false;
isSyncing = false;
if (pendingResync) {
pendingResync = false;
sync();
}
}
}
/**
* Prunes any offline nodes marked as ephemeral. This is due to a Headscale
* bug where ephemeral nodes wouldn't be automatically removed on disconnect.
*/
async function pruneStaleHostInfo() {
try {
const nodes = await apiClient.getNodes();
@@ -236,23 +298,12 @@ export async function createAgentManager(
async function pruneEphemeralNodes() {
try {
const rows = await db.select().from(ephemeralNodes);
if (rows.length === 0) {
return;
}
const nodes = await apiClient.getNodes();
const activeKeys = new Set(nodes.map((n) => n.nodeKey));
const toPrune = nodes.filter((n) => n.preAuthKey?.ephemeral && !n.online);
for (const row of rows) {
if (!row.node_key) {
continue;
}
if (!activeKeys.has(row.node_key)) {
await db.delete(ephemeralNodes).where(inArray(ephemeralNodes.auth_key, [row.auth_key]));
log.info("agent", "Pruned ephemeral SSH node %s", row.node_key);
}
for (const node of toPrune) {
await apiClient.deleteNode(node.id);
log.info("agent", "Pruned offline ephemeral node %s", node.givenName);
}
} catch (error) {
log.debug(
@@ -299,7 +350,12 @@ export async function createAgentManager(
},
dispose() {
disposed = true;
clearInterval(interval);
if (proc) {
proc.kill("SIGTERM");
proc = null;
}
},
};
}
+22 -14
View File
@@ -65,7 +65,7 @@ export type LoadContext = typeof appLoadContext;
import "react-router";
import { HeadplaneConfig } from "./config/config-schema";
import { ConfigError } from "./config/error";
import { createLazyOidcConnector } from "./web/oidc-connector";
import { createOidcService } from "./oidc/provider";
declare module "react-router" {
interface AppLoadContext extends LoadContext {}
@@ -84,6 +84,7 @@ const appLoadContext = {
auth: createAuthService({
secret: config.server.cookie_secret,
headscaleApiKey,
db,
cookie: {
name: "_hp_auth",
@@ -93,18 +94,31 @@ const appLoadContext = {
},
}),
headscaleApiKey,
hsApi,
agents,
integration: await loadIntegration(config.integration),
oidc:
config.oidc && config.oidc.enabled !== false && headscaleApiKey
? {
apiKey: headscaleApiKey,
connector: createLazyOidcConnector(
config.server.base_url,
config.oidc,
hsApi.getRuntimeClient(headscaleApiKey),
),
service: createOidcService({
issuer: config.oidc.issuer,
clientId: config.oidc.client_id,
clientSecret: config.oidc.client_secret,
baseUrl: config.server.base_url ?? "",
authorizationEndpoint: config.oidc.authorization_endpoint,
tokenEndpoint: config.oidc.token_endpoint,
userinfoEndpoint: config.oidc.userinfo_endpoint,
tokenEndpointAuthMethod:
config.oidc.token_endpoint_auth_method === "client_secret_jwt"
? undefined
: config.oidc.token_endpoint_auth_method,
usePkce: config.oidc.use_pkce,
scope: config.oidc.scope,
extraParams: config.oidc.extra_params,
profilePictureSource: config.oidc.profile_picture_source,
}),
disableApiKeyLogin: config.oidc.disable_api_key_login,
}
: undefined,
db,
@@ -150,13 +164,7 @@ export default createHonoServer({
},
});
// Prune expired auth sessions every 15 minutes
setInterval(
() => {
appLoadContext.auth.pruneExpiredSessions();
},
15 * 60 * 1000,
);
appLoadContext.auth.start();
process.on("SIGINT", () => {
log.info("server", "Received SIGINT, shutting down...");
+669
View File
@@ -0,0 +1,669 @@
import { createHash, randomBytes } from "node:crypto";
import { createRemoteJWKSet, errors as joseErrors, jwtVerify } from "jose";
import type { JWSHeaderParameters, JWTPayload, FlattenedJWSInput } from "jose";
import { type Result, err, ok } from "~/server/result";
import log from "~/utils/log";
export interface OidcConfig {
issuer: string;
clientId: string;
clientSecret: string;
baseUrl: string;
authorizationEndpoint?: string;
tokenEndpoint?: string;
userinfoEndpoint?: string;
jwksUri?: string;
tokenEndpointAuthMethod?: "client_secret_basic" | "client_secret_post";
usePkce?: boolean;
scope?: string;
extraParams?: Record<string, string>;
profilePictureSource?: "oidc" | "gravatar";
}
export interface ResolvedEndpoints {
authorizationEndpoint: string;
tokenEndpoint: string;
jwksUri: string;
userinfoEndpoint?: string;
endSessionEndpoint?: string;
}
export interface OidcFlowState {
state: string;
nonce: string;
codeVerifier: string;
redirectUri: string;
}
export interface OidcIdentity {
issuer: string;
subject: string;
name: string;
username: string;
email?: string;
picture?: string;
}
export type OidcErrorCode =
| "discovery_failed"
| "missing_endpoints"
| "invalid_api_key"
| "state_mismatch"
| "nonce_mismatch"
| "token_exchange_failed"
| "invalid_client"
| "pkce_error"
| "invalid_id_token"
| "missing_sub"
| "userinfo_failed";
export interface OidcError {
code: OidcErrorCode;
message: string;
hint?: string;
}
type JwksResolver = (
protectedHeader?: JWSHeaderParameters,
token?: FlattenedJWSInput,
) => Promise<CryptoKey>;
export interface OidcService {
status():
| { state: "ready"; endpoints: ResolvedEndpoints }
| { state: "pending" }
| { state: "error"; error: OidcError };
discover(): Promise<Result<ResolvedEndpoints, OidcError>>;
startFlow(): Promise<Result<{ url: string; flowState: OidcFlowState }, OidcError>>;
handleCallback(
callbackParams: URLSearchParams,
flowState: OidcFlowState,
): Promise<Result<OidcIdentity, OidcError>>;
invalidate(): void;
reload(config: OidcConfig): void;
}
interface OidcClaims extends JWTPayload {
nonce?: string;
name?: string;
given_name?: string;
family_name?: string;
preferred_username?: string;
email?: string;
picture?: string;
}
interface TokenResponse {
access_token: string;
id_token?: string;
token_type?: string;
expires_in?: number;
refresh_token?: string;
}
interface TokenErrorResponse {
error: string;
error_description?: string;
}
export function createOidcService(initialConfig: OidcConfig): OidcService {
let config = Object.freeze({ ...initialConfig });
let endpoints: ResolvedEndpoints | undefined;
let lastError: OidcError | undefined;
let jwks: JwksResolver | undefined;
let resolvedAuthMethod: "client_secret_basic" | "client_secret_post" | undefined =
initialConfig.tokenEndpointAuthMethod;
function status(): ReturnType<OidcService["status"]> {
if (lastError) {
return { state: "error", error: lastError };
}
if (endpoints) {
return { state: "ready", endpoints };
}
return { state: "pending" };
}
async function discover(): Promise<Result<ResolvedEndpoints, OidcError>> {
if (endpoints) {
return ok(endpoints);
}
const fullManual = config.authorizationEndpoint && config.tokenEndpoint && config.jwksUri;
if (fullManual) {
endpoints = {
authorizationEndpoint: config.authorizationEndpoint!,
tokenEndpoint: config.tokenEndpoint!,
jwksUri: config.jwksUri!,
userinfoEndpoint: config.userinfoEndpoint,
};
lastError = undefined;
jwks = createRemoteJWKSet(new URL(endpoints.jwksUri));
log.debug("auth", "OIDC endpoints configured manually, skipping discovery");
return ok(endpoints);
}
let discoveryUrl: string;
try {
const issuerUrl = new URL(config.issuer);
if (issuerUrl.pathname === "/" || issuerUrl.pathname === "") {
discoveryUrl = new URL("/.well-known/openid-configuration", issuerUrl).href;
} else {
discoveryUrl = new URL(
`${issuerUrl.pathname.replace(/\/$/, "")}/.well-known/openid-configuration`,
issuerUrl,
).href;
}
} catch {
const error: OidcError = {
code: "discovery_failed",
message: `Invalid issuer URL: ${config.issuer}`,
};
lastError = error;
return err(error);
}
let metadata: Record<string, unknown>;
try {
const response = await fetch(discoveryUrl, {
headers: { Accept: "application/json" },
signal: AbortSignal.timeout(10_000),
});
if (!response.ok) {
const error: OidcError = {
code: "discovery_failed",
message: `Discovery endpoint returned ${response.status}: ${discoveryUrl}`,
hint: "Check that your issuer URL is correct and that the identity provider is online.",
};
lastError = error;
return err(error);
}
metadata = (await response.json()) as Record<string, unknown>;
} catch (cause) {
const error: OidcError = {
code: "discovery_failed",
message: `Failed to reach OIDC discovery endpoint: ${cause instanceof Error ? cause.message : String(cause)}`,
hint: "Unable to reach your identity provider. SSO will automatically retry on the next login attempt.",
};
lastError = error;
return err(error);
}
if (typeof metadata.issuer === "string" && metadata.issuer !== config.issuer) {
log.debug(
"auth",
"Discovery issuer %s does not match configured issuer %s",
metadata.issuer,
config.issuer,
);
}
const authorizationEndpoint =
config.authorizationEndpoint ?? (metadata.authorization_endpoint as string | undefined);
const tokenEndpoint = config.tokenEndpoint ?? (metadata.token_endpoint as string | undefined);
const jwksUri = config.jwksUri ?? (metadata.jwks_uri as string | undefined);
const userinfoEndpoint =
config.userinfoEndpoint ?? (metadata.userinfo_endpoint as string | undefined);
const endSessionEndpoint = metadata.end_session_endpoint as string | undefined;
if (!authorizationEndpoint || !tokenEndpoint || !jwksUri) {
const missing: string[] = [];
if (!authorizationEndpoint) missing.push("authorization_endpoint");
if (!tokenEndpoint) missing.push("token_endpoint");
if (!jwksUri) missing.push("jwks_uri");
const error: OidcError = {
code: "missing_endpoints",
message: `Discovery is missing required endpoints: ${missing.join(", ")}`,
hint: "Your identity provider did not return all required endpoints. You can set them manually in your Headplane config.",
};
lastError = error;
return err(error);
}
endpoints = {
authorizationEndpoint,
tokenEndpoint,
jwksUri,
userinfoEndpoint,
endSessionEndpoint,
};
lastError = undefined;
jwks = createRemoteJWKSet(new URL(endpoints.jwksUri));
log.debug("auth", "OIDC discovery completed successfully");
return ok(endpoints);
}
async function startFlow(): Promise<
Result<{ url: string; flowState: OidcFlowState }, OidcError>
> {
const resolved = await discover();
if (!resolved.ok) {
return resolved;
}
const usePkce = config.usePkce !== false;
const scope = config.scope ?? "openid email profile";
const redirectUri = new URL(`${__PREFIX__}/oidc/callback`, config.baseUrl).href;
const state = generateRandom();
const nonce = generateRandom();
const codeVerifier = generateRandom(64);
const params = new URLSearchParams({
response_type: "code",
client_id: config.clientId,
redirect_uri: redirectUri,
scope,
state,
nonce,
});
if (usePkce) {
const codeChallenge = computeS256Challenge(codeVerifier);
params.set("code_challenge", codeChallenge);
params.set("code_challenge_method", "S256");
}
if (config.extraParams) {
for (const [key, value] of Object.entries(config.extraParams)) {
params.set(key, value);
}
}
const url = `${resolved.value.authorizationEndpoint}?${params.toString()}`;
const flowState: OidcFlowState = { state, nonce, codeVerifier, redirectUri };
return ok({ url, flowState });
}
async function handleCallback(
callbackParams: URLSearchParams,
flowState: OidcFlowState,
): Promise<Result<OidcIdentity, OidcError>> {
const resolved = await discover();
if (!resolved.ok) {
return resolved;
}
const callbackError = callbackParams.get("error");
if (callbackError) {
const desc = callbackParams.get("error_description") ?? "";
return err({
code: "token_exchange_failed",
message: `Provider returned error: ${callbackError}${desc}`,
hint: desc || undefined,
});
}
const code = callbackParams.get("code");
if (!code) {
return err({
code: "token_exchange_failed",
message: "Callback is missing the authorization code",
});
}
const returnedState = callbackParams.get("state");
if (returnedState !== flowState.state) {
return err({
code: "state_mismatch",
message: `State mismatch: expected ${flowState.state}, got ${returnedState}`,
hint: "Please try signing in again. If this keeps happening, your reverse proxy may be interfering with cookies.",
});
}
// Token exchange with auth method retry, hopefully this stops new GitHub issues about this
const tokenResult = await exchangeCode(resolved.value, code, flowState);
if (!tokenResult.ok) {
return tokenResult;
}
const tokens = tokenResult.value;
if (!tokens.id_token) {
return err({
code: "token_exchange_failed",
message: "Token response is missing id_token",
hint: "Your identity provider did not return an ID token. Make sure the 'openid' scope is included in your OIDC client configuration.",
});
}
// ID token verification
const verifyResult = await verifyIdToken(tokens.id_token, flowState.nonce);
if (!verifyResult.ok) {
return verifyResult;
}
const claims = verifyResult.value;
const enriched = await enrichWithUserInfo(resolved.value, tokens.access_token, claims);
return ok(buildIdentity(enriched));
}
async function exchangeCode(
ep: ResolvedEndpoints,
code: string,
flowState: OidcFlowState,
): Promise<Result<TokenResponse, OidcError>> {
const usePkce = config.usePkce !== false;
const body = new URLSearchParams({
grant_type: "authorization_code",
code,
redirect_uri: flowState.redirectUri,
...(usePkce ? { code_verifier: flowState.codeVerifier } : {}),
});
const methodToTry = resolvedAuthMethod ?? "client_secret_post";
const result = await fetchToken(ep.tokenEndpoint, body, methodToTry);
if (!result.ok && !resolvedAuthMethod) {
const isClientError =
result.error.code === "invalid_client" ||
(result.error.code === "token_exchange_failed" &&
result.error.message.includes("invalid_client"));
if (isClientError) {
const fallback =
methodToTry === "client_secret_post"
? ("client_secret_basic" as const)
: ("client_secret_post" as const);
log.debug("auth", "Token exchange failed with %s, retrying with %s", methodToTry, fallback);
const retryResult = await fetchToken(ep.tokenEndpoint, body, fallback);
if (retryResult.ok) {
resolvedAuthMethod = fallback;
log.debug("auth", "Auth method %s succeeded, caching for future requests", fallback);
}
return retryResult;
}
}
if (result.ok && !resolvedAuthMethod) {
resolvedAuthMethod = methodToTry;
}
return result;
}
async function fetchToken(
tokenEndpoint: string,
body: URLSearchParams,
method: "client_secret_basic" | "client_secret_post",
): Promise<Result<TokenResponse, OidcError>> {
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);
} else {
const credentials = btoa(
`${encodeURIComponent(config.clientId)}:${encodeURIComponent(config.clientSecret)}`,
);
headers.Authorization = `Basic ${credentials}`;
}
let response: Response;
try {
response = await fetch(tokenEndpoint, {
method: "POST",
headers,
body: body.toString(),
signal: AbortSignal.timeout(10_000),
});
} catch (cause) {
return err({
code: "token_exchange_failed",
message: `Failed to reach token endpoint: ${cause instanceof Error ? cause.message : String(cause)}`,
});
}
let json: unknown;
try {
json = await response.json();
} catch {
return err({
code: "token_exchange_failed",
message: `Token endpoint returned non-JSON response (status ${response.status})`,
});
}
const responseBody = json as Record<string, unknown>;
if (!response.ok || typeof responseBody.error === "string") {
const tokenError = responseBody as unknown as TokenErrorResponse;
const errorDesc = tokenError.error_description ?? "";
if (tokenError.error === "invalid_client") {
return err({
code: "invalid_client",
message: `invalid_client: ${errorDesc}`,
hint: "Your identity provider rejected the client credentials. Try setting oidc.token_endpoint_auth_method to 'client_secret_post' or 'client_secret_basic' in your config.",
});
}
// Praying on hopes and dreams, but this *might* help (MAYBE)
const isPkceError =
tokenError.error.toLowerCase().includes("pkce") ||
tokenError.error.toLowerCase().includes("code_verifier") ||
tokenError.error.toLowerCase().includes("code verifier") ||
errorDesc.toLowerCase().includes("pkce") ||
errorDesc.toLowerCase().includes("code_verifier") ||
errorDesc.toLowerCase().includes("code verifier");
if (isPkceError) {
const usePkce = config.usePkce !== false;
return err({
code: "pkce_error",
message: `PKCE error: ${tokenError.error}${errorDesc}. Current use_pkce=${usePkce}`,
hint: usePkce
? "Your identity provider may not support PKCE. Try setting oidc.use_pkce to false in your config."
: "Your identity provider may require PKCE. Try setting oidc.use_pkce to true in your config.",
});
}
return err({
code: "token_exchange_failed",
message: `Token exchange error: ${tokenError.error}${errorDesc}`,
});
}
if (typeof responseBody.access_token !== "string") {
return err({
code: "token_exchange_failed",
message: "Token response is missing access_token",
});
}
return ok({
access_token: responseBody.access_token as string,
id_token: responseBody.id_token as string | undefined,
token_type: responseBody.token_type as string | undefined,
expires_in: responseBody.expires_in as number | undefined,
refresh_token: responseBody.refresh_token as string | undefined,
});
}
async function verifyIdToken(
idToken: string,
expectedNonce: string,
): Promise<Result<OidcClaims, OidcError>> {
if (!jwks) {
return err({
code: "invalid_id_token",
message: "JWKS resolver is not initialized — endpoints must be resolved first",
});
}
try {
const { payload } = await jwtVerify<OidcClaims>(idToken, jwks, {
issuer: config.issuer,
audience: config.clientId,
clockTolerance: 60,
});
if (!payload.sub) {
return err({
code: "missing_sub",
message: "ID token is missing the 'sub' claim",
hint: "Your identity provider did not return a user identifier. Check that your OIDC client is configured to include the 'sub' claim.",
});
}
if (payload.nonce !== expectedNonce) {
return err({
code: "nonce_mismatch",
message: `Nonce mismatch: expected ${expectedNonce}, got ${payload.nonce}`,
hint: "Please try signing in again. This can happen with stale browser sessions.",
});
}
return ok(payload);
} catch (cause) {
if (cause instanceof joseErrors.JWTClaimValidationFailed) {
return err({
code: "invalid_id_token",
message: `JWT claim validation failed: ${cause.claim}${cause.reason}`,
});
}
if (cause instanceof joseErrors.JWTExpired) {
return err({
code: "invalid_id_token",
message: "ID token is expired",
});
}
if (cause instanceof joseErrors.JWSSignatureVerificationFailed) {
return err({
code: "invalid_id_token",
message: "ID token signature verification failed",
hint: "The identity provider's signing keys may have changed. Try restarting Headplane to refresh the key cache.",
});
}
return err({
code: "invalid_id_token",
message: `ID token verification failed: ${cause instanceof Error ? cause.message : String(cause)}`,
});
}
}
async function enrichWithUserInfo(
ep: ResolvedEndpoints,
accessToken: string,
claims: OidcClaims,
): Promise<OidcClaims> {
const needsEnrichment = !claims.name && !claims.email && !claims.picture;
if (!needsEnrichment || !ep.userinfoEndpoint) {
return claims;
}
try {
const response = await fetch(ep.userinfoEndpoint, {
headers: {
Authorization: `Bearer ${accessToken}`,
Accept: "application/json",
},
signal: AbortSignal.timeout(10_000),
});
if (!response.ok) {
log.debug("auth", "UserInfo endpoint returned %d, skipping enrichment", response.status);
return claims;
}
const userInfo = (await response.json()) as Record<string, unknown>;
return {
...claims,
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),
preferred_username:
claims.preferred_username ?? (userInfo.preferred_username as string | undefined),
email: claims.email ?? (userInfo.email as string | undefined),
picture: claims.picture ?? (userInfo.picture as string | undefined),
};
} catch (cause) {
log.debug(
"auth",
"UserInfo fetch failed (non-fatal): %s",
cause instanceof Error ? cause.message : String(cause),
);
return claims;
}
}
function buildIdentity(claims: OidcClaims): OidcIdentity {
const name =
claims.name ??
(claims.given_name && claims.family_name
? `${claims.given_name} ${claims.family_name}`
: (claims.preferred_username ?? "SSO User"));
const username = claims.preferred_username ?? claims.email?.split("@")[0] ?? "user";
let picture: string | undefined;
if (config.profilePictureSource === "gravatar") {
if (claims.email) {
const hash = createHash("sha256").update(claims.email.trim().toLowerCase()).digest("hex");
picture = `https://www.gravatar.com/avatar/${hash}?s=200&d=identicon&r=x`;
}
} else {
picture = claims.picture;
}
return {
issuer: config.issuer,
subject: claims.sub!,
name,
username,
email: claims.email,
picture,
};
}
function invalidate(): void {
endpoints = undefined;
lastError = undefined;
jwks = undefined;
resolvedAuthMethod = config.tokenEndpointAuthMethod;
}
function reload(newConfig: OidcConfig): void {
config = Object.freeze({ ...newConfig });
invalidate();
}
return { status, discover, startFlow, handleCallback, invalidate, reload };
}
function generateRandom(bytes = 32): string {
return randomBytes(bytes).toString("base64url");
}
function computeS256Challenge(verifier: string): string {
return createHash("sha256").update(verifier).digest("base64url");
}
+9
View File
@@ -0,0 +1,9 @@
export type Result<T, E = Error> = { ok: true; value: T } | { ok: false; error: E };
export function ok<T>(value: T): Result<T, never> {
return { ok: true, value };
}
export function err<E>(error: E): Result<never, E> {
return { ok: false, error };
}
+189 -235
View File
@@ -10,10 +10,6 @@ import type { Machine } from "~/types";
import { type HeadplaneUser, authSessions, users } from "../db/schema";
import { Capabilities, type Role, Roles, capsForRole } from "./roles";
// ── Principal ────────────────────────────────────────────────────────
// The per-request identity object. Discriminated on `kind` so routes
// can branch structurally instead of checking magic strings.
export type Principal =
| {
kind: "api_key";
@@ -38,14 +34,8 @@ export type Principal =
};
};
// ── Cookie payload ───────────────────────────────────────────────────
// The cookie contains only a session ID + minimal profile data for
// SSR rendering. Credentials never leave the server.
interface CookiePayload {
sid: string;
// API key is stored in the cookie ONLY for api_key sessions.
// OIDC sessions use the server-side oidc.headscale_api_key.
api_key?: string;
profile?: {
name: string;
@@ -54,10 +44,9 @@ interface CookiePayload {
};
}
// ── AuthService ──────────────────────────────────────────────────────
export interface AuthServiceOptions {
secret: string;
headscaleApiKey?: string;
db: NodeSQLiteDatabase;
cookie: {
name: string;
@@ -67,36 +56,94 @@ export interface AuthServiceOptions {
};
}
export class AuthService {
private opts: AuthServiceOptions;
private requestCache = new WeakMap<Request, Promise<Principal>>();
export interface AuthService {
require(request: Request): Promise<Principal>;
can(principal: Principal, capabilities: Capabilities): boolean;
canManageNode(principal: Principal, node: Machine): boolean;
getHeadscaleApiKey(principal: Principal): string;
createOidcSession(
userId: string,
profile: NonNullable<CookiePayload["profile"]>,
maxAge?: number,
): Promise<string>;
constructor(opts: AuthServiceOptions) {
this.opts = opts;
createApiKeySession(apiKey: string, displayName: string, maxAge: number): Promise<string>;
destroySession(request?: Request): Promise<string>;
findOrCreateUser(
subject: string,
profile?: { name?: string; email?: string; picture?: string },
): Promise<string>;
linkHeadscaleUser(userId: string, headscaleUserId: string): Promise<boolean>;
unlinkHeadscaleUser(userId: string): Promise<void>;
linkHeadscaleUserBySubject(subject: string, headscaleUserId: string): Promise<boolean>;
listUsers(): Promise<HeadplaneUser[]>;
claimedHeadscaleUserIds(): Promise<Set<string>>;
roleForSubject(subject: string): Promise<Role | undefined>;
roleForHeadscaleUser(headscaleUserId: string): Promise<Role | undefined>;
transferOwnership(currentOwnerSubject: string, newOwnerSubject: string): Promise<boolean>;
reassignSubject(subject: string, role: Role): Promise<boolean>;
pruneExpiredSessions(): Promise<void>;
start(): void;
stop(): void;
}
export function createAuthService(opts: AuthServiceOptions): AuthService {
const requestCache = new WeakMap<Request, Promise<Principal>>();
let pruneTimer: ReturnType<typeof setInterval> | undefined;
async function encodeCookie(payload: CookiePayload, maxAge: number): Promise<string> {
const cookie = createCookie(opts.cookie.name, {
...opts.cookie,
path: __PREFIX__,
maxAge,
});
const signed = Buffer.from(JSON.stringify(payload)).toString("base64url");
const hmac = createHmac("sha256", opts.secret).update(signed).digest("base64url");
return cookie.serialize(`${signed}.${hmac}`);
}
// ── Authentication ─────────────────────────────────────────────
/**
* Resolve the principal for a request. Throws if no valid session.
* Results are cached per-request so multiple calls in the same
* loader don't hit the DB repeatedly.
*/
require(request: Request): Promise<Principal> {
const cached = this.requestCache.get(request);
if (cached) {
return cached;
async function decodeCookie(request: Request): Promise<CookiePayload> {
const cookieHeader = request.headers.get("cookie");
if (!cookieHeader) {
throw new Error("No session cookie found");
}
const promise = this.resolve(request);
this.requestCache.set(request, promise);
return promise;
const cookie = createCookie(opts.cookie.name, {
...opts.cookie,
path: __PREFIX__,
});
const raw = (await cookie.parse(cookieHeader)) as string | null;
if (!raw) {
throw new Error("Session cookie is empty");
}
const dotIndex = raw.lastIndexOf(".");
if (dotIndex === -1) {
throw new Error("Malformed session cookie");
}
const signed = raw.slice(0, dotIndex);
const hmac = raw.slice(dotIndex + 1);
const expected = createHmac("sha256", opts.secret).update(signed).digest("base64url");
if (hmac !== expected) {
throw new Error("Invalid session cookie signature");
}
return JSON.parse(Buffer.from(signed, "base64url").toString("utf-8")) as CookiePayload;
}
private async resolve(request: Request): Promise<Principal> {
const payload = await this.decodeCookie(request);
function hashApiKey(key: string): string {
return createHash("sha256").update(key).digest("hex");
}
const [session] = await this.opts.db
async function resolve(request: Request): Promise<Principal> {
const payload = await decodeCookie(request);
const [session] = await opts.db
.select()
.from(authSessions)
.where(eq(authSessions.id, payload.sid))
@@ -107,7 +154,7 @@ export class AuthService {
}
if (session.expires_at < new Date()) {
await this.opts.db.delete(authSessions).where(eq(authSessions.id, session.id));
await opts.db.delete(authSessions).where(eq(authSessions.id, session.id));
throw new Error("Session expired");
}
@@ -128,11 +175,7 @@ export class AuthService {
throw new Error("OIDC session missing user_id");
}
const [user] = await this.opts.db
.select()
.from(users)
.where(eq(users.id, session.user_id))
.limit(1);
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");
@@ -157,13 +200,18 @@ export class AuthService {
};
}
// ── Authorization ──────────────────────────────────────────────
function require(request: Request): Promise<Principal> {
const cached = requestCache.get(request);
if (cached) {
return cached;
}
/**
* Check if a principal has a given set of capabilities.
* API key principals always have full access.
*/
can(principal: Principal, capabilities: Capabilities): boolean {
const promise = resolve(request);
requestCache.set(request, promise);
return promise;
}
function can(principal: Principal, capabilities: Capabilities): boolean {
if (principal.kind === "api_key") {
return true;
}
@@ -172,11 +220,7 @@ export class AuthService {
return (capabilities & roleCaps) === capabilities;
}
/**
* Check if a principal can act on a machine. Owners of the machine
* can act on it even without write_machines capability.
*/
canManageNode(principal: Principal, node: Machine): boolean {
function canManageNode(principal: Principal, node: Machine): boolean {
if (principal.kind === "api_key") {
return true;
}
@@ -190,105 +234,77 @@ export class AuthService {
return hsUserId !== undefined && node.user?.id === hsUserId;
}
// ── Session management ─────────────────────────────────────────
function getHeadscaleApiKey(principal: Principal): string {
if (principal.kind === "api_key") {
return principal.apiKey;
}
/**
* Create a new OIDC session. Returns the Set-Cookie header value.
*/
async createOidcSession(
if (!opts.headscaleApiKey) {
throw new Error("OIDC sessions require headscale.api_key to be configured");
}
return opts.headscaleApiKey;
}
async function createOidcSession(
userId: string,
profile: NonNullable<CookiePayload["profile"]>,
maxAge = this.opts.cookie.maxAge,
maxAge = opts.cookie.maxAge,
): Promise<string> {
const sid = ulid();
await this.opts.db.insert(authSessions).values({
await opts.db.insert(authSessions).values({
id: sid,
kind: "oidc",
user_id: userId,
expires_at: new Date(Date.now() + maxAge * 1000),
});
return this.encodeCookie({ sid, profile }, maxAge);
return encodeCookie({ sid, profile }, maxAge);
}
/**
* Create a new API key session. A SHA-256 hash of the key is stored
* server-side for auditing. The plaintext key is carried in the
* HMAC-signed cookie so it can be used for Headscale API calls.
* Returns the Set-Cookie header value.
*/
async createApiKeySession(apiKey: string, displayName: string, maxAge: number): Promise<string> {
async function createApiKeySession(
apiKey: string,
displayName: string,
maxAge: number,
): Promise<string> {
const sid = ulid();
await this.opts.db.insert(authSessions).values({
await opts.db.insert(authSessions).values({
id: sid,
kind: "api_key",
api_key_hash: this.hashApiKey(apiKey),
api_key_hash: hashApiKey(apiKey),
api_key_display: displayName,
expires_at: new Date(Date.now() + maxAge),
});
return this.encodeCookie({ sid, api_key: apiKey }, Math.floor(maxAge / 1000));
return encodeCookie({ sid, api_key: apiKey }, Math.floor(maxAge / 1000));
}
/**
* Get the Headscale API key for making API calls.
* OIDC sessions use the configured oidc.headscale_api_key.
* API key sessions use the user-provided key stored in the cookie.
*/
getHeadscaleApiKey(principal: Principal, oidcApiKey?: string): string {
if (principal.kind === "api_key") {
return principal.apiKey;
}
if (!oidcApiKey) {
throw new Error("OIDC sessions require oidc.headscale_api_key");
}
return oidcApiKey;
}
/**
* Destroy the current session. Returns the Set-Cookie header that
* clears the cookie.
*/
async destroySession(request?: Request): Promise<string> {
async function destroySession(request?: Request): Promise<string> {
if (request) {
try {
const payload = await this.decodeCookie(request);
await this.opts.db.delete(authSessions).where(eq(authSessions.id, payload.sid));
const payload = await decodeCookie(request);
await opts.db.delete(authSessions).where(eq(authSessions.id, payload.sid));
} catch {
// Cookie already invalid, just clear it
}
}
const cookie = createCookie(this.opts.cookie.name, {
...this.opts.cookie,
const cookie = createCookie(opts.cookie.name, {
...opts.cookie,
path: __PREFIX__,
});
return cookie.serialize("", { expires: new Date(0) });
}
// ── User management ────────────────────────────────────────────
/**
* Find or create a Headplane user by OIDC subject. Returns the
* user ID. The first user ever created is automatically granted
* the owner role (bootstrap). Profile data (name, email) is
* refreshed on every login.
*/
async findOrCreateUser(
async function findOrCreateUser(
subject: string,
profile?: { name?: string; email?: string; picture?: string },
): Promise<string> {
const [existing] = await this.opts.db
.select()
.from(users)
.where(eq(users.sub, subject))
.limit(1);
const [existing] = await opts.db.select().from(users).where(eq(users.sub, subject)).limit(1);
if (existing) {
await this.opts.db
await opts.db
.update(users)
.set({
name: profile?.name,
@@ -302,7 +318,7 @@ export class AuthService {
}
const id = ulid();
await this.opts.db.insert(users).values({
await opts.db.insert(users).values({
id,
sub: subject,
name: profile?.name,
@@ -312,14 +328,10 @@ export class AuthService {
caps: capsForRole("member"),
});
// If this is the only user in the table, promote to owner.
// The unique constraint on `sub` prevents two concurrent inserts
// for the same subject; for different subjects, COUNT atomically
// reflects all committed rows so at most one will see count === 1.
const [{ count }] = await this.opts.db.select({ count: sql<number>`count(*)` }).from(users);
const [{ count }] = await opts.db.select({ count: sql<number>`count(*)` }).from(users);
if (count === 1) {
await this.opts.db
await opts.db
.update(users)
.set({ role: "owner", caps: capsForRole("owner") })
.where(eq(users.id, id));
@@ -328,12 +340,8 @@ export class AuthService {
return id;
}
/**
* Link a Headplane user to a Headscale user. Returns false if the
* Headscale user is already claimed by another Headplane user.
*/
async linkHeadscaleUser(userId: string, headscaleUserId: string): Promise<boolean> {
const [existing] = await this.opts.db
async function linkHeadscaleUser(userId: string, headscaleUserId: string): Promise<boolean> {
const [existing] = await opts.db
.select({ id: users.id })
.from(users)
.where(eq(users.headscale_user_id, headscaleUserId))
@@ -343,7 +351,7 @@ export class AuthService {
return false;
}
await this.opts.db
await opts.db
.update(users)
.set({ headscale_user_id: headscaleUserId, updated_at: new Date() })
.where(eq(users.id, userId));
@@ -351,24 +359,18 @@ export class AuthService {
return true;
}
/**
* Clear the Headscale user link for a Headplane user. Used when the
* linked Headscale user no longer exists.
*/
async unlinkHeadscaleUser(userId: string): Promise<void> {
await this.opts.db
async function unlinkHeadscaleUser(userId: string): Promise<void> {
await opts.db
.update(users)
.set({ headscale_user_id: null, updated_at: new Date() })
.where(eq(users.id, userId));
}
/**
* Link a Headplane user (identified by OIDC subject) to a Headscale
* user. Used by admin UI when subjects are more accessible than
* internal Headplane IDs. Returns false if already claimed.
*/
async linkHeadscaleUserBySubject(subject: string, headscaleUserId: string): Promise<boolean> {
const [user] = await this.opts.db
async function linkHeadscaleUserBySubject(
subject: string,
headscaleUserId: string,
): Promise<boolean> {
const [user] = await opts.db
.select({ id: users.id })
.from(users)
.where(eq(users.sub, subject))
@@ -378,23 +380,15 @@ export class AuthService {
return false;
}
return this.linkHeadscaleUser(user.id, headscaleUserId);
return linkHeadscaleUser(user.id, headscaleUserId);
}
/**
* List all Headplane user records. Used by the users overview page
* to display the primary user list independently of the Headscale API.
*/
async listUsers(): Promise<HeadplaneUser[]> {
return this.opts.db.select().from(users);
async function listUsers(): Promise<HeadplaneUser[]> {
return opts.db.select().from(users);
}
/**
* Returns the set of Headscale user IDs that are already claimed
* by a Headplane user. Used to filter the link picker.
*/
async claimedHeadscaleUserIds(): Promise<Set<string>> {
const rows = await this.opts.db.select({ hsId: users.headscale_user_id }).from(users);
async function claimedHeadscaleUserIds(): Promise<Set<string>> {
const rows = await opts.db.select({ hsId: users.headscale_user_id }).from(users);
const ids = new Set<string>();
for (const row of rows) {
@@ -405,12 +399,8 @@ export class AuthService {
return ids;
}
/**
* Get the role for a given OIDC subject. Used by the users overview
* to display roles for Headscale users.
*/
async roleForSubject(subject: string): Promise<Role | undefined> {
const [user] = await this.opts.db.select().from(users).where(eq(users.sub, subject)).limit(1);
async function roleForSubject(subject: string): Promise<Role | undefined> {
const [user] = await opts.db.select().from(users).where(eq(users.sub, subject)).limit(1);
if (!user) {
return;
@@ -419,12 +409,8 @@ export class AuthService {
return (user.role in Roles ? user.role : "member") as Role;
}
/**
* Get the role for a Headplane user linked to a given Headscale user ID.
* Returns undefined if no Headplane user is linked to this Headscale user.
*/
async roleForHeadscaleUser(headscaleUserId: string): Promise<Role | undefined> {
const [user] = await this.opts.db
async function roleForHeadscaleUser(headscaleUserId: string): Promise<Role | undefined> {
const [user] = await opts.db
.select()
.from(users)
.where(eq(users.headscale_user_id, headscaleUserId))
@@ -437,14 +423,11 @@ export class AuthService {
return (user.role in Roles ? user.role : "member") as Role;
}
/**
* Transfer ownership from the current owner to another user.
* The current owner is demoted to admin and the target is promoted
* to owner. Both users must exist. Returns false if the caller is
* not actually the owner or the target doesn't exist.
*/
async transferOwnership(currentOwnerSubject: string, newOwnerSubject: string): Promise<boolean> {
const [current] = await this.opts.db
async function transferOwnership(
currentOwnerSubject: string,
newOwnerSubject: string,
): Promise<boolean> {
const [current] = await opts.db
.select()
.from(users)
.where(eq(users.sub, currentOwnerSubject))
@@ -454,7 +437,7 @@ export class AuthService {
return false;
}
const [target] = await this.opts.db
const [target] = await opts.db
.select()
.from(users)
.where(eq(users.sub, newOwnerSubject))
@@ -464,12 +447,12 @@ export class AuthService {
return false;
}
await this.opts.db
await opts.db
.update(users)
.set({ role: "admin", caps: capsForRole("admin"), updated_at: new Date() })
.where(eq(users.id, current.id));
await this.opts.db
await opts.db
.update(users)
.set({ role: "owner", caps: capsForRole("owner"), updated_at: new Date() })
.where(eq(users.id, target.id));
@@ -477,17 +460,13 @@ export class AuthService {
return true;
}
/**
* Reassign the role of a user identified by their OIDC subject.
* Cannot reassign the owner role.
*/
async reassignSubject(subject: string, role: Role): Promise<boolean> {
const currentRole = await this.roleForSubject(subject);
async function reassignSubject(subject: string, role: Role): Promise<boolean> {
const currentRole = await roleForSubject(subject);
if (currentRole === "owner") {
return false;
}
await this.opts.db
await opts.db
.insert(users)
.values({
id: ulid(),
@@ -503,66 +482,41 @@ export class AuthService {
return true;
}
/**
* Clean up expired sessions. Should be called periodically.
*/
async pruneExpiredSessions(): Promise<void> {
await this.opts.db.delete(authSessions).where(lt(authSessions.expires_at, new Date()));
async function pruneExpiredSessions(): Promise<void> {
await opts.db.delete(authSessions).where(lt(authSessions.expires_at, new Date()));
}
// ── Private helpers ────────────────────────────────────────────
private async encodeCookie(payload: CookiePayload, maxAge: number): Promise<string> {
const cookie = createCookie(this.opts.cookie.name, {
...this.opts.cookie,
path: __PREFIX__,
maxAge,
});
const signed = Buffer.from(JSON.stringify(payload)).toString("base64url");
const hmac = createHmac("sha256", this.opts.secret).update(signed).digest("base64url");
return cookie.serialize(`${signed}.${hmac}`);
function start(): void {
pruneTimer = setInterval(() => void pruneExpiredSessions(), 15 * 60 * 1000);
}
private async decodeCookie(request: Request): Promise<CookiePayload> {
const cookieHeader = request.headers.get("cookie");
if (!cookieHeader) {
throw new Error("No session cookie found");
function stop(): void {
if (pruneTimer) {
clearInterval(pruneTimer);
pruneTimer = undefined;
}
const cookie = createCookie(this.opts.cookie.name, {
...this.opts.cookie,
path: __PREFIX__,
});
const raw = (await cookie.parse(cookieHeader)) as string | null;
if (!raw) {
throw new Error("Session cookie is empty");
}
const dotIndex = raw.lastIndexOf(".");
if (dotIndex === -1) {
throw new Error("Malformed session cookie");
}
const signed = raw.slice(0, dotIndex);
const hmac = raw.slice(dotIndex + 1);
const expected = createHmac("sha256", this.opts.secret).update(signed).digest("base64url");
if (hmac !== expected) {
throw new Error("Invalid session cookie signature");
}
return JSON.parse(Buffer.from(signed, "base64url").toString("utf-8")) as CookiePayload;
}
private hashApiKey(key: string): string {
return createHash("sha256").update(key).digest("hex");
}
}
export function createAuthService(opts: AuthServiceOptions): AuthService {
return new AuthService(opts);
return {
require: require,
can,
canManageNode,
getHeadscaleApiKey,
createOidcSession,
createApiKeySession,
destroySession,
findOrCreateUser,
linkHeadscaleUser,
unlinkHeadscaleUser,
linkHeadscaleUserBySubject,
listUsers,
claimedHeadscaleUserIds,
roleForSubject,
roleForHeadscaleUser,
transferOwnership,
reassignSubject,
pruneExpiredSessions,
start,
stop,
};
}
+2 -1
View File
@@ -12,7 +12,8 @@ export function getOidcSubject(user: User): string | undefined {
return;
}
return user.providerId.split("/").pop();
const segment = user.providerId.split("/").pop();
return segment ? decodeURIComponent(segment) : segment;
}
/**
-321
View File
@@ -1,321 +0,0 @@
import * as oidc from "openid-client";
import log from "~/utils/log";
import type { HeadplaneConfig } from "../config/config-schema";
import type { RuntimeApiClient } from "../headscale/api/endpoints";
import { isDataUnauthorizedError } from "../headscale/api/error-client";
export type OidcConfig = NonNullable<HeadplaneConfig["oidc"]>;
/**
* Errors that can occur during OIDC connector setup and validation.
*/
export type OidcConnectorError =
| "INVALID_API_KEY"
| "MISSING_AUTHORIZATION_ENDPOINT"
| "MISSING_TOKEN_ENDPOINT"
| "MISSING_USERINFO_ENDPOINT"
| "MISSING_REQUIRED_CLAIMS"
| "DISCOVERY_FAILED"
| "UNKNOWN_ERROR";
/**
* Represents a "configured" OIDC setup for Headplane.
* This may include mis-configured versions too and will surface error messages.
*/
export type OidcConnector =
| {
isValid: true;
isExclusive: boolean;
usePKCE: boolean;
client: oidc.Configuration;
apiKey: string;
scope: string;
extraParams?: Record<string, string>;
}
| {
isValid: false;
isExclusive: false;
errors: OidcConnectorError[];
};
/**
* A lazy OIDC connector that retries initialization on failure.
* This allows OIDC to recover from transient startup failures (e.g., network issues,
* OIDC provider temporarily unavailable) without requiring a server restart.
*/
export interface LazyOidcConnector {
/**
* Get the current OIDC connector state.
* If a previous attempt failed, this will retry initialization.
* Successful results are cached until invalidated.
*/
get(): Promise<OidcConnector>;
/**
* Force a re-initialization of the OIDC connector on the next get() call.
* Useful for manually triggering a retry after configuration changes.
*/
invalidate(): void;
}
/**
* Creates a lazy OIDC connector that retries on failure.
* Successful initialization is cached; failed attempts are retried on each get() call.
*
* @param baseUrl The base URL of the Headplane server.
* @param config The OIDC configuration.
* @param client The Headscale runtime API client.
* @returns A lazy OIDC connector that retries on failure.
*/
export function createLazyOidcConnector(
baseUrl: string | undefined,
config: OidcConfig,
client: RuntimeApiClient,
): LazyOidcConnector {
let cachedConnector: OidcConnector | undefined;
let initPromise: Promise<OidcConnector> | undefined;
return {
async get(): Promise<OidcConnector> {
if (cachedConnector?.isValid) {
return cachedConnector;
}
if (initPromise) {
return initPromise;
}
initPromise = createOidcConnector(baseUrl, config, client);
try {
const connector = await initPromise;
if (connector.isValid) {
cachedConnector = connector;
log.info("auth", "OIDC connector initialized successfully");
} else {
log.warn("auth", "OIDC connector initialization failed, will retry on next request");
}
return connector;
} finally {
// Clear the promise so we can retry on next call if it failed
initPromise = undefined;
}
},
invalidate(): void {
cachedConnector = undefined;
initPromise = undefined;
log.info("auth", "OIDC connector cache invalidated");
},
};
}
/**
* Creates an OIDC connector based on the configuration and Headscale API.
* This will attempt to validate the configuration and return any errors.
*
* @param baseUrl The base URL of the Headplane server.
* @param config The OIDC configuration.
* @param client The Headscale runtime API client.
* @returns An OIDC connector with validation status.
*/
async function createOidcConnector(
baseUrl: string | undefined,
config: OidcConfig,
client: RuntimeApiClient,
): Promise<OidcConnector> {
if (baseUrl == null && config.redirect_uri == null) {
log.warn(
"config",
"OIDC is enabled but `server.base_url` is not set in the config. Starting in Headplane 0.7.0 this will be required for OIDC to function properly and will throw errors if not set, see https://headplane.net/features/sso#configuring-oidc for more information.",
);
}
const errors: OidcConnectorError[] = [];
if (!config.headscale_api_key) {
errors.push("INVALID_API_KEY");
return {
isValid: false,
isExclusive: false,
errors,
};
}
try {
await client.getApiKeys();
} catch (error) {
if (isDataUnauthorizedError(error)) {
errors.push("INVALID_API_KEY");
return {
isValid: false,
isExclusive: false,
errors,
};
}
// MARK: Otherwise assume the API key is valid since the API request
// failed for another reason that isn't 401 and we are optimistic
}
const oidcClientOrErrors = await discoveryCoalesce(config);
if (Array.isArray(oidcClientOrErrors)) {
errors.push(...oidcClientOrErrors);
return {
isValid: false,
isExclusive: false,
errors,
};
}
return {
isValid: true,
isExclusive: config.disable_api_key_login,
usePKCE: config.use_pkce,
client: oidcClientOrErrors,
apiKey: config.headscale_api_key,
scope: config.scope,
extraParams: config.extra_params,
};
}
/**
* Runs OIDC discovery and coalesces the results with the provided config.
* We treat the manually supplied values as overrides to discovery.
*
* @param config The OIDC configuration.
* @returns The coalesced OIDC configuration or an array of errors.
*/
async function discoveryCoalesce(
config: OidcConfig,
): Promise<oidc.Configuration | OidcConnectorError[]> {
let metadata: oidc.ServerMetadata;
let discoveryFailed = false;
try {
const client = await oidc.discovery(new URL(config.issuer), config.client_id);
metadata = client.serverMetadata();
if (config.use_pkce === true && !client.serverMetadata().supportsPKCE()) {
log.warn("config", "OIDC provider does not support PKCE, but it is enabled in the config");
}
if (metadata.claims_supported != null) {
if (!metadata.claims_supported.includes("sub")) {
log.error("config", "OIDC provider does not support `sub` claim");
return ["MISSING_REQUIRED_CLAIMS"];
}
if (!metadata.claims_supported.includes("name")) {
if (
!(
metadata.claims_supported.includes("given_name") &&
metadata.claims_supported.includes("family_name")
)
) {
log.warn(
"config",
"OIDC provider does not support `name`, `given_name`, or `family_name` claims",
);
}
}
if (
!metadata.claims_supported.includes("preferred_username") &&
!metadata.claims_supported.includes("email")
) {
log.warn("config", "OIDC provider does not support `preferred_username` or `email` claims");
}
}
} catch {
log.warn("auth", "Failed to reach OIDC provider for discovery, will retry on next request");
discoveryFailed = true;
metadata = {
issuer: config.issuer,
};
}
const authorization_endpoint = config.authorization_endpoint ?? metadata.authorization_endpoint;
const token_endpoint = config.token_endpoint ?? metadata.token_endpoint;
const userinfo_endpoint = config.userinfo_endpoint ?? metadata.userinfo_endpoint;
const hasMissingEndpoints = !authorization_endpoint || !token_endpoint || !userinfo_endpoint;
if (discoveryFailed && hasMissingEndpoints) {
return ["DISCOVERY_FAILED"];
}
const errors: OidcConnectorError[] = [];
if (!authorization_endpoint) {
errors.push("MISSING_AUTHORIZATION_ENDPOINT");
}
if (!token_endpoint) {
errors.push("MISSING_TOKEN_ENDPOINT");
}
if (!userinfo_endpoint) {
errors.push("MISSING_USERINFO_ENDPOINT");
}
if (errors.length > 0) {
return errors;
}
const oidcClient = new oidc.Configuration(
{
...metadata,
issuer: config.issuer,
authorization_endpoint,
token_endpoint,
userinfo_endpoint,
},
config.client_id,
config.client_secret,
negotiateTokenEndpointAuthMethod(config, metadata),
);
return oidcClient;
}
/**
* Determines the token endpoint authentication method based on config and metadata.
*
* @param config The OIDC configuration.
* @param metadata The OIDC server metadata.
* @returns The client authentication method for the token endpoint.
*/
function negotiateTokenEndpointAuthMethod(
config: OidcConfig,
metadata: oidc.ServerMetadata,
): oidc.ClientAuth {
if (config.token_endpoint_auth_method != null) {
switch (config.token_endpoint_auth_method) {
case "client_secret_basic":
return oidc.ClientSecretBasic(config.client_secret);
case "client_secret_post":
return oidc.ClientSecretPost(config.client_secret);
case "client_secret_jwt":
return oidc.ClientSecretJwt(config.client_secret);
}
}
const supported = metadata.token_endpoint_auth_methods_supported;
if (supported != null && supported.length > 0) {
// Prefer client_secret_basic (spec default), otherwise use first available
if (supported.includes("client_secret_basic")) {
return oidc.ClientSecretBasic(config.client_secret);
}
if (supported.includes("client_secret_post")) {
return oidc.ClientSecretPost(config.client_secret);
}
if (supported.includes("client_secret_jwt")) {
return oidc.ClientSecretJwt(config.client_secret);
}
}
log.warn("config", "Falling back to client_secret_post for token endpoint authentication");
return oidc.ClientSecretPost(config.client_secret);
}
+4
View File
@@ -1,5 +1,9 @@
import type { User } from "~/types/User";
export function getUserDisplayName(user: User): string {
if (user.name === "tagged-devices") {
return "Tag-owned";
}
return user.name || user.displayName || user.email || user.id;
}
+15 -1
View File
@@ -137,7 +137,21 @@ build_wasm() {
cat "$(go env GOROOT)/lib/wasm/wasm_exec.js" >> \
"$(dirname "$WASM_OUTPUT")/wasm_exec.js"
GOOS=js GOARCH=wasm go build -o "$WASM_OUTPUT" ./cmd/hp_ssh
# 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.
echo "==> Vendoring Go dependencies for WASM patch"
go mod vendor
DERP_PATCH="$ROOT_DIR/patches/tailscale-derp-port.patch"
if [ -f "$DERP_PATCH" ]; then
echo "==> Applying DERP port patch"
patch -d vendor/tailscale.com -p1 < "$DERP_PATCH" || \
die "failed to apply DERP port patch"
fi
GOOS=js GOARCH=wasm go build -mod=vendor -o "$WASM_OUTPUT" ./cmd/hp_ssh
rm -rf vendor
}
build_app() {
+36 -15
View File
@@ -1,15 +1,27 @@
package main
import (
"bufio"
"context"
"encoding/json"
"os"
"os/signal"
"syscall"
"github.com/tale/headplane/internal/config"
"github.com/tale/headplane/internal/tsnet"
"github.com/tale/headplane/internal/util"
)
type output struct {
Self string `json:"self"`
Hosts map[string]json.RawMessage `json:"hosts"`
}
type errorOutput struct {
Error string `json:"error"`
}
func main() {
log := util.GetLogger()
cfg, err := config.Load()
@@ -23,21 +35,30 @@ func main() {
agent.Connect()
hosts, err := agent.FetchAllHostInfo(context.Background())
if err != nil {
log.Fatal("Failed to fetch host info: %s", err)
}
output := struct {
Self string `json:"self"`
Hosts map[string]json.RawMessage `json:"hosts"`
}{
Self: agent.ID,
Hosts: hosts,
}
enc := json.NewEncoder(os.Stdout)
if err := enc.Encode(output); err != nil {
log.Fatal("Failed to encode result: %s", err)
scanner := bufio.NewScanner(os.Stdin)
// Shut down cleanly on signal or stdin close
sigCh := make(chan os.Signal, 1)
signal.Notify(sigCh, syscall.SIGTERM, syscall.SIGINT)
go func() {
<-sigCh
agent.Shutdown()
os.Exit(0)
}()
// Each line on stdin triggers a sync. The line content is ignored.
for scanner.Scan() {
hosts, err := agent.FetchAllHostInfo(context.Background())
if err != nil {
enc.Encode(errorOutput{Error: err.Error()})
continue
}
enc.Encode(output{
Self: agent.ID,
Hosts: hosts,
})
}
}
+43 -57
View File
@@ -12,96 +12,82 @@ import (
func main() {
log.Printf("Loading WASM Headplane SSH module")
js.Global().Set("TsWasmNet", js.FuncOf(func(this js.Value, args []js.Value) any {
if len(args) != 2 {
log.Fatal("Usage: TsWasmNet(config, callbacks)")
factory := js.FuncOf(func(this js.Value, args []js.Value) any {
if len(args) != 1 {
log.Printf("Usage: create(config)")
return nil
}
options, err := hp_ipn.ParseTsWasmNetOptions(args[0])
config, err := hp_ipn.ParseIPNConfig(args[0])
if err != nil {
log.Fatal("Error parsing options:", err)
log.Printf("Error parsing config: %v", err)
return nil
}
callbacks, err := hp_ipn.ParseTsWasmNetCallbacks(args[1])
callbacks := hp_ipn.ParseIPNCallbacks(args[0])
ipn, err := hp_ipn.NewTsWasmIpn(config, callbacks)
if err != nil {
log.Fatal("Error parsing callbacks:", err)
callbacks.OnError(err.Error())
return nil
}
ipn, err := hp_ipn.NewTsWasmIpn(options, callbacks)
if err != nil {
log.Fatal("Error creating TsWasmIpn:", err)
return nil
}
go func() {
if err := ipn.Start(context.Background()); err != nil {
callbacks.OnError(err.Error())
}
}()
return map[string]any{
"Start": js.FuncOf(func(this js.Value, args []js.Value) any {
ipn.Start(context.Background())
return nil
}),
"OpenSSH": js.FuncOf(func(this js.Value, args []js.Value) any {
if len(args) != 3 {
log.Fatal("Usage: OpenSSH(host, user, options)")
"openTunnel": js.FuncOf(func(this js.Value, args []js.Value) any {
if len(args) != 1 {
log.Printf("Usage: openTunnel(config)")
return nil
}
hostname := args[0]
if hostname.IsNull() || hostname.IsUndefined() {
log.Fatal("Hostname must be a non-null, non-undefined string")
return nil
}
if hostname.Type() != js.TypeString {
log.Fatal("Hostname must be a string")
return nil
}
username := args[1]
if username.IsNull() || username.IsUndefined() {
log.Fatal("Username must be a non-null, non-undefined string")
return nil
}
if username.Type() != js.TypeString {
log.Fatal("Username must be a string")
return nil
}
sshOptions, err := hp_ipn.ParseSSHXtermConfig(args[2])
tunnelConfig, err := hp_ipn.ParseTunnelConfig(args[0])
if err != nil {
log.Fatal("Error parsing SSH options:", err)
log.Printf("Error parsing tunnel config: %v", err)
return nil
}
session := ipn.NewSSHSession(hostname.String(), username.String(), sshOptions)
session := ipn.NewSSHSession(tunnelConfig)
go session.ConnectAndRun()
return map[string]any{
"Close": js.FuncOf(func(this js.Value, args []js.Value) any {
return session.Close() != nil
"writeInput": js.FuncOf(func(this js.Value, args []js.Value) any {
if len(args) == 1 {
session.WriteInput(args[0].String())
}
return nil
}),
"Resize": js.FuncOf(func(this js.Value, args []js.Value) any {
"resize": js.FuncOf(func(this js.Value, args []js.Value) any {
if len(args) != 2 {
log.Fatal("Usage: Resize(cols, rows)")
return nil
}
session.Resize(args[0].Int(), args[1].Int())
return nil
}),
rows := args[0].Int()
cols := args[1].Int()
if cols <= 0 || rows <= 0 {
log.Fatal("Columns and rows must be positive integers")
return nil
}
return session.Resize(cols, rows) == nil
"close": js.FuncOf(func(this js.Value, args []js.Value) any {
session.Close()
return nil
}),
}
}),
}
}))
})
resolve := js.Global().Get("__hp_ssh_resolve")
if resolve.Type() != js.TypeFunction {
log.Printf("__hp_ssh_resolve is not set, cannot initialize")
return
}
resolve.Invoke(factory)
js.Global().Delete("__hp_ssh_resolve")
log.Printf("WASM Headplane SSH module loaded successfully")
<-make(chan bool)
+74 -72
View File
@@ -1,77 +1,79 @@
import { defineConfig } from 'vitepress';
import { defineConfig } from "vitepress";
export default defineConfig({
title: 'Headplane',
description: 'The missing dashboard for Headscale',
cleanUrls: true,
head: [['link', { rel: 'icon', href: '/favicon.ico' }]],
themeConfig: {
logo: '/logo.svg',
nav: [
{ text: 'Home', link: '/' },
{ text: 'Changelog', link: '/CHANGELOG' },
],
search: {
provider: 'local',
},
sidebar: [
{
text: 'Getting Started',
items: [
{ text: 'What is Headplane?', link: '/introduction' },
{
text: 'Installation',
link: '/install',
items: [
{ text: 'Limited Mode', link: '/install/limited-mode' },
{ text: 'Native Mode', link: '/install/native-mode' },
{ text: 'Docker', link: '/install/docker' },
],
},
{
text: 'Configuration',
link: '/configuration',
items: [
{ text: 'Common Issues', link: '/configuration/common-issues' },
{
text: 'Sensitive Values',
link: '/configuration#sensitive-values',
},
],
},
{ text: 'Nix', link: '/Nix' },
{ text: 'NixOS', link: '/NixOS-options' },
{
text: 'Features',
items: [
{ text: 'Single Sign-On (SSO)', link: '/features/sso' },
{ text: 'Headplane Agent / SSH', link: '/features/agent' },
],
},
{
text: 'Development',
collapsed: true,
items: [
{ text: 'Contributing', link: '/CONTRIBUTING' },
{ text: 'Security', link: '/SECURITY' },
],
},
],
},
],
title: "Headplane",
description: "The missing dashboard for Headscale",
cleanUrls: true,
head: [["link", { rel: "icon", href: "/favicon.ico" }]],
themeConfig: {
logo: "/logo.svg",
nav: [
{ text: "Home", link: "/" },
{ text: "Changelog", link: "/CHANGELOG" },
],
search: {
provider: "local",
},
sidebar: [
{
text: "Getting Started",
items: [
{ text: "What is Headplane?", link: "/introduction" },
{
text: "Installation",
link: "/install",
items: [
{ text: "Limited Mode", link: "/install/limited-mode" },
{ text: "Native Mode", link: "/install/native-mode" },
{ text: "Docker", link: "/install/docker" },
],
},
{
text: "Configuration",
link: "/configuration",
items: [
{ text: "Common Issues", link: "/configuration/common-issues" },
{
text: "Sensitive Values",
link: "/configuration#sensitive-values",
},
],
},
{ text: "Nix", link: "/Nix" },
{ text: "NixOS", link: "/NixOS-options" },
{
text: "Features",
items: [
{ text: "Single Sign-On (SSO)", link: "/features/sso" },
{ text: "Headplane Agent", link: "/features/agent" },
{ text: "Browser SSH", link: "/features/ssh" },
],
},
{
text: "Development",
collapsed: true,
items: [
{ text: "Architecture", link: "/development/architecture" },
{ text: "Contributing", link: "/CONTRIBUTING" },
{ text: "Security", link: "/SECURITY" },
],
},
],
},
],
socialLinks: [
{ icon: 'github', link: 'https://github.com/tale/headplane' },
{ icon: 'githubsponsors', link: 'https://github.com/sponsors/tale' },
{ icon: 'kofi', link: 'https://ko-fi.com/atale' },
],
socialLinks: [
{ icon: "github", link: "https://github.com/tale/headplane" },
{ icon: "githubsponsors", link: "https://github.com/sponsors/tale" },
{ icon: "kofi", link: "https://ko-fi.com/atale" },
],
lastUpdated: {
text: 'Updated at',
formatOptions: {
dateStyle: 'full',
timeStyle: 'medium',
},
},
},
lastUpdated: {
text: "Updated at",
formatOptions: {
dateStyle: "full",
timeStyle: "medium",
},
},
},
});
Binary file not shown.

After

Width:  |  Height:  |  Size: 337 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 147 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 596 KiB

+338
View File
@@ -0,0 +1,338 @@
---
title: Architecture
description: Service architecture patterns used in Headplane's server code.
outline: [2, 3]
---
# Architecture
Headplane's server code is organized as independent service modules within a
single Node.js process. Each service manages its own state and lifecycle
without relying on a shared god-object or dependency injection framework.
This page documents the patterns that all server-side services must follow.
## Core Pattern: Closure Factories
Every service is a **factory function** that takes its dependencies as
arguments, closes over its private state, and returns a plain object of
functions. No classes, no decorators, no module-level globals.
```ts
// ✅ Correct: closure factory
export function createOidcService(config: OidcConfig): OidcService {
// Private state — owned by this instance, invisible outside
let endpoints: ResolvedEndpoints | undefined;
let cachedAuthMethod: string | undefined;
function status() {
if (endpoints) return { state: "ready", endpoints };
return { state: "pending" };
}
async function startFlow() {
// Uses `config` and `endpoints` from closure
}
function invalidate() {
endpoints = undefined;
cachedAuthMethod = undefined;
}
return { status, startFlow, invalidate };
}
```
```ts
// ❌ Wrong: module-level global state
let endpoints: ResolvedEndpoints | undefined;
export function init(config: OidcConfig) {
// Mutates module globals — untestable, import-order fragile
}
export function startFlow() {
// Reads from module globals — can't have two instances
}
```
```ts
// ❌ Wrong: class with `this`
export class OidcService {
private endpoints?: ResolvedEndpoints;
// Adds ceremony without adding value over closures
}
```
### Why Closures?
- **Testable**: Create a fresh instance per test with different config. No
`vi.resetModules()`, no import-order hacks, no singletons to clean up.
- **Composable**: Services can depend on other services by accepting them as
factory arguments. No container registration, no string keys.
- **Hot-reloadable**: Call `service.reload(newConfig)` or create a new
instance. Old state is garbage collected.
- **Explicit**: Every dependency is visible in the factory signature. No
hidden ambient state.
## Service Interface
Every service should define a TypeScript interface for its public API. This
is what consumers (routes, other services, tests) depend on — never the
internal implementation.
```ts
export interface OidcService {
status(): OidcStatus;
startFlow(): Promise<Result<FlowData, OidcError>>;
handleCallback(params: URLSearchParams, state: FlowState): Promise<Result<Identity, OidcError>>;
invalidate(): void;
reload(config: OidcConfig): void;
}
```
### Lifecycle Hooks
Services that run background work (timers, polling, watch loops) should
expose lifecycle hooks. These keep the background behavior local to the
service that owns it:
```ts
export interface AuthService {
require(request: Request): Promise<Principal>;
can(principal: Principal, cap: Capabilities): boolean;
// Lifecycle
start(): void; // Begin session pruning interval
stop(): void; // Clear interval, clean up
}
export function createAuthService(opts: AuthServiceOptions): AuthService {
let pruneTimer: NodeJS.Timeout | undefined;
return {
require(request) {
/* ... */
},
can(principal, cap) {
/* ... */
},
start() {
pruneTimer = setInterval(() => void pruneExpiredSessions(), 15 * 60 * 1000);
},
stop() {
if (pruneTimer) clearInterval(pruneTimer);
},
};
}
```
## Result Type
Services that can fail use the shared `Result<T, E>` type instead of
throwing exceptions. This makes error handling explicit at every call site.
```ts
import { type Result, ok, err } from "~/server/result";
// Returning success
return ok({ url, flowState });
// Returning failure
return err({ code: "discovery_failed", message: "..." });
```
Routes and other callers use the discriminated union:
```ts
const result = await runtime.oidc.startFlow();
if (!result.ok) {
// result.error is typed — render the right UI
return redirect(`/login?s=${result.error.code}`);
}
// result.value is typed
return redirect(result.value.url);
```
`Result` lives in `app/server/result.ts` and is intentionally minimal:
```ts
type Result<T, E = Error> = { ok: true; value: T } | { ok: false; error: E };
```
## Composition Root
All services are wired together in a single place: `server/index.ts`. This
is the **composition root** — the only file that knows about every service
and how they connect.
```ts
export interface AppRuntime {
config: HeadplaneConfig;
db: DbClient;
auth: AuthService;
oidc?: OidcService;
hsApi: HeadscaleInterface;
agents?: AgentManager;
stop(): Promise<void>;
}
export async function createAppRuntime(): Promise<AppRuntime> {
const config = await loadConfig();
const db = await createDbClient(/* ... */);
const auth = createAuthService({ db /* ... */ });
const oidc = config.oidc
? createOidcService({
/* ... */
})
: undefined;
return {
config,
db,
auth,
oidc,
async stop() {
auth.stop?.();
},
};
}
```
React Router's `AppLoadContext` wraps the runtime:
```ts
const runtime = await createAppRuntime();
getLoadContext() {
return { runtime };
}
```
Routes access services through `context.runtime`:
```ts
export async function loader({ context }: Route.LoaderArgs) {
const principal = await context.runtime.auth.require(request);
// ...
}
```
### Dependency Direction
Services can depend on other services, but only through explicit factory
arguments — never by importing another service's module and reading its
state:
```ts
// ✅ Correct: explicit dependency
export function createAuthService(opts: {
db: DbClient;
// ...
}): AuthService {}
// ❌ Wrong: hidden coupling
import { getDb } from "~/server/db";
export function createAuthService(): AuthService {
const db = getDb(); // Where does this come from? Is it initialized?
}
```
## Error Handling
### Config-Time vs Flow-Time
Services distinguish between errors that happen during setup (config-time)
and errors that happen during a user action (flow-time). This distinction
determines where and how errors are surfaced:
| Type | When | UI Surface | Example |
| ----------- | ------------------------ | ------------------------ | ----------------------------------------- |
| Config-time | Before user acts | Banner on login page | `discovery_failed`, `invalid_api_key` |
| Flow-time | After user starts a flow | Redirect with error code | `token_exchange_failed`, `state_mismatch` |
| Non-fatal | During a flow | Logged only | `userinfo_failed` |
### Error Codes
Every service error should have a unique `code` string that maps to:
1. A log message with actionable detail (for the operator)
2. A UI component (for the user)
3. A documentation section (for troubleshooting)
```ts
export interface OidcError {
code: OidcErrorCode; // Machine-readable, used in URLs and UI switches
message: string; // Human-readable, for server logs only
hint?: string; // Troubleshooting suggestion for logs
}
```
## Testing
### Unit Tests
Create a fresh service instance per test with the exact config you need.
No mocking frameworks required:
```ts
import { createOidcService } from "~/server/oidc/provider";
test("status is pending before first discovery", () => {
const oidc = createOidcService(testConfig);
expect(oidc.status().state).toBe("pending");
});
test("invalidate clears cached endpoints", async () => {
const oidc = createOidcService(testConfig);
await oidc.discover();
oidc.invalidate();
expect(oidc.status().state).toBe("pending");
});
```
### Faking Services
For route tests, build a partial runtime with only the services you need:
```ts
function createTestRuntime(overrides: Partial<AppRuntime> = {}): AppRuntime {
return {
config: testConfig,
db: createTestDb(),
auth: createTestAuth(),
hsApi: createTestHsApi(),
stop: async () => {},
...overrides,
};
}
test("login page shows SSO button when OIDC is ready", () => {
const runtime = createTestRuntime({
oidc: createOidcService(testOidcConfig),
});
// Test the route loader with this runtime
});
```
### Integration Tests
Use real OIDC providers in containers (Dex, Keycloak) via `testcontainers`
to test the full flow without browser automation:
```ts
// Configure Dex with static client + static passwords
// Hit the token endpoint directly
// Validate the entire server-side flow end-to-end
```
## Adding a New Service
1. **Define the interface** in a new file under `app/server/<name>/`.
2. **Write the factory** function that takes explicit deps and returns the
interface. Keep state in closure variables.
3. **Add lifecycle hooks** (`start`/`stop`/`reload`/`invalidate`) if the
service has background work or cached state.
4. **Use `Result<T, E>`** for operations that can fail. Define a typed error
with a `code` field.
5. **Wire it in `createAppRuntime()`** in `server/index.ts`.
6. **Write tests** that create isolated instances — no module mocking needed.
-5
View File
@@ -5,11 +5,6 @@ description: Configure the Headplane Agent for enhanced functionality.
# Headplane Agent
<figure>
<img src="../assets/ssh.png" />
<figcaption>SSH access via the browser</figcaption>
</figure>
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
+227
View File
@@ -0,0 +1,227 @@
---
title: Browser SSH
description: Open SSH sessions to your Tailnet nodes directly from the browser.
---
# Browser SSH
<figure>
<img src="../assets/ssh-btop.png" style="width: 100%;" />
<figcaption><code>btop</code> running over browser SSH</figcaption>
</figure>
Browser SSH allows a user to open an SSH session to any accesible node in the
Tailnet directly from the browser. It spins up an ephemeral Tailscale node that
joins the tailnet for the duration of the SSH session.
<figure>
<img src="../assets/ssh-fastfetch.png" style="width: 100%;" />
<figcaption><code>fastfetch</code> with Nerd Font icons</figcaption>
</figure>
## Prerequisites
- **Headscale 0.28 or newer** is required.
- 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).
## How It Works
:::tip
While we use Ghostty (via [restty](https://restty.dev)) to render the terminal,
the SSH connection is opened with a `TERM` value of `xterm-256color` for maximum
compatibility. Nerd Font glyphs are supported out of the box — the terminal
ships with a self-hosted JetBrains Mono Nerd Font.
:::
When a user opens an SSH session from the UI, the browser:
1. Loads a WASM module that runs a minimal Tailscale node using userspace
WireGuard. This node will connect to the tailnet via a pre-auth key.
2. Opens an SSH session to the target node's Tailscale IP address over the
tunnel and passes it to the browser.
3. Using [restty](https://restty.dev) (a Ghostty-based WASM terminal emulator),
the browser renders a full-featured terminal and proxies the SSH session
to it.
## Reverse Proxy Configuration
Browser SSH requires that the browser can reach both **Headplane** and
**Headscale** directly. If either is behind a reverse proxy, the proxy must be
configured to support WebSocket connections — this is how the WASM node
communicates with DERP relay servers.
### Required Headers
Your reverse proxy must forward these headers for Headscale's DERP endpoint:
| Header | Value |
| ------------------------ | --------------------------------------- |
| `Upgrade` | `websocket` |
| `Connection` | `Upgrade` |
| `Sec-WebSocket-Protocol` | forwarded as-is (Tailscale uses `derp`) |
### CORS Headers
Headscale must be accessible from the origin where Headplane is served. If
Headplane and Headscale are on different origins (different hosts or ports),
your reverse proxy must add CORS headers to Headscale responses:
| Header | Value |
| ------------------------------ | ----------------------------------------------- |
| `Access-Control-Allow-Origin` | The origin of your Headplane instance |
| `Access-Control-Allow-Methods` | `GET, POST, OPTIONS` |
| `Access-Control-Allow-Headers` | `Content-Type, Upgrade, Sec-WebSocket-Protocol` |
### Example: Caddy
```caddyfile
# Headscale
hs.example.com {
reverse_proxy localhost:8080
# If Headplane is on a different origin:
header Access-Control-Allow-Origin "https://headplane.example.com"
header Access-Control-Allow-Methods "GET, POST, OPTIONS"
header Access-Control-Allow-Headers "Content-Type, Upgrade, Sec-WebSocket-Protocol"
}
```
Caddy handles WebSocket upgrades automatically — no extra configuration needed.
### Example: nginx
```nginx
# Headscale
server {
listen 443 ssl;
server_name hs.example.com;
location / {
proxy_pass http://localhost:8080;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
# If Headplane is on a different origin:
add_header Access-Control-Allow-Origin "https://headplane.example.com" always;
add_header Access-Control-Allow-Methods "GET, POST, OPTIONS" always;
add_header Access-Control-Allow-Headers "Content-Type, Upgrade, Sec-WebSocket-Protocol" always;
}
}
```
### Same-Origin Setup
If Headplane and Headscale share the same origin (e.g. a single reverse proxy
routing `/admin` to Headplane and everything else to Headscale), CORS headers
are not needed. WebSocket upgrade forwarding is still required.
## Troubleshooting
### SSH Not Available
**Error:** "This version of Headplane was not built with browser SSH support."
The WASM assets (`hp_ssh.wasm` and `wasm_exec.js`) are missing. Rebuild with
`./build.sh --wasm` or ensure your Docker image was built with the `--wasm`
flag.
### Agent Required
**Error:** "Browser SSH is only available when the Headplane agent integration
is enabled."
The Headplane Agent is not enabled. Browser SSH depends on the agent for
Tailnet connectivity and ephemeral node cleanup. See the
[Agent documentation](/features/agent) for setup instructions.
### OIDC Required
**Error:** "Browser SSH is only available when OIDC authentication is enabled."
Browser SSH requires OIDC authentication to generate pre-auth keys tied to a
Headscale user. API key logins do not have an associated Headscale user
identity. Log in via your configured OIDC provider instead.
### User Not Linked
**Error:** "You'll need to link your user account to a Headscale user before
you can use Browser SSH."
Your OIDC account does not match any user in Headscale. You must authenticate
with Headscale at least once before using Browser SSH, so that a Headscale
user is created and linked to your OIDC identity.
### Node Not Found
**Error:** "No node found with hostname ..."
The node name in the URL does not match any node registered in Headscale. The
node may have been renamed or removed. Navigate back to the machines list and
try again.
### Node Offline
Headplane checks whether the target node is connected to the Tailnet before
attempting an SSH session. If the node is offline, you'll see an error page
with a **Retry Connection** button. Ensure the node is running and connected
to Headscale, then retry.
### Connection fails with EOF or hangs
- **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
advertised port from this value. Do **not** put the DERP port in Headplane's
`headscale.public_url` — that setting is used for display in the UI and
changing it will break registration commands and auth key instructions.
- **Verify reverse proxy WebSocket support.** The proxy in front of Headscale
must forward `Upgrade: websocket` headers. Without this, the DERP connection
will fail immediately.
- **Check CORS if on different origins.** Open the browser console and look for
CORS errors. If Headplane and Headscale are on different origins, CORS headers
must be configured on Headscale's proxy.
- Verify that the target node has Tailscale SSH enabled (`tailscale up --ssh`).
- Check the browser console for WASM errors or DERP connection failures.
### "failed to look up local user \*"
This error appears in the terminal when Headscale SSH ACLs use
`"users": ["*"]`, which some Tailscale versions interpret as a literal
username rather than a wildcard. To fix this, change your ACL SSH rules to
use `"autogroup:nonroot"` or explicit usernames instead:
```jsonc
// Before (broken on some versions)
{ "action": "accept", "src": ["autogroup:member"], "dst": ["autogroup:self"], "users": ["*"] }
// After (recommended)
{ "action": "accept", "src": ["autogroup:member"], "dst": ["autogroup:self"], "users": ["autogroup:nonroot"] }
```
### Terminal opens but input doesn't work
- Ensure the target node's Tailscale SSH ACLs permit the user. The SSH
connection succeeds at the transport level but the session may be rejected
by the node's SSH policy.
- Check that the username you entered is a valid Linux user on the target
node. If the user doesn't exist, the SSH session will appear to connect
but immediately fail.
### "SSH error: ssh: handshake failed: ssh: no common algorithm"
The target node's SSH server doesn't support any of the algorithms offered
by the Go SSH client. This usually means the target node is running a very
old or very new version of OpenSSH with non-default algorithm configuration.
Updating Tailscale on the target node typically resolves this.
### SSH session connects but immediately disconnects
- The target node may not have an SSH server installed or running. Tailscale
SSH (`tailscale up --ssh`) runs its own SSH server, but if Tailscale SSH
is not enabled, the node needs a standard SSH server (e.g. `openssh-server`)
listening on port 22.
- The node may have a firewall blocking port 22 even for Tailnet connections.
@@ -0,0 +1 @@
DROP TABLE `ephemeral_nodes`;
@@ -0,0 +1,266 @@
{
"version": "7",
"dialect": "sqlite",
"id": "dd3ce0c0-106f-4628-8c36-bdf9747e5f41",
"prevIds": ["b0533d83-6eb6-422f-925f-c59e4db89384"],
"ddl": [
{
"name": "auth_sessions",
"entityType": "tables"
},
{
"name": "host_info",
"entityType": "tables"
},
{
"name": "users",
"entityType": "tables"
},
{
"type": "text",
"notNull": false,
"autoincrement": false,
"default": null,
"generated": null,
"name": "id",
"entityType": "columns",
"table": "auth_sessions"
},
{
"type": "text",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "kind",
"entityType": "columns",
"table": "auth_sessions"
},
{
"type": "text",
"notNull": false,
"autoincrement": false,
"default": null,
"generated": null,
"name": "user_id",
"entityType": "columns",
"table": "auth_sessions"
},
{
"type": "text",
"notNull": false,
"autoincrement": false,
"default": null,
"generated": null,
"name": "api_key_hash",
"entityType": "columns",
"table": "auth_sessions"
},
{
"type": "text",
"notNull": false,
"autoincrement": false,
"default": null,
"generated": null,
"name": "api_key_display",
"entityType": "columns",
"table": "auth_sessions"
},
{
"type": "integer",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "expires_at",
"entityType": "columns",
"table": "auth_sessions"
},
{
"type": "integer",
"notNull": false,
"autoincrement": false,
"default": null,
"generated": null,
"name": "created_at",
"entityType": "columns",
"table": "auth_sessions"
},
{
"type": "text",
"notNull": false,
"autoincrement": false,
"default": null,
"generated": null,
"name": "host_id",
"entityType": "columns",
"table": "host_info"
},
{
"type": "text",
"notNull": false,
"autoincrement": false,
"default": null,
"generated": null,
"name": "payload",
"entityType": "columns",
"table": "host_info"
},
{
"type": "integer",
"notNull": false,
"autoincrement": false,
"default": null,
"generated": null,
"name": "updated_at",
"entityType": "columns",
"table": "host_info"
},
{
"type": "text",
"notNull": false,
"autoincrement": false,
"default": null,
"generated": null,
"name": "id",
"entityType": "columns",
"table": "users"
},
{
"type": "text",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "sub",
"entityType": "columns",
"table": "users"
},
{
"type": "text",
"notNull": false,
"autoincrement": false,
"default": null,
"generated": null,
"name": "name",
"entityType": "columns",
"table": "users"
},
{
"type": "text",
"notNull": false,
"autoincrement": false,
"default": null,
"generated": null,
"name": "email",
"entityType": "columns",
"table": "users"
},
{
"type": "text",
"notNull": false,
"autoincrement": false,
"default": null,
"generated": null,
"name": "picture",
"entityType": "columns",
"table": "users"
},
{
"type": "text",
"notNull": true,
"autoincrement": false,
"default": "'member'",
"generated": null,
"name": "role",
"entityType": "columns",
"table": "users"
},
{
"type": "text",
"notNull": false,
"autoincrement": false,
"default": null,
"generated": null,
"name": "headscale_user_id",
"entityType": "columns",
"table": "users"
},
{
"type": "integer",
"notNull": false,
"autoincrement": false,
"default": null,
"generated": null,
"name": "created_at",
"entityType": "columns",
"table": "users"
},
{
"type": "integer",
"notNull": false,
"autoincrement": false,
"default": null,
"generated": null,
"name": "updated_at",
"entityType": "columns",
"table": "users"
},
{
"type": "integer",
"notNull": false,
"autoincrement": false,
"default": null,
"generated": null,
"name": "last_login_at",
"entityType": "columns",
"table": "users"
},
{
"type": "integer",
"notNull": true,
"autoincrement": false,
"default": "0",
"generated": null,
"name": "caps",
"entityType": "columns",
"table": "users"
},
{
"columns": ["id"],
"nameExplicit": false,
"name": "auth_sessions_pk",
"table": "auth_sessions",
"entityType": "pks"
},
{
"columns": ["host_id"],
"nameExplicit": false,
"name": "host_info_pk",
"table": "host_info",
"entityType": "pks"
},
{
"columns": ["id"],
"nameExplicit": false,
"name": "users_pk",
"table": "users",
"entityType": "pks"
},
{
"columns": ["sub"],
"nameExplicit": false,
"name": "users_sub_unique",
"entityType": "uniques",
"table": "users"
},
{
"columns": ["headscale_user_id"],
"nameExplicit": false,
"name": "users_headscale_user_id_unique",
"entityType": "uniques",
"table": "users"
}
],
"renames": []
}
Generated
+3 -3
View File
@@ -40,11 +40,11 @@
},
"nixpkgs": {
"locked": {
"lastModified": 1773840656,
"narHash": "sha256-9tpvMGFteZnd3gRQZFlRCohVpqooygFuy9yjuyRL2C0=",
"lastModified": 1775126147,
"narHash": "sha256-J0dZU4atgcfo4QvM9D92uQ0Oe1eLTxBVXjJzdEMQpD0=",
"owner": "nixos",
"repo": "nixpkgs",
"rev": "9cf7092bdd603554bd8b63c216e8943cf9b12512",
"rev": "8d8c1fa5b412c223ffa47410867813290cdedfef",
"type": "github"
},
"original": {
+21 -60
View File
@@ -14,7 +14,6 @@ import (
"tailscale.com/ipn/ipnlocal"
"tailscale.com/ipn/ipnserver"
"tailscale.com/ipn/store/mem"
// "tailscale.com/net/netmon"
"tailscale.com/net/netns"
"tailscale.com/net/tsdial"
"tailscale.com/safesocket"
@@ -24,102 +23,74 @@ import (
"tailscale.com/wgengine/netstack"
)
// Represents an in-state Tailscale backend that is WASM friendly.
// The bare minimum to have userspace Wireguard networking is a dialer,
// a server, and a backend.
type TsWasmIpn struct {
// The options used to initialize the TsWasmNet module.
options *TsWasmNetOptions
// The Tailscale dialer, which is used to establish connections.
dialer *tsdial.Dialer
// The Tailscale server, which handles incoming connections and requests.
server *ipnserver.Server
// The Tailscale backend, which manages the local state and operations.
options *IPNConfig
dialer *tsdial.Dialer
server *ipnserver.Server
backend *ipnlocal.LocalBackend
}
// NewTsWasmIpn initializes a new TsWasmIpn instance with the provided options.
// This intentionally does not initialize Logtail, as it is only available in
// the Tailscale SaaS and not on self-hosted instances.
func NewTsWasmIpn(options *TsWasmNetOptions, callbacks *TsWasmNetCallbacks) (*TsWasmIpn, error) {
logf := log.Printf // TODO: Update
func NewTsWasmIpn(options *IPNConfig, callbacks *IPNCallbacks) (*TsWasmIpn, error) {
logf := log.Printf
netns.SetEnabled(false)
netns.SetEnabled(false) // netns is a separate process (not WASM friendly)
// Base system (NewSystem() creates a bus automatically)
// We supply an in-memory store
sys := tsd.NewSystem()
// bus := sys.Bus.Get()
sys.Set(new(mem.Store))
dialer := &tsdial.Dialer{Logf: logf}
// netmon, err := netmon.New(bus, logf)
// if err != nil {
// return nil, err
// }
// Userspace Wireguard engine
engine, err := wgengine.NewUserspaceEngine(logf, wgengine.Config{
Dialer: dialer,
// NetMon: netmon,
Dialer: dialer,
SetSubsystem: sys.Set,
ControlKnobs: sys.ControlKnobs(),
HealthTracker: sys.HealthTracker(),
Metrics: sys.UserMetricsRegistry(),
EventBus: sys.Bus.Get(),
})
if err != nil {
return nil, err
return nil, fmt.Errorf("failed to create userspace engine: %w", err)
}
sys.Set(engine)
tun := sys.Tun.Get()
msock := sys.MagicSock.Get()
dnsman := sys.DNSManager.Get()
proxymap := sys.ProxyMapper()
wgstack, err := netstack.Create(logf, tun, engine, msock, dialer, dnsman, proxymap)
sys.Set(wgstack)
wgstack, err := netstack.Create(logf, tun, engine, msock, dialer, dnsman, proxymap)
if err != nil {
return nil, err
return nil, fmt.Errorf("failed to create netstack: %w", err)
}
// Configure the local Netstack and Dialer
sys.Set(wgstack)
wgstack.ProcessLocalIPs = true
wgstack.ProcessSubnets = true
dialer.UseNetstackForIP = func(ip netip.Addr) bool {
return true
}
dialer.NetstackDialTCP = func(ctx context.Context, dst netip.AddrPort) (net.Conn, error) {
return wgstack.DialContextTCP(ctx, dst)
}
dialer.NetstackDialUDP = func(ctx context.Context, dst netip.AddrPort) (net.Conn, error) {
return wgstack.DialContextUDP(ctx, dst)
}
// Dummy logid for the Tailscale backend
logid := logid.PublicID{}
logID := logid.PublicID{}
sys.NetstackRouter.Set(true)
sys.Tun.Get().Start()
server := ipnserver.New(logf, logid, sys.NetMon.Get())
flags := controlclient.LoginDefault | controlclient.LoginEphemeral | controlclient.LocalBackendStartKeyOSNeutral
server := ipnserver.New(logf, logID, sys.NetMon.Get())
backend, err := ipnlocal.NewLocalBackend(logf, logid, sys, flags)
backend, err := ipnlocal.NewLocalBackend(logf, logID, sys, controlclient.LoginEphemeral)
if err != nil {
return nil, err
return nil, fmt.Errorf("failed to create local backend: %w", err)
}
err = wgstack.Start(backend)
if err != nil {
return nil, err
if err := wgstack.Start(backend); err != nil {
return nil, fmt.Errorf("failed to start netstack: %w", err)
}
server.SetLocalBackend(backend)
@@ -133,26 +104,18 @@ func NewTsWasmIpn(options *TsWasmNetOptions, callbacks *TsWasmNetCallbacks) (*Ts
}, nil
}
// Starts the WASM backend which will connect to the Tailscale tailnet and
// register an ephemeral node viewable in the Tailscale admin console.
func (t *TsWasmIpn) Start(ctx context.Context) error {
// Blank "socket" is a requirement for WASM
// This NEEDS to happen before the LocalBackend is started,
listener, err := safesocket.Listen("")
if err != nil {
return fmt.Errorf("failed to create safesocket listener: %w", err)
}
// Start the server BEFORE the LocalBackend is started
go func() {
err := t.server.Run(ctx, listener)
if err != nil {
// TODO: Handle this dispatch using a chan
log.Printf("Failed to run Tailscale server: %v", err)
if err := t.server.Run(ctx, listener); err != nil {
log.Printf("Tailscale server exited: %v", err)
}
}()
// Start the LocalBackend
err = t.backend.Start(ipn.Options{
AuthKey: t.options.PreAuthKey,
UpdatePrefs: &ipn.Prefs{
@@ -163,11 +126,9 @@ func (t *TsWasmIpn) Start(ctx context.Context) error {
LoggedOut: false,
},
})
if err != nil {
return fmt.Errorf("failed to start Tailscale backend: %w", err)
}
log.Printf("Tailscale backend started successfully with hostname: %s", t.options.Hostname)
return nil
}
+24 -87
View File
@@ -3,15 +3,35 @@
package hp_ipn
import (
"errors"
"fmt"
"syscall/js"
"tailscale.com/ipn"
"tailscale.com/types/netmap"
)
// Maps ipn.State values to their string representations for the frontend.
type IPNCallbacks struct {
OnReady func()
OnError func(string)
}
func ParseIPNCallbacks(obj js.Value) *IPNCallbacks {
cb := &IPNCallbacks{
OnReady: func() {},
OnError: func(string) {},
}
onReady := obj.Get("onReady")
if onReady.Type() == js.TypeFunction {
cb.OnReady = func() { onReady.Invoke() }
}
onError := obj.Get("onError")
if onError.Type() == js.TypeFunction {
cb.OnError = func(msg string) { onError.Invoke(msg) }
}
return cb
}
var BackendState = map[ipn.State]string{
ipn.NoState: "NoState",
ipn.Stopped: "Stopped",
@@ -21,86 +41,3 @@ var BackendState = map[ipn.State]string{
ipn.NeedsMachineAuth: "NeedsMachineAuth",
ipn.NeedsLogin: "NeedsLogin",
}
// Represents the callbacks that the TsWasmNet module can invoke to register
// data retrieval and notifications on the frontend.
type TsWasmNetCallbacks struct {
// Changes in the backend state.
NotifyState func(ipn.State)
// Updates to the backend's network map.
NotifyNetMap func(*netmap.NetworkMap)
// If interactive login is required, this passes a login URL.
NotifyBrowseToURL func(string)
// If the process panics, this function is called in go.recover.
NotifyPanicRecover func(string)
}
// Parses a JavaScript object containing the necessary callbacks for the
// TsWasmNet module to properly interact with the frontend.
func ParseTsWasmNetCallbacks(obj js.Value) (*TsWasmNetCallbacks, error) {
if obj.IsUndefined() || obj.IsNull() {
return nil, errors.New("callbacks object is undefined or null")
}
state, err := validateCallback("NotifyState", obj)
if err != nil {
return nil, fmt.Errorf("invalid callback NotifyState: %w", err)
}
// TODO: This is complicated, as the NetworkMap is a complex type.
_, err = validateCallback("NotifyNetMap", obj)
if err != nil {
return nil, fmt.Errorf("invalid callback NotifyNetMap: %w", err)
}
browseURL, err := validateCallback("NotifyBrowseToURL", obj)
if err != nil {
return nil, fmt.Errorf("invalid callback NotifyBrowseToURL: %w", err)
}
panicRecover, err := validateCallback("NotifyPanicRecover", obj)
if err != nil {
return nil, fmt.Errorf("invalid callback NotifyPanicRecover: %w", err)
}
return &TsWasmNetCallbacks{
NotifyState: func(ipnState ipn.State) {
state.Invoke(BackendState[ipnState])
},
NotifyNetMap: func(nm *netmap.NetworkMap) {
// We need to build a JSON representation of the NetworkMap
// For now we just pass the NodeKey since that's what we need.
jsObj := js.ValueOf(map[string]any{
"NodeKey": nm.NodeKey.String(),
})
obj.Get("NotifyNetMap").Invoke(jsObj)
},
NotifyBrowseToURL: func(url string) {
browseURL.Invoke(url)
},
NotifyPanicRecover: func(msg string) {
panicRecover.Invoke(msg)
},
}, nil
}
// Validates the specified key is a JS function and returns it.
func validateCallback(key string, obj js.Value) (*js.Value, error) {
val := obj.Get(key)
if val.IsUndefined() || val.IsNull() {
return nil, errors.New("callback is undefined or null")
}
if val.Type() != js.TypeFunction {
return nil, errors.New("callback is not a function")
}
return &val, nil
}
+38 -71
View File
@@ -7,114 +7,83 @@ import (
"syscall/js"
)
// Represents the options needed to initialize the TsWasmNet module.
type TsWasmNetOptions struct {
// Tailscale control URL, e.g., "https://controlplane.tailscale.com"
type IPNConfig struct {
ControlURL string
// Pre-authentication key for the Tailnet
PreAuthKey string
// Optional hostname, autogenerated if not provided.
Hostname string
Hostname string
}
// Parses the provided JS object to validate and extract the TsWasmNetOptions.
func ParseTsWasmNetOptions(obj js.Value) (*TsWasmNetOptions, error) {
func ParseIPNConfig(obj js.Value) (*IPNConfig, error) {
if obj.IsUndefined() || obj.IsNull() {
return nil, errors.New("TsWasmNetOptions cannot be undefined or null")
return nil, errors.New("config cannot be undefined or null")
}
cUrl := safeString("ControlURL", obj)
preAuthKey := safeString("PreAuthKey", obj)
hostname := safeString("Hostname", obj)
controlURL := safeString("controlURL", obj)
preAuthKey := safeString("preAuthKey", obj)
hostname := safeString("hostname", obj)
if cUrl == "" || preAuthKey == "" || hostname == "" {
return nil, errors.New("missing required fields in TsWasmNetOptions")
if controlURL == "" || preAuthKey == "" || hostname == "" {
return nil, errors.New("missing required fields: controlURL, preAuthKey, hostname")
}
return &TsWasmNetOptions{
ControlURL: cUrl,
return &IPNConfig{
ControlURL: controlURL,
PreAuthKey: preAuthKey,
Hostname: hostname,
}, nil
}
// Options passed from the JS side to pass data to xterm.js.
type SSHXtermConfig struct {
Timeout int // Timeout in seconds for the PTY connection.
Rows int // Number of rows in the PTY.
Cols int // Number of columns in the PTY.
OnStdout func(data js.Value) // Fires when the PTY has output.
OnStderr func(error js.Value) // Fires when the PTY has an error.
OnStdin js.Value // Passes a function to the JS side to provide input.
OnConnect func() // Fires when the PTY is opened.
OnDisconnect func() // Fires when the PTY is closed.
type TunnelConfig struct {
IPAddress string
Username string
Timeout int
OnData func(data string)
OnConnect func()
OnDisconnect func()
}
// Parses the provided JS object to validate and extract SSHXtermConfig.
func ParseSSHXtermConfig(obj js.Value) (*SSHXtermConfig, error) {
func ParseTunnelConfig(obj js.Value) (*TunnelConfig, error) {
if obj.IsUndefined() || obj.IsNull() {
return nil, errors.New("SSHXtermConfig cannot be undefined or null")
return nil, errors.New("tunnel config cannot be undefined or null")
}
ipAddress := safeString("ipAddress", obj)
username := safeString("username", obj)
if ipAddress == "" || username == "" {
return nil, errors.New("missing required fields: ipAddress, username")
}
timeout := safeInt("timeout", obj)
rows := safeInt("rows", obj)
cols := safeInt("cols", obj)
if rows <= 0 || cols <= 0 {
return nil, errors.New("`rows` and `cols` must be positive integers")
}
if timeout <= 0 {
timeout = 30 // Default timeout to 30 seconds if not specified
timeout = 30
}
config := &SSHXtermConfig{
Timeout: timeout,
Rows: rows,
Cols: cols,
config := &TunnelConfig{
IPAddress: ipAddress,
Username: username,
Timeout: timeout,
}
onStdout := obj.Get("onStdout")
if onStdout.IsUndefined() || onStdout.IsNull() || (onStdout.Type() != js.TypeFunction) {
return nil, errors.New("`onStdout` is required and must be a function")
onData := obj.Get("onData")
if onData.IsUndefined() || onData.IsNull() || onData.Type() != js.TypeFunction {
return nil, errors.New("`onData` is required and must be a function")
}
config.OnStdout = func(data js.Value) {
onStdout.Invoke(data)
config.OnData = func(data string) {
onData.Invoke(data)
}
onStderr := obj.Get("onStderr")
if onStderr.IsUndefined() || onStderr.IsNull() || (onStderr.Type() != js.TypeFunction) {
return nil, errors.New("`onStderr` is required and must be a function")
}
config.OnStderr = func(error js.Value) {
onStderr.Invoke(error)
}
onStdin := obj.Get("onStdin")
if onStdin.IsUndefined() || onStdin.IsNull() || (onStdin.Type() != js.TypeFunction) {
return nil, errors.New("`onStdin` is required and must be a function")
}
config.OnStdin = onStdin
onConnect := obj.Get("onConnect")
if onConnect.IsUndefined() || onConnect.IsNull() || (onConnect.Type() != js.TypeFunction) {
if onConnect.IsUndefined() || onConnect.IsNull() || onConnect.Type() != js.TypeFunction {
return nil, errors.New("`onConnect` is required and must be a function")
}
config.OnConnect = func() {
onConnect.Invoke()
}
onDisconnect := obj.Get("onDisconnect")
if onDisconnect.IsUndefined() || onDisconnect.IsNull() || (onDisconnect.Type() != js.TypeFunction) {
if onDisconnect.IsUndefined() || onDisconnect.IsNull() || onDisconnect.Type() != js.TypeFunction {
return nil, errors.New("`onDisconnect` is required and must be a function")
}
config.OnDisconnect = func() {
onDisconnect.Invoke()
}
@@ -122,7 +91,6 @@ func ParseSSHXtermConfig(obj js.Value) (*SSHXtermConfig, error) {
return config, nil
}
// Retrieves a string value from a JS object safely.
func safeString(key string, obj js.Value) string {
if obj.IsUndefined() || obj.IsNull() {
return ""
@@ -136,7 +104,6 @@ func safeString(key string, obj js.Value) string {
return val.String()
}
// Retrieves an integer value from a JS object safely.
func safeInt(key string, obj js.Value) int {
if obj.IsUndefined() || obj.IsNull() {
return 0
+11 -69
View File
@@ -6,94 +6,36 @@ import (
"context"
"fmt"
"log"
"sync"
"tailscale.com/ipn"
"tailscale.com/ipn/ipnlocal"
)
func registerNotifyCallback(callbacks *TsWasmNetCallbacks, lb *ipnlocal.LocalBackend) {
lb.SetNotifyCallback(func(n ipn.Notify) {
// Panics should be treated with care in a JS/wasm environment.
// If a panic occurs, notify the user and either automatically reload
// or give the option to reload.
func registerNotifyCallback(callbacks *IPNCallbacks, lb *ipnlocal.LocalBackend) {
var readyOnce sync.Once
lb.SetNotifyCallback(func(n ipn.Notify) {
defer func() {
rec := recover()
if rec != nil {
callbacks.NotifyPanicRecover(fmt.Sprint(rec))
if rec := recover(); rec != nil {
callbacks.OnError(fmt.Sprint(rec))
}
}()
if n.State != nil {
callbacks.NotifyState(*n.State)
if *n.State == ipn.Running {
readyOnce.Do(callbacks.OnReady)
}
if *n.State == ipn.NeedsLogin {
// If the state is NeedsLogin, we need to force an interactive login.
go forceInteractiveLogin(lb)
}
}
if n.BrowseToURL != nil {
callbacks.NotifyBrowseToURL(*n.BrowseToURL)
}
if n.NetMap != nil {
callbacks.NotifyNetMap(n.NetMap)
}
log.Printf("NOTIFY: %+v", n)
// if nm := n.NetMap; nm != nil {
// jsNetMap := jsNetMap{
// Self: jsNetMapSelfNode{
// jsNetMapNode: jsNetMapNode{
// Name: nm.Name,
// Addresses: mapSliceView(nm.GetAddresses(), func(a netip.Prefix) string { return a.Addr().String() }),
// NodeKey: nm.NodeKey.String(),
// MachineKey: nm.MachineKey.String(),
// },
// MachineStatus: jsMachineStatus[nm.GetMachineStatus()],
// },
// Peers: mapSlice(nm.Peers, func(p tailcfg.NodeView) jsNetMapPeerNode {
// name := p.Name()
// if name == "" {
// // In practice this should only happen for Hello.
// name = p.Hostinfo().Hostname()
// }
// addrs := make([]string, p.Addresses().Len())
// for i, ap := range p.Addresses().All() {
// addrs[i] = ap.Addr().String()
// }
// return jsNetMapPeerNode{
// jsNetMapNode: jsNetMapNode{
// Name: name,
// Addresses: addrs,
// MachineKey: p.Machine().String(),
// NodeKey: p.Key().String(),
// },
// Online: p.Online().Clone(),
// TailscaleSSHEnabled: p.Hostinfo().TailscaleSSHEnabled(),
// }
// }),
// LockedOut: nm.TKAEnabled && nm.SelfNode.KeySignature().Len() == 0,
// }
// if jsonNetMap, err := json.Marshal(jsNetMap); err == nil {
// jsCallbacks.Call("notifyNetMap", string(jsonNetMap))
// } else {
// log.Printf("Could not generate JSON netmap: %v", err)
// }
// }
// if n.BrowseToURL != nil {
// jsCallbacks.Call("notifyBrowseToURL", *n.BrowseToURL)
// }
})
}
// To get auth to work, even with a pre-auth key, we need to
// force an interactive login on the NeedsLogin state.
func forceInteractiveLogin(lb *ipnlocal.LocalBackend) {
err := lb.StartLoginInteractive(context.Background())
if err != nil {
fmt.Printf("Error starting interactive login: %v\n", err)
if err := lb.StartLoginInteractive(context.Background()); err != nil {
log.Printf("Error starting interactive login: %v", err)
}
}
+72 -126
View File
@@ -8,83 +8,71 @@ import (
"io"
"log"
"net"
"syscall/js"
"time"
"golang.org/x/crypto/ssh"
)
// Represents an SSH session over the Tailnet.
type SSHSession struct {
// Hostname on the Tailnet.
Hostname string
IPAddress string
Username string
Config *TunnelConfig
Ipn *TsWasmIpn
Pty *ssh.Session
// Username for the SSH connection.
Username string
// Xterm configuration for the SSH session.
TermConfig *SSHXtermConfig
// Handle to the current IPN connection.
Ipn *TsWasmIpn
// Handle to the current SSH session.
Pty *ssh.Session
// Tracks resize notifications for rows.
ResizeRows int
// Tracks resize notifications for columns.
ResizeCols int
// Reference to our stdin handler, released on close.
stdinHandler *js.Func
stdin io.Writer
resizeCols int
resizeRows int
cancel context.CancelFunc
}
// Creates a new SSH session given a hostname and username.
func (i *TsWasmIpn) NewSSHSession(hostname, username string, termConfig *SSHXtermConfig) *SSHSession {
func (i *TsWasmIpn) NewSSHSession(config *TunnelConfig) *SSHSession {
return &SSHSession{
Hostname: hostname,
Username: username,
TermConfig: termConfig,
Ipn: i,
IPAddress: config.IPAddress,
Username: config.Username,
Config: config,
Ipn: i,
}
}
func (s *SSHSession) ConnectAndRun() {
defer s.TermConfig.OnDisconnect()
defer s.Config.OnDisconnect()
ctx, cancel := context.WithTimeout(context.Background(), time.Duration(s.TermConfig.Timeout)*time.Second)
ctx, cancel := context.WithTimeout(context.Background(), time.Duration(s.Config.Timeout)*time.Second)
s.cancel = cancel
defer cancel()
// TODO: Log here
log.Printf("Attempting SSH dial to host: %s", net.JoinHostPort(s.Hostname, "22"))
conn, err := s.Ipn.dialer.UserDial(ctx, "tcp", net.JoinHostPort(s.Hostname, "22"))
conn, err := s.Ipn.dialer.UserDial(ctx, "tcp", net.JoinHostPort(s.IPAddress, "22"))
if err != nil {
log.Printf("SSH dial error: %v", err)
s.writeError("Dial", err)
return
}
defer conn.Close()
// In Go WASM, gVisor's netstack conn.Read blocks indefinitely without
// a deadline because the single-threaded goroutine scheduler needs the
// deadline machinery to yield to the browser event loop and process
// inbound WireGuard packets. We set a deadline that covers the entire
// SSH handshake and clear it once the session is established.
conn.SetReadDeadline(time.Now().Add(30 * time.Second))
sshConf := &ssh.ClientConfig{
User: s.Username,
HostKeyCallback: func(hostname string, remote net.Addr, key ssh.PublicKey) error {
// Tailscale SSH doesn't use host keys
// TODO: Log that the connection was established
return nil
},
}
// TODO: LOG: Starting SSH Client
sshConn, _, _, err := ssh.NewClientConn(conn, s.Hostname, sshConf)
sshConn, chans, reqs, err := ssh.NewClientConn(conn, s.IPAddress, sshConf)
if err != nil {
s.writeError("SSH", err)
return
}
defer sshConn.Close()
sshClient := ssh.NewClient(sshConn, nil, nil)
conn.SetReadDeadline(time.Time{})
sshClient := ssh.NewClient(sshConn, chans, reqs)
defer sshClient.Close()
pty, err := sshClient.NewSession()
@@ -92,30 +80,28 @@ func (s *SSHSession) ConnectAndRun() {
s.writeError("SSH", err)
return
}
defer pty.Close()
s.Pty = pty
rows := s.TermConfig.Rows
if s.ResizeRows != 0 {
rows = s.ResizeRows
rows := 24
if s.resizeRows != 0 {
rows = s.resizeRows
}
cols := s.TermConfig.Cols
if s.ResizeCols != 0 {
cols = s.ResizeCols
cols := 80
if s.resizeCols != 0 {
cols = s.resizeCols
}
err = pty.RequestPty("xterm", rows, cols, ssh.TerminalModes{
ssh.ECHO: 1, // enable echoing
ssh.ICANON: 1, // canonical mode
ssh.ISIG: 1, // enable signals
ssh.ICRNL: 1, // map CR to NL on input
ssh.IUTF8: 1, // input is UTF-8
ssh.TTY_OP_ISPEED: 14400, // input speed = 14.4kbaud
ssh.TTY_OP_OSPEED: 14400, // output speed = 14.4kbaud
err = pty.RequestPty("xterm-256color", rows, cols, ssh.TerminalModes{
ssh.ECHO: 1,
ssh.ICANON: 1,
ssh.ISIG: 1,
ssh.ICRNL: 1,
ssh.IUTF8: 1,
ssh.TTY_OP_ISPEED: 14400,
ssh.TTY_OP_OSPEED: 14400,
})
if err != nil {
s.writeError("SSH", err)
return
@@ -126,8 +112,7 @@ func (s *SSHSession) ConnectAndRun() {
s.writeError("SSH", err)
return
}
s.wireStdinHandler(stdin)
s.stdin = stdin
stdout, err := pty.StdoutPipe()
if err != nil {
@@ -141,100 +126,61 @@ func (s *SSHSession) ConnectAndRun() {
return
}
go io.Copy(XtermPipe{s.TermConfig.OnStdout}, stdout)
go io.Copy(XtermPipe{s.TermConfig.OnStderr}, stderr)
go io.Copy(DataPipe{s.Config.OnData}, stdout)
go io.Copy(DataPipe{s.Config.OnData}, stderr)
// Create our shell
err = pty.Shell()
if err != nil {
s.writeError("SSH", err)
return
}
s.TermConfig.OnConnect()
err = pty.Wait()
if err != nil {
s.writeError("SSH", err)
return
s.Config.OnConnect()
if err := pty.Wait(); err != nil {
log.Printf("SSH session ended: %v", err)
}
}
// Resize resizes the terminal for the SSH session.
// TODO: This does NOT work correctly from Xterm.js
func (s *SSHSession) Resize(rows, cols int) error {
// Used to handle resizes while still connecting.
func (s *SSHSession) WriteInput(data string) {
if s.stdin != nil {
s.stdin.Write([]byte(data))
}
}
// Resize takes cols and rows (JS convention: cols first, rows second)
// and translates to SSH's WindowChange(rows, cols) order.
func (s *SSHSession) Resize(cols, rows int) error {
if s.Pty == nil {
s.ResizeRows = rows
s.ResizeCols = cols
s.resizeCols = cols
s.resizeRows = rows
return nil
}
return s.Pty.WindowChange(cols, rows)
return s.Pty.WindowChange(rows, cols)
}
// Closes the SSH session.
func (s *SSHSession) Close() error {
if s.stdinHandler != nil {
s.stdinHandler.Release()
s.stdinHandler = nil
if s.cancel != nil {
s.cancel()
s.cancel = nil
}
if s.Pty != nil {
err := s.Pty.Close()
if err != nil {
return err
}
return s.Pty.Close()
}
return nil
}
// Wires up the stdin handler to pass data from JS to the SSH session.
func (s *SSHSession) wireStdinHandler(w io.Writer) {
if s.stdinHandler != nil {
s.stdinHandler.Release()
s.stdinHandler = nil
}
cb := js.FuncOf(func(this js.Value, args []js.Value) any {
v := args[0] // This is ALWAYS a Uint8Array technically
len := v.Get("byteLength").Int()
buf := make([]byte, len)
js.CopyBytesToGo(buf, v)
if _, err := w.Write(buf); err != nil {
s.writeError("SSH Stdin", err)
return nil
}
// TODO: Remove debug log
log.Printf("SSH wrote %d bytes: %v (%q)", len, buf, string(buf))
return nil
})
s.stdinHandler = &cb
s.TermConfig.OnStdin.Invoke(cb)
}
// Quick easy formatter for writing errors to the terminal.
func (s *SSHSession) writeError(label string, err error) {
o := fmt.Sprintf("%s error: %v\r\n", label, err)
uint8Array := js.Global().Get("Uint8Array").New(len(o))
js.CopyBytesToJS(uint8Array, []byte(o))
s.TermConfig.OnStderr(uint8Array)
s.Config.OnData(fmt.Sprintf("%s error: %v\r\n", label, err))
}
// io.Writer "emulator" to pass to the ssh module.
type XtermPipe struct {
// Function to call when data is written.
Send func(data js.Value)
type DataPipe struct {
Send func(data string)
}
// Write implements the io.Writer interface for XtermPipe.
func (x XtermPipe) Write(data []byte) (int, error) {
uint8Array := js.Global().Get("Uint8Array").New(len(data))
js.CopyBytesToJS(uint8Array, data)
x.Send(uint8Array)
func (p DataPipe) Write(data []byte) (int, error) {
p.Send(string(data))
return len(data), nil
}
+7 -3
View File
@@ -5,6 +5,8 @@
makeWrapper,
nodejs_24,
pnpm_10,
fetchPnpmDeps,
pnpmConfigHook,
stdenv,
}: let
pkg = builtins.fromJSON (builtins.readFile ../package.json);
@@ -20,16 +22,18 @@ in
nativeBuildInputs = [
makeWrapper
nodejs_24
pnpm_10.configHook
pnpm_10
pnpmConfigHook
git
];
dontCheckForBrokenSymlinks = true;
pnpmDeps = pnpm_10.fetchDeps {
pnpmDeps = fetchPnpmDeps {
inherit (finalAttrs) pname version src;
fetcherVersion = 3;
hash = "sha256-zEivmBfx9qCQBHvSA1kRRmjsb58uPv5Ip+geV40CP4Q=";
pnpm = pnpm_10;
hash = "sha256-oJt5ysYXytwNR8Yx5nkEN++YQNaf9EO4fP4CPrChUPo=";
};
buildPhase = ''
+9 -1
View File
@@ -22,7 +22,15 @@ in
buildPhase = ''
export GOOS=js
export GOARCH=wasm
go build -o hp_ssh.wasm ./cmd/hp_ssh
# 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
patch -d vendor/tailscale.com -p1 < patches/tailscale-derp-port.patch
fi
go build -mod=vendor -o hp_ssh.wasm ./cmd/hp_ssh
'';
installPhase = ''
+2 -8
View File
@@ -1,6 +1,6 @@
{
"name": "headplane",
"version": "0.7.0-beta.1",
"version": "0.7.0-beta.2",
"private": true,
"type": "module",
"sideEffects": false,
@@ -33,11 +33,6 @@
"@uiw/codemirror-theme-github": "4.25.1",
"@uiw/codemirror-theme-xcode": "4.25.5",
"@uiw/react-codemirror": "4.25.5",
"@xterm/addon-clipboard": "^0.2.0",
"@xterm/addon-fit": "^0.11.0",
"@xterm/addon-unicode11": "^0.9.0",
"@xterm/addon-web-links": "^0.12.0",
"@xterm/xterm": "^6.0.0",
"arktype": "^2.1.29",
"clsx": "^2.1.1",
"drizzle-orm": "1.0.0-beta.16-c2458b2",
@@ -47,7 +42,6 @@
"lucide-react": "^0.575.0",
"mime": "^4.1.0",
"openapi-types": "^12.1.3",
"openid-client": "6.8.2",
"react": "19.2.4",
"react-codemirror-merge": "4.25.5",
"react-dom": "19.2.4",
@@ -56,13 +50,13 @@
"react-router-dom": "^7.13.1",
"react-router-hono-server": "2.25.0",
"remix-utils": "^9.0.1",
"restty": "^0.1.35",
"tailwind-merge": "3.5.0",
"ulidx": "2.4.1",
"undici": "7.22.0",
"yaml": "2.8.2"
},
"devDependencies": {
"@faker-js/faker": "10.3.0",
"@react-router/dev": "^7.13.1",
"@shopify/lang-jsonc": "^1.0.1",
"@tailwindcss/vite": "^4.2.1",
+30
View File
@@ -0,0 +1,30 @@
Fix DERP WebSocket URL 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.
--- a/derp/derphttp/derphttp_client.go
+++ b/derp/derphttp/derphttp_client.go
@@ -279,10 +279,19 @@
return c.url.String()
}
proto := "https"
+ var port string
if debugUseDERPHTTP() {
proto = "http"
+ port = "3340"
}
- return fmt.Sprintf("%s://%s/derp", proto, node.HostName)
+ if node != nil && node.DERPPort != 0 {
+ port = fmt.Sprint(node.DERPPort)
+ }
+ host := node.HostName
+ if port != "" {
+ host = net.JoinHostPort(node.HostName, port)
+ }
+ return fmt.Sprintf("%s://%s/derp", proto, host)
}
// AddressFamilySelector decides whether IPv6 is preferred for
+24 -55
View File
@@ -55,21 +55,6 @@ importers:
'@uiw/react-codemirror':
specifier: 4.25.5
version: 4.25.5(@babel/runtime@7.28.6)(@codemirror/autocomplete@6.18.2(@codemirror/language@6.12.2)(@codemirror/state@6.6.0)(@codemirror/view@6.39.15)(@lezer/common@1.5.1))(@codemirror/language@6.12.2)(@codemirror/lint@6.8.2)(@codemirror/search@6.5.7)(@codemirror/state@6.6.0)(@codemirror/theme-one-dark@6.1.2)(@codemirror/view@6.39.15)(codemirror@6.0.1(@lezer/common@1.5.1))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
'@xterm/addon-clipboard':
specifier: ^0.2.0
version: 0.2.0
'@xterm/addon-fit':
specifier: ^0.11.0
version: 0.11.0
'@xterm/addon-unicode11':
specifier: ^0.9.0
version: 0.9.0
'@xterm/addon-web-links':
specifier: ^0.12.0
version: 0.12.0
'@xterm/xterm':
specifier: ^6.0.0
version: 6.0.0
arktype:
specifier: ^2.1.29
version: 2.1.29
@@ -97,9 +82,6 @@ importers:
openapi-types:
specifier: ^12.1.3
version: 12.1.3
openid-client:
specifier: 6.8.2
version: 6.8.2
react:
specifier: 19.2.4
version: 19.2.4
@@ -124,6 +106,9 @@ importers:
remix-utils:
specifier: ^9.0.1
version: 9.0.1(@oslojs/crypto@1.0.1)(@oslojs/encoding@1.1.0)(@standard-schema/spec@1.1.0)(react-router@7.13.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react@19.2.4)
restty:
specifier: ^0.1.35
version: 0.1.35(typescript@5.9.3)
tailwind-merge:
specifier: 3.5.0
version: 3.5.0
@@ -137,9 +122,6 @@ importers:
specifier: 2.8.2
version: 2.8.2
devDependencies:
'@faker-js/faker':
specifier: 10.3.0
version: 10.3.0
'@react-router/dev':
specifier: ^7.13.1
version: 7.13.1(@types/node@25.3.1)(jiti@2.6.1)(lightningcss@1.32.0)(react-router@7.13.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(terser@5.39.0)(tsx@4.21.0)(typescript@5.9.3)(vite@8.0.0(@types/node@25.3.1)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.0)(tsx@4.21.0)(yaml@2.8.2))(yaml@2.8.2)
@@ -1015,10 +997,6 @@ packages:
cpu: [x64]
os: [win32]
'@faker-js/faker@10.3.0':
resolution: {integrity: sha512-It0Sne6P3szg7JIi6CgKbvTZoMjxBZhcv91ZrqrNuaZQfB5WoqYYbzCUOq89YR+VY8juY9M1vDWmDDa2TzfXCw==}
engines: {node: ^20.19.0 || ^22.13.0 || ^23.5.0 || >=24.0.0, npm: '>=10'}
'@floating-ui/core@1.7.5':
resolution: {integrity: sha512-1Ih4WTWyw0+lKyFMcBHGbb5U5FtuHJuujoyyr5zTaWS5EYMeT6Jb2AuDeftsCsEuchO+mM2ij5+q9crhydzLhQ==}
@@ -2221,21 +2199,6 @@ packages:
peerDependencies:
vue: ^3.5.0
'@xterm/addon-clipboard@0.2.0':
resolution: {integrity: sha512-Dl31BCtBhLaUEECUbEiVcCLvLBbaeGYdT7NofB8OJkGTD3MWgBsaLjXvfGAD4tQNHhm6mbKyYkR7XD8kiZsdNg==}
'@xterm/addon-fit@0.11.0':
resolution: {integrity: sha512-jYcgT6xtVYhnhgxh3QgYDnnNMYTcf8ElbxxFzX0IZo+vabQqSPAjC3c1wJrKB5E19VwQei89QCiZZP86DCPF7g==}
'@xterm/addon-unicode11@0.9.0':
resolution: {integrity: sha512-FxDnYcyuXhNl+XSqGZL/t0U9eiNb/q3EWT5rYkQT/zuig8Gz/VagnQANKHdDWFM2lTMk9ly0EFQxxxtZUoRetw==}
'@xterm/addon-web-links@0.12.0':
resolution: {integrity: sha512-4Smom3RPyVp7ZMYOYDoC/9eGJJJqYhnPLGGqJ6wOBfB8VxPViJNSKdgRYb8NpaM6YSelEKbA2SStD7lGyqaobw==}
'@xterm/xterm@6.0.0':
resolution: {integrity: sha512-TQwDdQGtwwDt+2cgKDLn0IRaSxYu1tSUjgKarSDkUM0ZNiSRXFpjxEsvc/Zgc5kq5omJ+V0a8/kIM2WD3sMOYg==}
abort-controller@3.0.0:
resolution: {integrity: sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==}
engines: {node: '>=6.5'}
@@ -3763,6 +3726,10 @@ packages:
resolve-pkg-maps@1.0.0:
resolution: {integrity: sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==}
restty@0.1.35:
resolution: {integrity: sha512-IUr4NsdYCTErKBxuAd0eu/KdiGZhADBxA0ddSPEEIxAz8np3RT1bLiL9iIH+wglmUeSmHRmJzkPN7hGJJ+E/Yw==}
engines: {bun: '>=1.2.0'}
retry@0.12.0:
resolution: {integrity: sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow==}
engines: {node: '>= 4'}
@@ -3987,6 +3954,11 @@ packages:
text-decoder@1.2.3:
resolution: {integrity: sha512-3/o9z3X0X0fTupwsYvR03pJ/DjWuqqrfwBgTQzdWDiQSm9KitAyz/9WqsT2JQW7KV2m+bC2ol/zqpW37NHxLaA==}
text-shaper@0.1.22:
resolution: {integrity: sha512-OYxqPcNEox3tlXilxZVPNub8EU/A6Lxy9vlCAe362fiGI9f3pMav497wL7cecX0CPq1yVtiBn/lii/ZZUUk9bQ==}
peerDependencies:
typescript: ^5
tinybench@2.9.0:
resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==}
@@ -5090,8 +5062,6 @@ snapshots:
'@esbuild/win32-x64@0.27.3':
optional: true
'@faker-js/faker@10.3.0': {}
'@floating-ui/core@1.7.5':
dependencies:
'@floating-ui/utils': 0.2.11
@@ -6185,18 +6155,6 @@ snapshots:
dependencies:
vue: 3.5.18(typescript@5.9.3)
'@xterm/addon-clipboard@0.2.0':
dependencies:
js-base64: 3.7.8
'@xterm/addon-fit@0.11.0': {}
'@xterm/addon-unicode11@0.9.0': {}
'@xterm/addon-web-links@0.12.0': {}
'@xterm/xterm@6.0.0': {}
abort-controller@3.0.0:
dependencies:
event-target-shim: 5.0.1
@@ -6960,7 +6918,8 @@ snapshots:
jose@6.2.1: {}
js-base64@3.7.8: {}
js-base64@3.7.8:
optional: true
js-md4@0.3.2: {}
@@ -7646,6 +7605,12 @@ snapshots:
resolve-pkg-maps@1.0.0: {}
restty@0.1.35(typescript@5.9.3):
dependencies:
text-shaper: 0.1.22(typescript@5.9.3)
transitivePeerDependencies:
- typescript
retry@0.12.0: {}
rfc4648@1.5.4: {}
@@ -7973,6 +7938,10 @@ snapshots:
transitivePeerDependencies:
- react-native-b4a
text-shaper@0.1.22(typescript@5.9.3):
dependencies:
typescript: 5.9.3
tinybench@2.9.0: {}
tinyexec@1.0.2: {}
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+1 -2
View File
@@ -114,8 +114,7 @@ describe.sequential.for(HS_VERSIONS)("Headscale %s: Pre-auth Keys", (version) =>
const preAuthKeys = await client.getPreAuthKeys(preAuthKeyUser.id);
expect(preAuthKeys.length).toBeGreaterThanOrEqual(2);
const preAuthKeyToExpire = preAuthKeys[0];
await client.expirePreAuthKey(preAuthKeyUser.id, preAuthKeyToExpire.key);
await client.expirePreAuthKey(preAuthKeyUser.id, preAuthKeyToExpire);
const preAuthKeysAfterExpire = await client.getPreAuthKeys(preAuthKeyUser.id);
const expiredKey = preAuthKeysAfterExpire.find((key) => key.key === preAuthKeyToExpire.key);
+167
View File
@@ -0,0 +1,167 @@
import { afterAll, beforeAll, describe, expect, test, vi } from "vitest";
import { createOidcService, type OidcConfig } from "~/server/oidc/provider";
import { type DexEnv, startDex } from "./start-dex";
vi.mock("~/utils/log", () => ({
default: { warn: vi.fn(), error: vi.fn(), debug: vi.fn(), info: vi.fn() },
}));
let dex: DexEnv;
beforeAll(async () => {
dex = await startDex();
}, 60_000);
afterAll(async () => {
await dex?.container.stop({ remove: true, removeVolumes: true });
});
function dexConfig(overrides?: Partial<OidcConfig>): OidcConfig {
// Dex's issuer inside the container is http://0.0.0.0:5556 but we
// connect via the mapped port. We provide manual endpoint overrides
// pointing to the external URL so the service can actually reach them,
// while the issuer stays as configured in Dex for JWT validation.
return {
issuer: "http://0.0.0.0:5556",
clientId: "test-client",
clientSecret: "test-secret",
baseUrl: "http://localhost",
authorizationEndpoint: `${dex.issuerUrl}/auth`,
tokenEndpoint: `${dex.issuerUrl}/token`,
userinfoEndpoint: `${dex.issuerUrl}/userinfo`,
jwksUri: `${dex.issuerUrl}/keys`,
...overrides,
};
}
describe("discovery against real Dex", () => {
test("resolves endpoints via manual overrides", async () => {
const svc = createOidcService(dexConfig());
const result = await svc.discover();
expect(result.ok).toBe(true);
if (!result.ok) {
return;
}
expect(result.value.authorizationEndpoint).toContain("/auth");
expect(result.value.tokenEndpoint).toContain("/token");
expect(result.value.jwksUri).toContain("/keys");
});
test("fetches real discovery document from Dex", async () => {
const svc = createOidcService({
issuer: dex.issuerUrl,
clientId: "test-client",
clientSecret: "test-secret",
baseUrl: "http://localhost",
});
const result = await svc.discover();
expect(result.ok).toBe(true);
if (!result.ok) {
return;
}
// Dex returns endpoints with the internal issuer
expect(result.value.authorizationEndpoint).toContain("/auth");
expect(result.value.tokenEndpoint).toContain("/token");
expect(result.value.jwksUri).toContain("/keys");
});
test("status is ready after discovery", async () => {
const svc = createOidcService(dexConfig());
await svc.discover();
expect(svc.status().state).toBe("ready");
});
});
describe("startFlow against real Dex", () => {
test("builds a valid authorization URL", async () => {
const svc = createOidcService(dexConfig());
const result = await svc.startFlow();
expect(result.ok).toBe(true);
if (!result.ok) {
return;
}
const url = new URL(result.value.url);
expect(url.pathname).toBe("/auth");
expect(url.searchParams.get("client_id")).toBe("test-client");
expect(url.searchParams.get("response_type")).toBe("code");
expect(url.searchParams.get("redirect_uri")).toBe("http://localhost/admin/oidc/callback");
expect(url.searchParams.get("scope")).toContain("openid");
});
test("PKCE challenge is included by default", async () => {
const svc = createOidcService(dexConfig());
const result = await svc.startFlow();
expect(result.ok).toBe(true);
if (!result.ok) {
return;
}
const url = new URL(result.value.url);
expect(url.searchParams.get("code_challenge_method")).toBe("S256");
expect(url.searchParams.get("code_challenge")).toBeTruthy();
});
});
describe("handleCallback error handling against real Dex", () => {
test("invalid authorization code returns error", async () => {
const svc = createOidcService(dexConfig({ usePkce: false }));
const flowResult = await svc.startFlow();
if (!flowResult.ok) {
throw new Error("startFlow failed");
}
const { flowState } = flowResult.value;
const params = new URLSearchParams({
code: "invalid-code",
state: flowState.state,
});
const result = await svc.handleCallback(params, flowState);
expect(result.ok).toBe(false);
});
test("state mismatch detected before hitting Dex", async () => {
const svc = createOidcService(dexConfig());
const flowResult = await svc.startFlow();
if (!flowResult.ok) {
throw new Error("startFlow failed");
}
const params = new URLSearchParams({
code: "any-code",
state: "tampered-state",
});
const result = await svc.handleCallback(params, flowResult.value.flowState);
expect(result.ok).toBe(false);
if (result.ok) {
return;
}
expect(result.error.code).toBe("state_mismatch");
});
});
describe("invalidate and rediscovery against real Dex", () => {
test("invalidate forces rediscovery", async () => {
const svc = createOidcService(dexConfig());
await svc.discover();
expect(svc.status().state).toBe("ready");
svc.invalidate();
expect(svc.status().state).toBe("pending");
const result = await svc.discover();
expect(result.ok).toBe(true);
expect(svc.status().state).toBe("ready");
});
});
+30
View File
@@ -0,0 +1,30 @@
import tc from "testcontainers";
export interface DexEnv {
container: tc.StartedTestContainer;
issuerUrl: string;
}
export async function startDex(): Promise<DexEnv> {
const container = await new tc.GenericContainer("dexidp/dex:v2.41.1")
.withExposedPorts(5556)
.withEnvironment({
DEX_ISSUER: "http://0.0.0.0:5556",
DEX_ENABLE_PASSWORD_DB: "true",
DEX_OAUTH2_SKIP_APPROVAL_SCREEN: "true",
})
.withWaitStrategy(tc.Wait.forLogMessage("listening on", 1).withStartupTimeout(30_000))
.start();
const host = container.getHost();
const port = container.getMappedPort(5556);
// Dex's issuer is configured as http://0.0.0.0:5556 inside the
// container. The external URL uses the mapped port. Discovery
// will return endpoints with the internal issuer, but that's fine
// for testing discovery + startFlow. The issuer mismatch is
// expected and logged at debug level.
const issuerUrl = `http://${host}:${port}`;
return { container, issuerUrl };
}
+2 -2
View File
@@ -1,13 +1,13 @@
import { drizzle } from "drizzle-orm/node-sqlite";
import { migrate } from "drizzle-orm/node-sqlite/migrator";
import { AuthService } from "~/server/web/auth";
import { createAuthService } from "~/server/web/auth";
export function createTestAuth() {
const db = drizzle(":memory:");
migrate(db, { migrationsFolder: "./drizzle" });
const auth = new AuthService({
const auth = createAuthService({
secret: "test-secret-key-for-unit-tests",
db,
cookie: {
+878
View File
@@ -0,0 +1,878 @@
import { createServer, type IncomingMessage, type Server, type ServerResponse } from "node:http";
import { SignJWT, exportJWK, generateKeyPair } from "jose";
import { afterAll, beforeAll, describe, expect, test, vi } from "vitest";
import { createOidcService, type OidcConfig } from "~/server/oidc/provider";
vi.mock("~/utils/log", () => ({
default: { warn: vi.fn(), error: vi.fn(), debug: vi.fn(), info: vi.fn() },
}));
let server: Server;
let baseUrl: string;
let privateKey: CryptoKey;
let publicJwk: Record<string, unknown>;
const CLIENT_ID = "test-client";
const CLIENT_SECRET = "test-secret";
let tokenHandler: (req: IncomingMessage, res: ServerResponse) => void;
let userinfoHandler: ((req: IncomingMessage, res: ServerResponse) => void) | undefined;
async function signIdToken(claims: Record<string, unknown>, nonce?: string) {
const jwt = new SignJWT({ nonce, ...claims })
.setProtectedHeader({ alg: "RS256", kid: "test-key" })
.setIssuer(baseUrl)
.setAudience(CLIENT_ID)
.setIssuedAt()
.setExpirationTime("5m");
return jwt.sign(privateKey);
}
// You would think this is a lot better in 2026, but no
function readBody(req: IncomingMessage): Promise<string> {
return new Promise((resolve) => {
let body = "";
req.on("data", (chunk: Buffer) => {
body += chunk.toString();
});
req.on("end", () => resolve(body));
});
}
beforeAll(async () => {
const keyPair = await generateKeyPair("RS256");
privateKey = keyPair.privateKey as CryptoKey;
const exported = await exportJWK(keyPair.publicKey);
publicJwk = { ...exported, kid: "test-key", use: "sig", alg: "RS256" };
server = createServer(async (req, res) => {
const url = new URL(req.url!, "http://localhost");
if (url.pathname === "/.well-known/openid-configuration") {
res.writeHead(200, { "Content-Type": "application/json" });
res.end(
JSON.stringify({
issuer: baseUrl,
authorization_endpoint: `${baseUrl}/authorize`,
token_endpoint: `${baseUrl}/token`,
userinfo_endpoint: `${baseUrl}/userinfo`,
jwks_uri: `${baseUrl}/jwks`,
end_session_endpoint: `${baseUrl}/logout`,
}),
);
return;
}
if (url.pathname === "/jwks") {
res.writeHead(200, { "Content-Type": "application/json" });
res.end(JSON.stringify({ keys: [publicJwk] }));
return;
}
if (url.pathname === "/token") {
tokenHandler(req, res);
return;
}
if (url.pathname === "/userinfo" && userinfoHandler) {
userinfoHandler(req, res);
return;
}
res.writeHead(404);
res.end();
});
await new Promise<void>((resolve) => {
server.listen(0, "127.0.0.1", () => {
const addr = server.address();
if (typeof addr === "object" && addr) {
baseUrl = `http://127.0.0.1:${addr.port}`;
}
resolve();
});
});
});
afterAll(() => {
server?.close();
});
function testConfig(overrides?: Partial<OidcConfig>): OidcConfig {
return {
issuer: baseUrl,
clientId: CLIENT_ID,
clientSecret: CLIENT_SECRET,
baseUrl: "https://headplane.example.com",
...overrides,
};
}
describe("status", () => {
test("returns pending before discovery", () => {
const svc = createOidcService(testConfig());
expect(svc.status().state).toBe("pending");
});
test("returns ready after successful discovery", async () => {
const svc = createOidcService(testConfig());
await svc.discover();
expect(svc.status().state).toBe("ready");
});
test("returns error after failed discovery", async () => {
const svc = createOidcService(testConfig({ issuer: "http://127.0.0.1:1" }));
await svc.discover();
const status = svc.status();
expect(status.state).toBe("error");
if (status.state === "error") {
expect(status.error.code).toBe("discovery_failed");
}
});
});
describe("discover", () => {
test("resolves endpoints from discovery document", async () => {
const svc = createOidcService(testConfig());
const result = await svc.discover();
expect(result.ok).toBe(true);
if (result.ok) {
expect(result.value.authorizationEndpoint).toBe(`${baseUrl}/authorize`);
expect(result.value.tokenEndpoint).toBe(`${baseUrl}/token`);
expect(result.value.jwksUri).toBe(`${baseUrl}/jwks`);
expect(result.value.userinfoEndpoint).toBe(`${baseUrl}/userinfo`);
expect(result.value.endSessionEndpoint).toBe(`${baseUrl}/logout`);
}
});
test("caches successful discovery", async () => {
const svc = createOidcService(testConfig());
const first = await svc.discover();
const second = await svc.discover();
expect(first).toStrictEqual(second);
});
test("skips discovery when all endpoints are manual", async () => {
const svc = createOidcService(
testConfig({
issuer: "http://127.0.0.1:1",
authorizationEndpoint: "http://example.com/auth",
tokenEndpoint: "http://example.com/token",
jwksUri: "http://example.com/jwks",
}),
);
const result = await svc.discover();
expect(result.ok).toBe(true);
if (result.ok) {
expect(result.value.authorizationEndpoint).toBe("http://example.com/auth");
}
});
test("returns missing_endpoints when discovery is incomplete", async () => {
const incomplete = createServer((_, res) => {
res.writeHead(200, { "Content-Type": "application/json" });
res.end(
JSON.stringify({
issuer: "http://localhost",
authorization_endpoint: "http://localhost/auth",
}),
);
});
await new Promise<void>((resolve) => incomplete.listen(0, "127.0.0.1", resolve));
const addr = incomplete.address();
const port = typeof addr === "object" && addr ? addr.port : 0;
const svc = createOidcService(testConfig({ issuer: `http://127.0.0.1:${port}` }));
const result = await svc.discover();
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error.code).toBe("missing_endpoints");
}
incomplete.close();
});
test("retries after failure on next call", async () => {
const svc = createOidcService(testConfig({ issuer: "http://127.0.0.1:1" }));
const r1 = await svc.discover();
expect(r1.ok).toBe(false);
svc.reload(testConfig());
const r2 = await svc.discover();
expect(r2.ok).toBe(true);
});
test("config overrides take precedence over discovery", async () => {
const svc = createOidcService(
testConfig({
authorizationEndpoint: "http://override.example.com/auth",
}),
);
const result = await svc.discover();
expect(result.ok).toBe(true);
if (result.ok) {
expect(result.value.authorizationEndpoint).toBe("http://override.example.com/auth");
expect(result.value.tokenEndpoint).toBe(`${baseUrl}/token`);
}
});
});
describe("invalidate and reload", () => {
test("invalidate resets to pending", async () => {
const svc = createOidcService(testConfig());
await svc.discover();
expect(svc.status().state).toBe("ready");
svc.invalidate();
expect(svc.status().state).toBe("pending");
});
test("reload clears state and applies new config", async () => {
const svc = createOidcService(testConfig());
await svc.discover();
svc.reload(testConfig({ issuer: "http://127.0.0.1:1" }));
expect(svc.status().state).toBe("pending");
const result = await svc.discover();
expect(result.ok).toBe(false);
});
});
describe("startFlow", () => {
test("builds authorization URL with required params", async () => {
const svc = createOidcService(testConfig());
const result = await svc.startFlow();
expect(result.ok).toBe(true);
if (!result.ok) {
return;
}
const url = new URL(result.value.url);
expect(`${url.origin}${url.pathname}`).toBe(`${baseUrl}/authorize`);
expect(url.searchParams.get("response_type")).toBe("code");
expect(url.searchParams.get("client_id")).toBe(CLIENT_ID);
expect(url.searchParams.get("scope")).toBe("openid email profile");
expect(url.searchParams.get("state")).toBe(result.value.flowState.state);
expect(url.searchParams.get("nonce")).toBe(result.value.flowState.nonce);
expect(url.searchParams.get("redirect_uri")).toBe(
"https://headplane.example.com/admin/oidc/callback",
);
});
test("includes PKCE challenge by default", async () => {
const svc = createOidcService(testConfig());
const result = await svc.startFlow();
expect(result.ok).toBe(true);
if (!result.ok) return;
const url = new URL(result.value.url);
expect(url.searchParams.get("code_challenge_method")).toBe("S256");
expect(url.searchParams.get("code_challenge")).toBeTruthy();
expect(result.value.flowState.codeVerifier).toBeTruthy();
});
test("omits PKCE when disabled", async () => {
const svc = createOidcService(testConfig({ usePkce: false }));
const result = await svc.startFlow();
expect(result.ok).toBe(true);
if (!result.ok) return;
const url = new URL(result.value.url);
expect(url.searchParams.has("code_challenge")).toBe(false);
expect(url.searchParams.has("code_challenge_method")).toBe(false);
});
test("uses custom scope", async () => {
const svc = createOidcService(testConfig({ scope: "openid email" }));
const result = await svc.startFlow();
expect(result.ok).toBe(true);
if (!result.ok) return;
const url = new URL(result.value.url);
expect(url.searchParams.get("scope")).toBe("openid email");
});
test("passes extra_params", async () => {
const svc = createOidcService(
testConfig({
extraParams: { prompt: "select_account", hd: "example.com" },
}),
);
const result = await svc.startFlow();
expect(result.ok).toBe(true);
if (!result.ok) return;
const url = new URL(result.value.url);
expect(url.searchParams.get("prompt")).toBe("select_account");
expect(url.searchParams.get("hd")).toBe("example.com");
});
test("generates unique state and nonce per call", async () => {
const svc = createOidcService(testConfig());
const r1 = await svc.startFlow();
const r2 = await svc.startFlow();
expect(r1.ok && r2.ok).toBe(true);
if (!r1.ok || !r2.ok) return;
expect(r1.value.flowState.state).not.toBe(r2.value.flowState.state);
expect(r1.value.flowState.nonce).not.toBe(r2.value.flowState.nonce);
});
});
describe("handleCallback", () => {
test("successful flow returns identity", async () => {
const svc = createOidcService(testConfig({ usePkce: false }));
const flowResult = await svc.startFlow();
if (!flowResult.ok) throw new Error("startFlow failed");
const { flowState } = flowResult.value;
const idToken = await signIdToken(
{
sub: "user-123",
name: "Test User",
email: "test@example.com",
preferred_username: "testuser",
},
flowState.nonce,
);
tokenHandler = async (_req, res) => {
res.writeHead(200, { "Content-Type": "application/json" });
res.end(
JSON.stringify({
access_token: "mock-access-token",
id_token: idToken,
token_type: "Bearer",
}),
);
};
userinfoHandler = undefined;
const params = new URLSearchParams({ code: "test-code", state: flowState.state });
const result = await svc.handleCallback(params, flowState);
expect(result.ok).toBe(true);
if (!result.ok) {
return;
}
expect(result.value.issuer).toBe(baseUrl);
expect(result.value.subject).toBe("user-123");
expect(result.value.name).toBe("Test User");
expect(result.value.email).toBe("test@example.com");
expect(result.value.username).toBe("testuser");
});
test("state mismatch returns error", async () => {
const svc = createOidcService(testConfig({ usePkce: false }));
const flowResult = await svc.startFlow();
if (!flowResult.ok) throw new Error("startFlow failed");
const { flowState } = flowResult.value;
const params = new URLSearchParams({ code: "test-code", state: "wrong-state" });
const result = await svc.handleCallback(params, flowState);
expect(result.ok).toBe(false);
if (result.ok) {
return;
}
expect(result.error.code).toBe("state_mismatch");
});
test("provider error in callback params", async () => {
const svc = createOidcService(testConfig());
const flowResult = await svc.startFlow();
if (!flowResult.ok) throw new Error("startFlow failed");
const params = new URLSearchParams({
error: "access_denied",
error_description: "User denied",
});
const result = await svc.handleCallback(params, flowResult.value.flowState);
expect(result.ok).toBe(false);
if (result.ok) {
return;
}
expect(result.error.code).toBe("token_exchange_failed");
});
test("missing authorization code", async () => {
const svc = createOidcService(testConfig({ usePkce: false }));
const flowResult = await svc.startFlow();
if (!flowResult.ok) {
throw new Error("startFlow failed");
}
const { flowState } = flowResult.value;
const params = new URLSearchParams({ state: flowState.state });
const result = await svc.handleCallback(params, flowState);
expect(result.ok).toBe(false);
if (result.ok) {
return;
}
expect(result.error.code).toBe("token_exchange_failed");
});
test("nonce mismatch returns error", async () => {
const svc = createOidcService(testConfig({ usePkce: false }));
const flowResult = await svc.startFlow();
if (!flowResult.ok) {
throw new Error("startFlow failed");
}
const { flowState } = flowResult.value;
const idToken = await signIdToken({ sub: "user-123" }, "wrong-nonce");
tokenHandler = async (_req, res) => {
res.writeHead(200, { "Content-Type": "application/json" });
res.end(
JSON.stringify({
access_token: "mock-access-token",
id_token: idToken,
token_type: "Bearer",
}),
);
};
userinfoHandler = undefined;
const params = new URLSearchParams({ code: "test-code", state: flowState.state });
const result = await svc.handleCallback(params, flowState);
expect(result.ok).toBe(false);
if (result.ok) {
return;
}
expect(result.error.code).toBe("nonce_mismatch");
});
test("missing sub claim returns error", async () => {
const svc = createOidcService(testConfig({ usePkce: false }));
const flowResult = await svc.startFlow();
if (!flowResult.ok) {
throw new Error("startFlow failed");
}
const { flowState } = flowResult.value;
const jwt = new SignJWT({ nonce: flowState.nonce })
.setProtectedHeader({ alg: "RS256", kid: "test-key" })
.setIssuer(baseUrl)
.setAudience(CLIENT_ID)
.setIssuedAt()
.setExpirationTime("5m");
const idToken = await jwt.sign(privateKey);
tokenHandler = async (_req, res) => {
res.writeHead(200, { "Content-Type": "application/json" });
res.end(
JSON.stringify({
access_token: "mock-access-token",
id_token: idToken,
token_type: "Bearer",
}),
);
};
userinfoHandler = undefined;
const params = new URLSearchParams({ code: "test-code", state: flowState.state });
const result = await svc.handleCallback(params, flowState);
expect(result.ok).toBe(false);
if (result.ok) {
return;
}
expect(result.error.code).toBe("missing_sub");
});
test("invalid_client triggers auth method retry", async () => {
const svc = createOidcService(testConfig({ usePkce: false }));
const flowResult = await svc.startFlow();
if (!flowResult.ok) {
throw new Error("startFlow failed");
}
const { flowState } = flowResult.value;
const idToken = await signIdToken({ sub: "user-123", name: "Test" }, flowState.nonce);
let callCount = 0;
tokenHandler = async (req, res) => {
callCount++;
const body = await readBody(req);
const bodyParams = new URLSearchParams(body);
if (callCount === 1 && bodyParams.has("client_secret")) {
res.writeHead(400, { "Content-Type": "application/json" });
res.end(JSON.stringify({ error: "invalid_client", error_description: "Use basic auth" }));
return;
}
res.writeHead(200, { "Content-Type": "application/json" });
res.end(
JSON.stringify({
access_token: "mock-access-token",
id_token: idToken,
token_type: "Bearer",
}),
);
};
userinfoHandler = undefined;
const params = new URLSearchParams({ code: "test-code", state: flowState.state });
const result = await svc.handleCallback(params, flowState);
expect(result.ok).toBe(true);
expect(callCount).toBe(2);
});
test("token exchange uses client_secret_post by default", async () => {
const svc = createOidcService(testConfig({ usePkce: false }));
const flowResult = await svc.startFlow();
if (!flowResult.ok) {
throw new Error("startFlow failed");
}
const { flowState } = flowResult.value;
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" }));
};
userinfoHandler = undefined;
const params = new URLSearchParams({ code: "test-code", state: flowState.state });
await svc.handleCallback(params, flowState);
expect(receivedAuth).toBeUndefined();
const bodyParams = new URLSearchParams(receivedBody);
expect(bodyParams.get("client_id")).toBe(CLIENT_ID);
expect(bodyParams.get("client_secret")).toBe(CLIENT_SECRET);
});
test("explicit client_secret_basic sends Authorization header", async () => {
const svc = createOidcService(
testConfig({
usePkce: false,
tokenEndpointAuthMethod: "client_secret_basic",
}),
);
const flowResult = await svc.startFlow();
if (!flowResult.ok) {
throw new Error("startFlow failed");
}
const { flowState } = flowResult.value;
const idToken = await signIdToken({ sub: "user-123", name: "Test" }, flowState.nonce);
let receivedAuth: string | undefined;
tokenHandler = async (req, res) => {
receivedAuth = req.headers.authorization;
res.writeHead(200, { "Content-Type": "application/json" });
res.end(JSON.stringify({ access_token: "at", id_token: idToken, token_type: "Bearer" }));
};
userinfoHandler = undefined;
const params = new URLSearchParams({ code: "test-code", state: flowState.state });
await svc.handleCallback(params, flowState);
expect(receivedAuth).toBeDefined();
expect(receivedAuth!.startsWith("Basic ")).toBe(true);
});
});
describe("identity resolution", () => {
async function flowWithClaims(
claims: Record<string, unknown>,
configOverrides?: Partial<OidcConfig>,
) {
const svc = createOidcService(testConfig({ usePkce: false, ...configOverrides }));
const flowResult = await svc.startFlow();
if (!flowResult.ok) {
throw new Error("startFlow failed");
}
const { flowState } = flowResult.value;
const idToken = await signIdToken({ ...claims }, 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 = undefined;
const params = new URLSearchParams({ code: "c", state: flowState.state });
return svc.handleCallback(params, flowState);
}
test("uses name claim directly", async () => {
const result = await flowWithClaims({ sub: "u1", name: "Alice Smith" });
expect(result.ok && result.value.name).toBe("Alice Smith");
});
test("falls back to given_name + family_name", async () => {
const result = await flowWithClaims({
sub: "u1",
given_name: "Alice",
family_name: "Smith",
});
expect(result.ok && result.value.name).toBe("Alice Smith");
});
test("falls back to preferred_username for name", async () => {
const result = await flowWithClaims({ sub: "u1", preferred_username: "asmith" });
expect(result.ok && result.value.name).toBe("asmith");
});
test("falls back to SSO User", async () => {
const result = await flowWithClaims({ sub: "u1" });
expect(result.ok && result.value.name).toBe("SSO User");
});
test("username from preferred_username", async () => {
const result = await flowWithClaims({ sub: "u1", preferred_username: "alice" });
expect(result.ok && result.value.username).toBe("alice");
});
test("username falls back to email local part", async () => {
const result = await flowWithClaims({ sub: "u1", email: "alice@example.com" });
expect(result.ok && result.value.username).toBe("alice");
});
test("username falls back to 'user'", async () => {
const result = await flowWithClaims({ sub: "u1" });
expect(result.ok && result.value.username).toBe("user");
});
test("gravatar picture from email", async () => {
const result = await flowWithClaims(
{ sub: "u1", email: "test@example.com" },
{ profilePictureSource: "gravatar" },
);
expect(result.ok).toBe(true);
if (!result.ok) {
return;
}
expect(result.value.picture).toMatch(/gravatar\.com\/avatar\//);
});
test("oidc picture from claims", async () => {
const result = await flowWithClaims({
sub: "u1",
picture: "https://example.com/photo.jpg",
});
expect(result.ok && result.value.picture).toBe("https://example.com/photo.jpg");
});
});
describe("userinfo enrichment", () => {
test("enriches missing claims from userinfo", async () => {
const svc = createOidcService(testConfig({ usePkce: false }));
const flowResult = await svc.startFlow();
if (!flowResult.ok) {
throw new Error("startFlow failed");
}
const { flowState } = flowResult.value;
const idToken = await signIdToken({ sub: "user-123" }, 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({
sub: "user-123",
name: "From UserInfo",
email: "userinfo@example.com",
}),
);
};
const params = new URLSearchParams({ code: "c", state: flowState.state });
const result = await svc.handleCallback(params, flowState);
expect(result.ok).toBe(true);
if (!result.ok) {
return;
}
expect(result.value.name).toBe("From UserInfo");
expect(result.value.email).toBe("userinfo@example.com");
});
test("skips userinfo when id token has all claims", async () => {
const svc = createOidcService(testConfig({ usePkce: false }));
const flowResult = await svc.startFlow();
if (!flowResult.ok) {
throw new Error("startFlow failed");
}
const { flowState } = flowResult.value;
const idToken = await signIdToken(
{
sub: "user-123",
name: "From Token",
email: "token@example.com",
picture: "https://example.com/pic.jpg",
},
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" }));
};
let userinfoCalledCount = 0;
userinfoHandler = (_req, res) => {
userinfoCalledCount++;
res.writeHead(200, { "Content-Type": "application/json" });
res.end(JSON.stringify({ name: "Should Not Use" }));
};
const params = new URLSearchParams({ code: "c", state: flowState.state });
const result = await svc.handleCallback(params, flowState);
expect(result.ok).toBe(true);
if (!result.ok) {
return;
}
expect(result.value.name).toBe("From Token");
expect(userinfoCalledCount).toBe(0);
});
test("userinfo failure does not block login", async () => {
const svc = createOidcService(testConfig({ usePkce: false }));
const flowResult = await svc.startFlow();
if (!flowResult.ok) {
throw new Error("startFlow failed");
}
const { flowState } = flowResult.value;
const idToken = await signIdToken({ sub: "user-123" }, 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(500);
res.end("Internal Server Error");
};
const params = new URLSearchParams({ code: "c", state: flowState.state });
const result = await svc.handleCallback(params, flowState);
expect(result.ok).toBe(true);
if (!result.ok) {
return;
}
expect(result.value.subject).toBe("user-123");
expect(result.value.name).toBe("SSO User");
});
});
describe("pkce detection", () => {
test("detects pkce error from provider response", async () => {
const svc = createOidcService(testConfig());
const flowResult = await svc.startFlow();
if (!flowResult.ok) {
throw new Error("startFlow failed");
}
const { flowState } = flowResult.value;
tokenHandler = async (_req, res) => {
res.writeHead(400, { "Content-Type": "application/json" });
res.end(
JSON.stringify({
error: "invalid_request",
error_description: "code_verifier is required",
}),
);
};
const params = new URLSearchParams({ code: "c", state: flowState.state });
const result = await svc.handleCallback(params, flowState);
expect(result.ok).toBe(false);
if (result.ok) {
return;
}
expect(result.error.code).toBe("pkce_error");
});
});
describe("path-based issuers", () => {
test("handles issuer with path correctly", async () => {
const pathServer = createServer((_req, res) => {
res.writeHead(200, { "Content-Type": "application/json" });
res.end(
JSON.stringify({
issuer: "http://localhost/realms/test",
authorization_endpoint: "http://localhost/realms/test/auth",
token_endpoint: "http://localhost/realms/test/token",
jwks_uri: "http://localhost/realms/test/jwks",
}),
);
});
await new Promise<void>((resolve) => pathServer.listen(0, "127.0.0.1", resolve));
const addr = pathServer.address();
const port = typeof addr === "object" && addr ? addr.port : 0;
const svc = createOidcService(
testConfig({
issuer: `http://127.0.0.1:${port}/realms/test`,
}),
);
const result = await svc.discover();
expect(result.ok).toBe(true);
if (result.ok) {
expect(result.value.authorizationEndpoint).toBe("http://localhost/realms/test/auth");
}
pathServer.close();
});
});
+8
View File
@@ -44,6 +44,14 @@ export default defineConfig({
testTimeout: 60_000,
},
},
{
extends: true,
test: {
name: "integration:oidc",
include: ["tests/integration/oidc/**/*.test.ts"],
testTimeout: 60_000,
},
},
],
env: {
HEADPLANE_DEBUG_LOG: "true",