mirror of
https://github.com/tale/headplane.git
synced 2026-07-26 15:58:14 +00:00
Compare commits
24 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 272233ae62 | |||
| 974c3b0e48 | |||
| acd3ff3403 | |||
| 1961608e12 | |||
| c4ac28f90b | |||
| a0b2077c7b | |||
| 0f19fdf0da | |||
| d255098128 | |||
| 98e0806e5a | |||
| 44dffeaff0 | |||
| 33f7bbb0cf | |||
| 10278d0cc9 | |||
| bdcd4c5bad | |||
| 43cff2f4b7 | |||
| 61e7303363 | |||
| dc44cc4155 | |||
| 30ce5e2727 | |||
| 55a09476ab | |||
| ac90b4e8bb | |||
| 4b47b1bbed | |||
| 003985d192 | |||
| 1259642f8a | |||
| 4cd0c1e206 | |||
| b2dfa773b0 |
@@ -13,3 +13,4 @@ node_modules
|
||||
/docs/.vitepress/dist/
|
||||
/docs/.vitepress/cache/
|
||||
/.direnv
|
||||
/vendor
|
||||
|
||||
+31
-4
@@ -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`.
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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", [
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
),
|
||||
});
|
||||
|
||||
@@ -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} />
|
||||
|
||||
@@ -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,
|
||||
}),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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
@@ -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(
|
||||
|
||||
@@ -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",
|
||||
);
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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",
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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} />;
|
||||
}
|
||||
Vendored
-57
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
Vendored
-7
@@ -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;
|
||||
}
|
||||
@@ -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} />
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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...");
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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 |
@@ -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,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
|
||||
|
||||
@@ -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
@@ -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": {
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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",
|
||||
|
||||
@@ -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
|
||||
Generated
+24
-55
@@ -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.
Binary file not shown.
@@ -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);
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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: {
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user