feat: rebuild browser ssh from the ground up

This commit is contained in:
Aarnav Tale
2026-04-09 21:56:06 -04:00
parent d255098128
commit 0f19fdf0da
39 changed files with 1292 additions and 1231 deletions
+1 -3
View File
@@ -2,7 +2,6 @@ import { Outlet, redirect, type ShouldRevalidateFunction } from "react-router";
import { ErrorBanner } from "~/components/error-banner";
import StatusBanner from "~/components/status-banner";
import { pruneEphemeralNodes } from "~/server/db/pruner";
import { isDataUnauthorizedError } from "~/server/headscale/api/error-client";
import { usersResource } from "~/server/headscale/live-store";
import { Capabilities } from "~/server/web/roles";
@@ -30,7 +29,7 @@ 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);
@@ -53,7 +52,6 @@ export async function loader({ request, context, ...rest }: Route.LoaderArgs) {
if (isHealthy) {
try {
await api.getApiKeys();
await pruneEphemeralNodes({ context, request, ...rest });
} catch (error) {
if (isDataUnauthorizedError(error)) {
const displayName =
+1 -1
View File
@@ -13,7 +13,7 @@ export default [
route("/logout", "routes/auth/logout.ts"),
route("/oidc/callback", "routes/auth/oidc-callback.ts"),
route("/oidc/start", "routes/auth/oidc-start.ts"),
route("/ssh", "routes/ssh/console.tsx"),
route("/ssh/:id", "routes/ssh/page.tsx"),
// All the main logged-in routes
layout("layout/app.tsx", [
@@ -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(
+2 -2
View File
@@ -107,7 +107,7 @@ export default function MachineMenu({
// in a new WINDOW since href can only
// do a new TAB.
window.open(
`${__PREFIX__}/ssh?hostname=${node.givenName}`,
`${__PREFIX__}/ssh/${node.givenName}`,
"_blank",
"noopener,noreferrer,width=800,height=600",
);
@@ -127,7 +127,7 @@ export default function MachineMenu({
variant="light"
onClick={() => {
window.open(
`${__PREFIX__}/ssh?hostname=${node.givenName}`,
`${__PREFIX__}/ssh/${node.givenName}`,
"_blank",
"noopener,noreferrer,width=800,height=600",
);
-278
View File
@@ -1,278 +0,0 @@
import { faker } from "@faker-js/faker";
import { eq } from "drizzle-orm";
import { Loader2 } from "lucide-react";
import { useEffect, useState } from "react";
import { data, type ShouldRevalidateFunction, useSubmit } from "react-router";
import { ExternalScriptsHandle } from "remix-utils/external-scripts";
import { EphemeralNodeInsert, ephemeralNodes } from "~/server/db/schema";
import { findHeadscaleUserBySubject } from "~/server/web/headscale-identity";
import { useLiveData } from "~/utils/live-data";
import log from "~/utils/log";
import type { Route } from "./+types/console";
import UserPrompt from "./user-prompt";
import XTerm from "./xterm.client";
export const shouldRevalidate: ShouldRevalidateFunction = () => {
return false;
};
export async function loader({ request, context }: Route.LoaderArgs) {
const origin = new URL(request.url).origin;
const assets = [`${__PREFIX__}/wasm_exec.js`, `${__PREFIX__}/hp_ssh.wasm`];
const missing: string[] = [];
for (const file of assets) {
const res = await fetch(`${origin}${file}`, { method: "HEAD" });
if (!res.ok) missing.push(file);
}
if (missing.length > 0) {
throw data("WebSSH is not configured in this build.", 405);
}
if (!context.agents) {
throw data("WebSSH is only available with the Headplane agent integration", 400);
}
const principal = await context.auth.require(request);
if (principal.kind === "api_key") {
throw data("Only OAuth users are allowed to use WebSSH", 403);
}
const apiKey = context.auth.getHeadscaleApiKey(principal);
const api = context.hsApi.getRuntimeClient(apiKey);
const users = await api.getUsers();
// MARK: This assumes that a user has authenticated with Headscale first
// Since the only way to enforce permissions via ACLs is to generate a
// pre-authkey which REQUIRES a user ID, meaning the user has to have
// authenticated with Headscale first.
const lookup = findHeadscaleUserBySubject(users, principal.user.subject, principal.profile.email);
if (!lookup) {
throw data(`User with subject ${principal.user.subject} not found within Headscale`, 404);
}
const preAuthKey = await api.createPreAuthKey(
lookup.id,
true, // ephemeral
false, // reusable
new Date(Date.now() + 60 * 1000), // expiration: 1 minute
null, // aclTags
);
// TODO: Enable config to enforce generate_authkeys capability
// For now, any user is capable of WebSSH connections
// const check = await context.sessions.check(
// request,
// Capabilities.generate_authkeys,
// );
const qp = new URL(request.url).searchParams;
const username = qp.get("username") || undefined;
const hostname = qp.get("hostname") || undefined;
if (!hostname) {
throw data("Missing required parameter: hostname", 400);
}
if (!username) {
return {
ipnDetails: undefined,
sshDetails: {
username,
hostname,
},
};
}
// We're making a request to <url>/key?v=116 to check the CORS headers
const u = context.config.headscale.public_url ?? context.config.headscale.url;
// const res = await fetch(`${u}/key?v=116`, {
// method: 'GET',
// });
// const corsOrigin = res.headers.get('Access-Control-Allow-Origin');
// const corsMethods = res.headers.get('Access-Control-Allow-Methods');
// const corsHeaders = res.headers.get('Access-Control-Allow-Headers');
// console.log(corsOrigin, corsMethods, corsHeaders);
// if (!corsOrigin || !corsMethods || !corsHeaders) {
// throw data(
// 'Headscale server does not have the required CORS headers for WebSSH',
// 500,
// );
// }
const nodes = await api.getNodes();
const lookupNode = nodes.find((n) => n.givenName === hostname);
if (!lookupNode) {
throw data(`Node with hostname ${hostname} not found`, 404);
}
// Last thing is keeping track of the ephemeral node in the database
// because Headscale doesn't automatically delete ephemeral nodes???
const [_ephemeralNode] = await context.db
.insert(ephemeralNodes)
.values({
auth_key: preAuthKey.key,
} satisfies EphemeralNodeInsert)
.returning();
return {
ipnDetails: {
PreAuthKey: preAuthKey.key,
Hostname: generateHostname(username),
ControlURL: u,
},
sshDetails: {
username,
hostname,
},
};
}
function generateHostname(username: string) {
const adjective = faker.word.adjective({
length: {
min: 3,
max: 6,
},
});
const noun = faker.word.noun({
length: {
min: 3,
max: 6,
},
});
return `ssh-${adjective}-${noun}-${username}`;
}
export async function action({ request, context }: Route.ActionArgs) {
await context.auth.require(request);
if (!context.agents) {
throw data("WebSSH is only available with the Headplane agent integration", 400);
}
const form = await request.formData();
const nodeKey = form.get("node_key");
const authKey = form.get("auth_key");
if (nodeKey === null || typeof nodeKey !== "string") {
throw data("Missing node_key", 400);
}
if (authKey === null || typeof authKey !== "string") {
throw data("Missing auth_key", 400);
}
await context.db
.update(ephemeralNodes)
.set({
node_key: nodeKey,
})
.where(eq(ephemeralNodes.auth_key, authKey));
context.agents?.triggerSync().catch((err) => {
log.debug("agent", "Background agent sync failed: %s", err);
});
}
export const links: Route.LinksFunction = () => [
{
rel: "preload",
href: `${__PREFIX__}/hp_ssh.wasm`,
as: "fetch",
type: "application/wasm",
crossOrigin: "anonymous",
},
];
export const handle: ExternalScriptsHandle = {
scripts: [
{
src: `${__PREFIX__}/wasm_exec.js`,
crossOrigin: "anonymous",
preload: true,
},
],
};
export default function Page({ loaderData: { ipnDetails, sshDetails } }: Route.ComponentProps) {
const submit = useSubmit();
const { pause } = useLiveData();
const [ipn, setIpn] = useState<TsWasmNet | null>(null);
const [nodeKey, setNodeKey] = useState<string | null>(null);
useEffect(() => {
if (!ipnDetails) {
return;
}
pause();
const go = new Go(); // Go is defined by wasm_exec.js
WebAssembly.instantiateStreaming(fetch(`${__PREFIX__}/hp_ssh.wasm`), go.importObject).then(
(value) => {
go.run(value.instance);
const handle = TsWasmNet(ipnDetails, {
NotifyState: (state) => {
console.log("State changed:", state);
if (state === "Running") {
setIpn(handle);
}
},
NotifyNetMap: (netmap) => {
// Only set NodeKey if it is not already set and then
// also dispatch that to the backend to track the
// ephemeral node.
//
// We open an SSE connection to the backend
// so that when the connection is closed,
// the backend can delete the ephemeral node.
if (nodeKey === null) {
setNodeKey(netmap.NodeKey);
submit(
{
node_key: netmap.NodeKey,
auth_key: ipnDetails.PreAuthKey,
},
{ method: "POST" },
);
}
},
NotifyBrowseToURL: (url) => {
console.log("Browse to URL:", url);
},
NotifyPanicRecover: (message) => {
console.error("Panic recover:", message);
},
});
handle.Start();
},
);
}, []);
if (!sshDetails.username) {
return <UserPrompt hostname={sshDetails.hostname} />;
}
return (
<div className="h-screen w-screen bg-mist-900">
{ipn === null ? (
<div className="mx-auto flex h-screen items-center justify-center">
<Loader2 className="size-10 animate-spin text-mist-50" />
</div>
) : (
<div className="flex h-screen flex-col">
<XTerm hostname={sshDetails.hostname} ipn={ipn} username={sshDetails.username} />
</div>
)}
</div>
);
}
+77
View File
@@ -0,0 +1,77 @@
import { AlertCircle } from "lucide-react";
import Card from "~/components/card";
import Link from "~/components/link";
export const sshErrors = {
wasm_missing: {
title: "Browser SSH is not available",
message: "This version of Headplane was not built with browser SSH support.",
anchor: "#ssh-not-available",
},
agent_required: {
title: "Browser SSH requires the Headplane agent",
message: "Browser SSH is only available when the Headplane agent integration is enabled.",
anchor: "#agent-required",
},
oidc_required: {
title: "Browser SSH requires OIDC authentication",
message: "Browser SSH is only available when OIDC authentication is enabled.",
anchor: "#oidc-required",
},
node_not_found: (hostname: string) => ({
title: "Node not found",
message: `No node found with hostname ${hostname}.`,
anchor: "#node-not-found",
}),
user_not_linked: {
title: "User account not linked",
message:
"You'll need to link your user account to a Headscale user before you can use Browser SSH.",
anchor: "#user-not-linked",
},
} as const;
interface SSHErrorBoundaryProps {
title: string;
message: string;
anchor: string;
}
export function isSSHError(error: unknown): error is SSHErrorBoundaryProps {
return (
typeof error === "object" &&
error !== null &&
"title" in error &&
"message" in error &&
"anchor" in error &&
typeof error.title === "string" &&
typeof error.message === "string" &&
typeof error.anchor === "string"
);
}
const DOCS_BASE = "https://headplane.net/features/ssh";
export function SSHErrorBoundary({ title, message, anchor }: SSHErrorBoundaryProps) {
return (
<Card className="w-screen" variant="flat">
<div className="flex items-center justify-between gap-4">
<Card.Title>{title}</Card.Title>
<AlertCircle className="mb-2 h-6 w-6 text-red-500" />
</div>
<Card.Text>
{message}
<br />
<br />
<Link to={`${DOCS_BASE}${anchor}`} external styled>
Headplane SSH Documentation
</Link>{" "}
</Card.Text>
</Card>
);
}
+137
View File
@@ -0,0 +1,137 @@
import { useEffect, useRef } from "react";
import { Restty } from "restty";
import type { GhosttyTheme } from "restty";
import type { PtyTransport } from "restty/internal";
import type { HeadplaneSSH, TunnelSession } from "./wasm.client";
const FONT_BASE = `${__PREFIX__}/fonts`;
// Ghostty's default canvas background is rgb(20,23,26) — a dark gray, not black.
// Override it so the terminal matches the page and pane container backgrounds.
const HEADPLANE_THEME: GhosttyTheme = {
colors: {
background: { r: 0, g: 0, b: 0 },
foreground: { r: 235, g: 237, b: 242 },
palette: [],
},
raw: {},
};
function createSSHTransport(ssh: HeadplaneSSH, ipAddress: string, username: string): PtyTransport {
let session: TunnelSession | null = null;
return {
connect(options) {
session = ssh.openTunnel({
ipAddress,
username,
onData: (data) => options.callbacks.onData?.(data),
onConnect: () => options.callbacks.onConnect?.(),
onDisconnect: () => {
options.callbacks.onDisconnect?.();
session = null;
},
});
if (options.cols && options.rows) {
session.resize(options.cols, options.rows);
}
},
disconnect() {
session?.close();
session = null;
},
sendInput(data) {
session?.writeInput(data);
return session != null;
},
resize(cols, rows) {
session?.resize(cols, rows);
return session != null;
},
isConnected() {
return session != null;
},
destroy() {
session?.close();
session = null;
},
};
}
interface GhosttyProps {
ssh: HeadplaneSSH;
ipAddress: string;
username: string;
onConnected: () => void;
}
export default function Ghostty({ ssh, ipAddress, username, onConnected }: GhosttyProps) {
const divRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!divRef.current) return;
const transport = createSSHTransport(ssh, ipAddress, username);
const restty = new Restty({
root: divRef.current,
createInitialPane: true,
defaultContextMenu: false,
shortcuts: false,
searchUi: false,
paneStyles: {
inactivePaneOpacity: 1,
activePaneOpacity: 1,
},
appOptions: {
fontSize: 20,
ligatures: true,
fontPreset: "none",
fontSources: [
{
type: "url",
url: `${FONT_BASE}/JetBrainsMonoNLNerdFontMono-Regular.ttf`,
label: "JetBrains Mono Nerd Font",
},
{
type: "url",
url: `${FONT_BASE}/JetBrainsMonoNLNerdFontMono-Bold.ttf`,
label: "JetBrains Mono Nerd Font Bold",
},
{
type: "url",
url: `${FONT_BASE}/JetBrainsMonoNLNerdFontMono-Italic.ttf`,
label: "JetBrains Mono Nerd Font Italic",
},
{
type: "url",
url: `${FONT_BASE}/JetBrainsMonoNLNerdFontMono-BoldItalic.ttf`,
label: "JetBrains Mono Nerd Font Bold Italic",
},
{
type: "url",
url: `${FONT_BASE}/SymbolsNerdFontMono-Regular.ttf`,
label: "Symbols Nerd Font",
},
],
ptyTransport: transport,
callbacks: {
onPtyStatus: (status) => {
if (status === "connected") onConnected();
},
},
},
});
restty.applyTheme(HEADPLANE_THEME);
restty.updateSize(true);
restty.connectPty();
return () => {
restty.destroy();
};
}, [ssh, ipAddress, username]);
return <div className="min-h-0 min-w-0 flex-1 overflow-hidden bg-black" ref={divRef} />;
}
-57
View File
@@ -1,57 +0,0 @@
declare function TsWasmNet(
options: TsWasmNetOptions,
callbacks: TsWasmNetCallbacks,
): TsWasmNet;
interface TsWasmNetOptions {
ControlURL: string;
PreAuthKey: string;
Hostname: string;
}
interface TsWasmNetCallbacks {
NotifyState: (state: IPNState) => void;
NotifyNetMap: (netmap: TsWasmNetMap) => void;
NotifyBrowseToURL: (url: string) => void;
NotifyPanicRecover: (err: string) => void;
}
interface TsWasmNetMap {
NodeKey: string;
}
interface TsWasmNet {
Start: () => void;
OpenSSH: (
hostname: string,
username: string,
options: XtermConfig,
) => SSHSession;
}
type IPNState =
| 'NoState'
| 'InUseOtherUser'
| 'NeedsLogin'
| 'NeedsMachineAuth'
| 'Stopped'
| 'Starting'
| 'Running';
interface XtermConfig {
rows: number;
cols: number;
timeout?: number;
onStdout: (data: Uint8Array) => void;
onStderr: (data: Uint8Array) => void;
onStdin: (func: (input: Uint8Array) => void) => void;
onConnect: () => void;
onDisconnect: () => void;
}
interface SSHSession {
Close(): boolean;
Resize(rows: number, cols: number): boolean;
}
+242
View File
@@ -0,0 +1,242 @@
import { faker } from "@faker-js/faker";
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 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 const links: Route.LinksFunction = () => [
{
rel: "preload",
href: WASM_MODULE_URL,
as: "fetch",
type: "application/wasm",
crossOrigin: "anonymous",
},
];
export const handle: ExternalScriptsHandle = {
scripts: [
{
src: WASM_HELPER_URL,
crossOrigin: "anonymous",
preload: true,
},
],
};
export default function Page({ loaderData }: Route.ComponentProps) {
const { hostname, username, offline, node } = loaderData;
if (offline) {
return (
<div className="flex h-screen w-screen items-center justify-center bg-black">
<Card className="w-screen" variant="flat">
<div className="flex items-center justify-between gap-4">
<Card.Title>Node Offline</Card.Title>
<WifiOff className="mb-2 h-6 w-6 text-red-500" />
</div>
<Card.Text>
<Code>{hostname}</Code> is not currently connected to the Tailnet.
</Card.Text>
<Button className="mt-8 w-full" onClick={() => window.location.reload()}>
Retry Connection
</Button>
</Card>
</div>
);
}
if (!username || !node) {
return <UserPrompt hostname={hostname} />;
}
return <SSHConsole hostname={hostname} username={username} node={node} />;
}
function SSHConsole({
hostname,
username,
node,
}: {
hostname: string;
username: string;
node: { ipAddress: string; controlURL: string; preAuthKey: string; ephemeralHostname: string };
}) {
const [ssh, setSsh] = useState<HeadplaneSSH | null>(null);
const [connected, setConnected] = useState(false);
const [status, setStatus] = useState("Starting tunnel…");
useEffect(() => {
let cancelled = false;
console.log("[ssh] Loading WASM factory");
loadHeadplaneWASM().then((create) => {
console.log("[ssh] Factory loaded, creating IPN", create);
if (cancelled) {
return;
}
setStatus("Joining Tailnet…");
const instance = create({
controlURL: node.controlURL,
preAuthKey: node.preAuthKey,
hostname: node.ephemeralHostname,
onReady: () => {
console.log("[ssh] IPN ready (Running)");
if (!cancelled) {
setStatus(`Connecting to ${hostname}`);
setSsh(instance);
}
},
onError: (msg) => console.error("[ssh] IPN error:", msg),
});
console.log("[ssh] IPN instance created", instance);
});
return () => {
cancelled = true;
};
}, [node]);
return (
<div className="fixed inset-0 flex flex-col bg-black">
{!connected && (
<div className="absolute inset-0 z-50 flex items-center justify-center">
<div className="flex flex-col items-center gap-3">
<Loader2 className="size-8 animate-spin text-mist-200" />
<p className="text-sm text-mist-400">{status}</p>
</div>
</div>
)}
{ssh && (
<Ghostty
ssh={ssh}
username={username}
ipAddress={node.ipAddress}
onConnected={() => setConnected(true)}
/>
)}
</div>
);
}
export function ErrorBoundary({ error }: Route.ErrorBoundaryProps) {
const routeError = isRouteErrorResponse(error) ? error.data : null;
if (routeError == null || !isSSHError(routeError)) {
// Pass through further down the tree to the global error boundary
throw error;
}
return (
<div className="flex h-screen w-screen items-center justify-center">
<SSHErrorBoundary
title={routeError.title}
message={routeError.message}
anchor={routeError.anchor}
/>
</div>
);
}
+40 -21
View File
@@ -1,17 +1,16 @@
import { useState } from "react";
import { Form } from "react-router";
import Button from "~/components/button";
import Card from "~/components/card";
import Code from "~/components/code";
import Input from "~/components/input";
import Link from "~/components/link";
interface UserPromptProps {
hostname: string;
}
export default function UserPrompt({ hostname }: UserPromptProps) {
const [username, setUsername] = useState("");
return (
<div className="flex h-screen items-center justify-center">
<Card>
@@ -19,27 +18,47 @@ export default function UserPrompt({ hostname }: UserPromptProps) {
<Card.Text className="mb-4">
Enter the username you want to use to connect to <Code>{hostname}</Code>
{". "}
WebSSH follows the Headscale ACLs, so only permitted usernames will be able to connect.
SSH via the web follows the same ACL rules as regular SSH access in Headscale, so only
permitted usernames will work.
<br />
<br />
See the{" "}
<Link external styled to="https://headplane.net/features/ssh#troubleshooting">
troubleshooting guide
</Link>{" "}
for common errors.
</Card.Text>
<Input
labelHidden
type="text"
label="Username"
placeholder="Username"
className="mb-2"
onChange={setUsername}
/>
<Button
variant="heavy"
className="w-full"
onClick={() => {
// We can't use the navigate hook here as we need to do a
// full page reload to ensure the SSH connection is established
window.location.href = `${__PREFIX__}/ssh?hostname=${hostname}&username=${username}`;
<Form
method="GET"
onSubmit={(e) => {
const formData = new FormData(e.currentTarget);
const username = formData.get("user");
if (!username) {
e.preventDefault();
return;
}
// We have to do a full navigation, since the page needs a full
// reload to initialize the SSH connection due to us disabling the
// revalidator.
const url = new URL(window.location.href);
url.searchParams.set("user", username.toString());
window.location.assign(url.toString());
}}
>
Connect
</Button>
<Input
labelHidden
type="text"
label="Username"
name="user"
placeholder="Username"
className="mb-2"
required
/>
<Button type="submit" variant="heavy" className="w-full">
Connect
</Button>
</Form>
</Card>
</div>
);
+64
View File
@@ -0,0 +1,64 @@
const WASM_MODULE_URL = `${__PREFIX__}/hp_ssh.wasm`;
declare global {
type HeadplaneSSHFactory = (config: HeadplaneSSHConfig) => HeadplaneSSH;
var __hp_ssh_resolve: ((factory: HeadplaneSSHFactory) => void) | undefined;
var Go: {
new (): {
importObject: WebAssembly.Imports;
run(instance: WebAssembly.Instance): Promise<void>;
argv?: string[];
env?: Record<string, string>;
exit?: (code: number) => void;
};
};
}
interface HeadplaneSSHConfig {
controlURL: string;
preAuthKey: string;
hostname: string;
onReady: () => void;
onError?: (message: string) => void;
}
export interface HeadplaneSSH {
openTunnel(config: TunnelConfig): TunnelSession;
}
interface TunnelConfig {
ipAddress: string;
username: string;
timeout?: number;
onData: (data: string) => void;
onConnect: () => void;
onDisconnect: () => void;
}
export interface TunnelSession {
writeInput(data: string): void;
resize(cols: number, rows: number): void;
close(): void;
}
let resolvedFactory: Promise<HeadplaneSSHFactory> | null = null;
/**
* One-shot function that loads the Go WASM binary and returns the SSH factory.
* It expects the Go WASM helper to be loaded, and will error if called before.
*/
export async function loadHeadplaneWASM(): Promise<HeadplaneSSHFactory> {
if (!resolvedFactory) {
const go = new Go();
const result = await WebAssembly.instantiateStreaming(fetch(WASM_MODULE_URL), go.importObject);
resolvedFactory = new Promise<HeadplaneSSHFactory>((resolve) => {
globalThis.__hp_ssh_resolve = resolve;
});
go.run(result.instance);
}
return resolvedFactory;
}
-7
View File
@@ -1,7 +0,0 @@
declare class Go {
importObject: WebAssembly.Imports;
run(instance: WebAssembly.Instance): Promise<void>;
argv?: string[];
env?: Record<string, string>;
exit?: (code: number) => void;
}
-218
View File
@@ -1,218 +0,0 @@
import { ClipboardAddon } from "@xterm/addon-clipboard";
import { FitAddon } from "@xterm/addon-fit";
import { Unicode11Addon } from "@xterm/addon-unicode11";
import { WebLinksAddon } from "@xterm/addon-web-links";
import * as xterm from "@xterm/xterm";
import { Loader2 } from "lucide-react";
import { useEffect, useRef, useState } from "react";
import cn from "~/utils/cn";
import { useLiveData } from "~/utils/live-data";
import toast from "~/utils/toast";
import "@xterm/xterm/css/xterm.css";
interface XTermProps {
ipn: TsWasmNet;
username: string;
hostname: string;
}
// Go's WASM -> JS crosses realms so we might have to normalize the data under
// certain conditions. This also enforces bytes instead of strings being sent.
function normU8(data: unknown) {
if (data instanceof Uint8Array) {
return data;
}
if (data && typeof data === "object") {
const any = data as {
buffer?: ArrayBufferLike;
byteOffset?: number;
byteLength?: number;
};
if (any.buffer instanceof ArrayBuffer && typeof any.byteLength === "number") {
return new Uint8Array(
any.buffer.slice(any.byteOffset ?? 0, (any.byteOffset ?? 0) + any.byteLength),
);
}
}
throw new Error("Data is not a Uint8Array or ArrayBuffer-like object");
}
export default function XTerm({ ipn, username, hostname }: XTermProps) {
const { pause } = useLiveData();
const genRef = useRef(0);
const termRef = useRef<xterm.Terminal>(null);
const roRef = useRef<ResizeObserver>(null);
const inputRef = useRef<(input: Uint8Array) => void>(null);
const sshRef = useRef<SSHSession>(null);
const divRef = useRef<HTMLDivElement>(null);
const [isResizing, setIsResizing] = useState(false);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
pause();
});
useEffect(() => {
if (!divRef.current) {
return;
}
const currentGen = ++genRef.current;
const term = new xterm.Terminal({
allowProposedApi: true,
cursorBlink: true,
convertEol: true,
fontSize: 14,
});
const fit = new FitAddon();
term.loadAddon(fit);
term.loadAddon(new Unicode11Addon());
term.loadAddon(new ClipboardAddon());
term.loadAddon(
new WebLinksAddon((event, uri) => {
event.view?.open(uri, "_blank", "noopener noreferrer");
}),
);
term.unicode.activeVersion = "11";
termRef.current = term;
term.open(divRef.current!);
fit.fit();
term.focus();
const session = ipn.OpenSSH(hostname, username, {
rows: term.rows,
cols: term.cols,
onStdout: (data) => {
if (currentGen !== genRef.current || term !== termRef.current) {
console.warn("Stale terminal instance, ignoring stdout");
return;
}
const text = normU8(data);
term.write(text);
},
onStderr: (data) => {
if (currentGen !== genRef.current || term !== termRef.current) {
console.warn("Stale terminal instance, ignoring stderr");
return;
}
const text = normU8(data);
term.write(text);
const str = new TextDecoder().decode(text);
setError(str);
},
onStdin: (func) => {
inputRef.current = func;
},
onConnect: () => {
if (currentGen !== genRef.current) {
console.warn("Stale terminal instance, ignoring onConnect");
return;
}
setIsLoading(false);
},
onDisconnect: () => {
if (currentGen !== genRef.current) {
console.warn("Stale terminal instance, ignoring onDisconnect");
return;
}
roRef.current?.disconnect();
term.dispose();
termRef.current = null;
inputRef.current = null;
sshRef.current = null;
setIsLoading(false);
},
});
sshRef.current = session;
const enc = new TextEncoder();
term.onData((data) => {
if (currentGen !== genRef.current) {
console.warn("Stale terminal instance, ignoring onData");
return;
}
const bytes = enc.encode(data);
inputRef.current?.(bytes);
});
const ro = new ResizeObserver(() => {
if (currentGen !== genRef.current || term !== termRef.current) {
console.warn("Stale terminal instance, ignoring resize");
return;
}
setIsResizing(true);
fit.fit();
sshRef.current?.Resize(term.cols, term.rows);
setTimeout(() => setIsResizing(false), 100);
});
roRef.current = ro;
ro.observe(divRef.current!);
return () => {
++genRef.current;
roRef.current?.disconnect();
roRef.current = null;
sshRef.current?.Close();
sshRef.current = null;
term.dispose();
if (termRef.current === term) {
termRef.current = null;
}
inputRef.current = null;
};
}, [ipn, username, hostname]);
return (
<>
{isLoading ? (
<div className="absolute z-50 mx-auto flex h-screen w-screen items-center justify-center">
<Loader2 className="size-10 animate-spin text-mist-50" />
</div>
) : undefined}
<div className={cn("w-full h-full", isLoading ? "opacity-0" : "opacity-100")} ref={divRef} />
{termRef.current && isResizing ? (
<div
className={cn(
"absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2",
"px-4 py-2 bg-mist-800 text-white rounded-full shadow z-50",
)}
>
{termRef.current.cols}x{termRef.current.rows}
</div>
) : undefined}
{error !== null ? (
<div
className={cn(
"absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 text-center",
"px-4 py-2 bg-mist-800 text-white rounded-full shadow z-50",
)}
>
Failed to connect to SSH session
{error}
</div>
) : undefined}
</>
);
}
@@ -30,8 +30,8 @@ export default function HeadplaneUserRow({
const displayEmail = user.linkedHeadscaleUser?.email ?? user.email;
return (
<tr className="group hover:bg-mist-50 dark:hover:bg-mist-950" key={user.id}>
<td className="py-2 pl-0.5">
<tr className="group hover:bg-mist-100 dark:hover:bg-mist-800" key={user.id}>
<td className="py-2 pl-2">
<div className="flex items-center">
{user.profilePicUrl ? (
<img alt={displayName} className="h-10 w-10 rounded-full" src={user.profilePicUrl} />
-53
View File
@@ -1,53 +0,0 @@
import { eq, isNotNull } from "drizzle-orm";
import { nodesResource } from "~/server/headscale/live-store";
import log from "~/utils/log";
import type { Route } from "../../layout/+types/app";
import { ephemeralNodes } from "./schema";
export async function pruneEphemeralNodes({ context, request }: Route.LoaderArgs) {
const principal = await context.auth.require(request);
const ephemerals = await context.db
.select()
.from(ephemeralNodes)
.where(isNotNull(ephemeralNodes.node_key));
if (ephemerals.length === 0) {
log.debug("api", "No ephemeral nodes to prune");
return;
}
const apiKey = context.auth.getHeadscaleApiKey(principal);
const api = context.hsApi.getRuntimeClient(apiKey);
const nodes = await api.getNodes();
const toPrune = nodes.filter((node) => {
if (node.online) {
return false;
}
return ephemerals.some((ephemeral) => node.nodeKey === ephemeral.node_key);
});
if (toPrune.length === 0) {
log.debug("api", "No SSH nodes to prune");
return;
}
// Delete from the Headscale nodes list and then from the database
const promises = toPrune.map((node) => {
return async () => {
log.debug("api", `Pruning node ${node.name}`);
await api.deleteNode(node.id);
await context.db.delete(ephemeralNodes).where(eq(ephemeralNodes.node_key, node.nodeKey));
log.debug("api", `Node ${node.name} pruned successfully`);
};
});
await Promise.all(promises.map((p) => p()));
if (toPrune.length > 0) {
await context.hsLive.refresh(nodesResource, api);
}
}
-8
View File
@@ -2,14 +2,6 @@ import { integer, sqliteTable, text } from "drizzle-orm/sqlite-core";
import { HostInfo } from "~/types";
export const ephemeralNodes = sqliteTable("ephemeral_nodes", {
auth_key: text("auth_key").primaryKey(),
node_key: text("node_key"),
});
export type EphemeralNode = typeof ephemeralNodes.$inferSelect;
export type EphemeralNodeInsert = typeof ephemeralNodes.$inferInsert;
export const hostInfo = sqliteTable("host_info", {
host_id: text("host_id").primaryKey(),
payload: text("payload", { mode: "json" }).$type<HostInfo>(),
+9 -16
View File
@@ -10,7 +10,7 @@ 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";
export interface AgentManager {
@@ -266,6 +266,10 @@ export async function createAgentManager(
}
}
/**
* 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();
@@ -294,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(