diff --git a/CHANGELOG.md b/CHANGELOG.md
index 016a15d..edeb114 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,7 +1,11 @@
-# 0.7.0-beta.2 (April 7, 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.
- **Replaced `openid-client` with a clean-room OIDC implementation.**
@@ -30,8 +34,19 @@
- 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)).
diff --git a/app/layout/app.tsx b/app/layout/app.tsx
index 07463a2..0c764ef 100644
--- a/app/layout/app.tsx
+++ b/app/layout/app.tsx
@@ -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 =
diff --git a/app/routes.ts b/app/routes.ts
index 382cea0..7490570 100644
--- a/app/routes.ts
+++ b/app/routes.ts
@@ -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", [
diff --git a/app/routes/machines/components/machine-row.tsx b/app/routes/machines/components/machine-row.tsx
index d5812b2..489deac 100644
--- a/app/routes/machines/components/machine-row.tsx
+++ b/app/routes/machines/components/machine-row.tsx
@@ -50,8 +50,8 @@ export default function MachineRow({
}, [magic, node.ipAddresses]);
return (
-
+
{
window.open(
- `${__PREFIX__}/ssh?hostname=${node.givenName}`,
+ `${__PREFIX__}/ssh/${node.givenName}`,
"_blank",
"noopener,noreferrer,width=800,height=600",
);
diff --git a/app/routes/ssh/console.tsx b/app/routes/ssh/console.tsx
deleted file mode 100644
index 8198f40..0000000
--- a/app/routes/ssh/console.tsx
+++ /dev/null
@@ -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 /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(null);
- const [nodeKey, setNodeKey] = useState(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 ;
- }
-
- return (
-
- {ipn === null ? (
-
-
-
- ) : (
-
-
-
- )}
-
- );
-}
diff --git a/app/routes/ssh/errors.tsx b/app/routes/ssh/errors.tsx
new file mode 100644
index 0000000..53089c5
--- /dev/null
+++ b/app/routes/ssh/errors.tsx
@@ -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 (
+
+
+
+ {message}
+
+
+
+ Headplane SSH Documentation
+ {" "}
+
+
+ );
+}
diff --git a/app/routes/ssh/ghostty.client.tsx b/app/routes/ssh/ghostty.client.tsx
new file mode 100644
index 0000000..661a944
--- /dev/null
+++ b/app/routes/ssh/ghostty.client.tsx
@@ -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(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
;
+}
diff --git a/app/routes/ssh/hp_ssh.d.ts b/app/routes/ssh/hp_ssh.d.ts
deleted file mode 100644
index 2bb73e8..0000000
--- a/app/routes/ssh/hp_ssh.d.ts
+++ /dev/null
@@ -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;
-}
diff --git a/app/routes/ssh/page.tsx b/app/routes/ssh/page.tsx
new file mode 100644
index 0000000..23686c6
--- /dev/null
+++ b/app/routes/ssh/page.tsx
@@ -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 (
+
+
+
+ Node Offline
+
+
+
+ {hostname} is not currently connected to the Tailnet.
+
+ window.location.reload()}>
+ Retry Connection
+
+
+
+ );
+ }
+
+ if (!username || !node) {
+ return ;
+ }
+
+ return ;
+}
+
+function SSHConsole({
+ hostname,
+ username,
+ node,
+}: {
+ hostname: string;
+ username: string;
+ node: { ipAddress: string; controlURL: string; preAuthKey: string; ephemeralHostname: string };
+}) {
+ const [ssh, setSsh] = useState(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 (
+
+ {!connected && (
+
+ )}
+
+ {ssh && (
+
setConnected(true)}
+ />
+ )}
+
+ );
+}
+
+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 (
+
+
+
+ );
+}
diff --git a/app/routes/ssh/user-prompt.tsx b/app/routes/ssh/user-prompt.tsx
index eef06a9..7d1df8d 100644
--- a/app/routes/ssh/user-prompt.tsx
+++ b/app/routes/ssh/user-prompt.tsx
@@ -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 (
@@ -19,27 +18,47 @@ export default function UserPrompt({ hostname }: UserPromptProps) {
Enter the username you want to use to connect to {hostname}
{". "}
- 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.
+
+
+ See the{" "}
+
+ troubleshooting guide
+ {" "}
+ for common errors.
-
- {
- // 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}`;
+
+
+
+ Connect
+
+
);
diff --git a/app/routes/ssh/wasm.client.ts b/app/routes/ssh/wasm.client.ts
new file mode 100644
index 0000000..883c145
--- /dev/null
+++ b/app/routes/ssh/wasm.client.ts
@@ -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;
+ argv?: string[];
+ env?: Record;
+ 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 | 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 {
+ if (!resolvedFactory) {
+ const go = new Go();
+ const result = await WebAssembly.instantiateStreaming(fetch(WASM_MODULE_URL), go.importObject);
+
+ resolvedFactory = new Promise((resolve) => {
+ globalThis.__hp_ssh_resolve = resolve;
+ });
+
+ go.run(result.instance);
+ }
+
+ return resolvedFactory;
+}
diff --git a/app/routes/ssh/wasm_exec.d.ts b/app/routes/ssh/wasm_exec.d.ts
deleted file mode 100644
index 25b56fd..0000000
--- a/app/routes/ssh/wasm_exec.d.ts
+++ /dev/null
@@ -1,7 +0,0 @@
-declare class Go {
- importObject: WebAssembly.Imports;
- run(instance: WebAssembly.Instance): Promise;
- argv?: string[];
- env?: Record;
- exit?: (code: number) => void;
-}
diff --git a/app/routes/ssh/xterm.client.tsx b/app/routes/ssh/xterm.client.tsx
deleted file mode 100644
index e2b79b3..0000000
--- a/app/routes/ssh/xterm.client.tsx
+++ /dev/null
@@ -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(null);
- const roRef = useRef(null);
- const inputRef = useRef<(input: Uint8Array) => void>(null);
- const sshRef = useRef(null);
- const divRef = useRef(null);
-
- const [isResizing, setIsResizing] = useState(false);
- const [isLoading, setIsLoading] = useState(true);
- const [error, setError] = useState(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 ? (
-
-
-
- ) : undefined}
-
- {termRef.current && isResizing ? (
-
- {termRef.current.cols}x{termRef.current.rows}
-
- ) : undefined}
- {error !== null ? (
-
- Failed to connect to SSH session
- {error}
-
- ) : undefined}
- >
- );
-}
diff --git a/app/routes/users/components/headplane-user-row.tsx b/app/routes/users/components/headplane-user-row.tsx
index c75ae5c..b3e686b 100644
--- a/app/routes/users/components/headplane-user-row.tsx
+++ b/app/routes/users/components/headplane-user-row.tsx
@@ -30,8 +30,8 @@ export default function HeadplaneUserRow({
const displayEmail = user.linkedHeadscaleUser?.email ?? user.email;
return (
-
-
+
+
{user.profilePicUrl ? (
diff --git a/app/server/db/pruner.ts b/app/server/db/pruner.ts
deleted file mode 100644
index 3d44c6a..0000000
--- a/app/server/db/pruner.ts
+++ /dev/null
@@ -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);
- }
-}
diff --git a/app/server/db/schema.ts b/app/server/db/schema.ts
index ce8d5fc..452baaa 100644
--- a/app/server/db/schema.ts
+++ b/app/server/db/schema.ts
@@ -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
(),
diff --git a/app/server/hp-agent.ts b/app/server/hp-agent.ts
index 2099510..fdeedc7 100644
--- a/app/server/hp-agent.ts
+++ b/app/server/hp-agent.ts
@@ -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(
diff --git a/cmd/hp_ssh/hp_ssh.go b/cmd/hp_ssh/hp_ssh.go
index bee7ce1..3fd3ce9 100644
--- a/cmd/hp_ssh/hp_ssh.go
+++ b/cmd/hp_ssh/hp_ssh.go
@@ -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)
diff --git a/docs/.vitepress/config.ts b/docs/.vitepress/config.ts
index c00456a..90a5a77 100644
--- a/docs/.vitepress/config.ts
+++ b/docs/.vitepress/config.ts
@@ -46,7 +46,7 @@ export default defineConfig({
items: [
{ text: "Single Sign-On (SSO)", link: "/features/sso" },
{ text: "Headplane Agent", link: "/features/agent" },
- { text: "WebSSH", link: "/features/ssh" },
+ { text: "Browser SSH", link: "/features/ssh" },
],
},
{
diff --git a/docs/assets/ssh-btop.png b/docs/assets/ssh-btop.png
new file mode 100644
index 0000000..fa98367
Binary files /dev/null and b/docs/assets/ssh-btop.png differ
diff --git a/docs/assets/ssh-fastfetch.png b/docs/assets/ssh-fastfetch.png
new file mode 100644
index 0000000..268a511
Binary files /dev/null and b/docs/assets/ssh-fastfetch.png differ
diff --git a/docs/assets/ssh.png b/docs/assets/ssh.png
deleted file mode 100644
index 3ca28f7..0000000
Binary files a/docs/assets/ssh.png and /dev/null differ
diff --git a/docs/features/ssh.md b/docs/features/ssh.md
index a3ff5bf..1146896 100644
--- a/docs/features/ssh.md
+++ b/docs/features/ssh.md
@@ -1,73 +1,227 @@
---
-title: WebSSH
+title: Browser SSH
description: Open SSH sessions to your Tailnet nodes directly from the browser.
---
-# WebSSH
+# Browser SSH
-
- SSH access via the browser
+
+ btop running over browser SSH
-WebSSH lets you open an SSH session to any node directly from the browser. It
-uses a Go-based WASM shim that creates an ephemeral Tailscale node in the
-browser and connects to the target node over the Tailnet.
+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.
+
+
+
+ fastfetch with Nerd Font icons
+
## Prerequisites
- **Headscale 0.28 or newer** is required.
-- Target nodes must have **Tailscale SSH** enabled.
-- Users must be authenticated via **OIDC** (API key logins cannot use WebSSH).
-- The **Headplane Agent** must be
- [enabled and configured](/features/agent) so that ephemeral node cleanup
- works correctly.
-- The WASM assets (`hp_ssh.wasm` and `wasm_exec.js`) must be present in the
- build. These are built automatically by `./build.sh --wasm`.
+- 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 Go WASM binary that implements a minimal Tailscale node.
-2. Authenticates to the Tailnet using an ephemeral pre-auth key generated
- server-side.
-3. Connects to the target node over the Tailnet using DERP relay servers.
-4. Opens an SSH session and renders it in an [xterm.js](https://xtermjs.org)
- terminal.
+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.
-The ephemeral node is automatically cleaned up after the session ends.
+## Reverse Proxy Configuration
-## DERP Servers on Non-Standard Ports
+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.
-In the browser, Tailscale connects to DERP relay servers via WebSockets. If
-your DERP servers run on a non-standard port (e.g. `:8443` instead of `:443`),
-Headplane includes a patch to Tailscale's DERP client that preserves the port
-in WebSocket URLs. This patch is applied automatically during the WASM build.
+### Required Headers
-::: warning
-If you are building the WASM module manually (outside of `build.sh`), make sure
-to apply `patches/tailscale-derp-port.patch` to the vendored Tailscale source
-before compiling. See the `build_wasm()` function in `build.sh` for reference.
-:::
+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
-### "WebSSH is not configured in this build"
+### SSH Not Available
-The WASM assets are missing. Rebuild with `./build.sh --wasm` or ensure your
-Docker image was built with the `--wasm` flag.
+**Error:** "This version of Headplane was not built with browser SSH support."
-### "Only OAuth users are allowed to use WebSSH"
+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.
-WebSSH requires OIDC authentication to generate pre-auth keys tied to a
+### 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.
+identity. Log in via your configured OIDC provider instead.
-### Connection hangs or fails to reach the node
+### User Not Linked
-- Verify that the target node has Tailscale SSH enabled.
-- If using custom DERP servers on non-standard ports, ensure you are running
- a build that includes the DERP port patch (any build from `build.sh` or
- Docker includes it automatically).
+**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.
diff --git a/drizzle/20260408181638_parallel_synch/migration.sql b/drizzle/20260408181638_parallel_synch/migration.sql
new file mode 100644
index 0000000..c1698c0
--- /dev/null
+++ b/drizzle/20260408181638_parallel_synch/migration.sql
@@ -0,0 +1 @@
+DROP TABLE `ephemeral_nodes`;
\ No newline at end of file
diff --git a/drizzle/20260408181638_parallel_synch/snapshot.json b/drizzle/20260408181638_parallel_synch/snapshot.json
new file mode 100644
index 0000000..842c0f5
--- /dev/null
+++ b/drizzle/20260408181638_parallel_synch/snapshot.json
@@ -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": []
+}
diff --git a/internal/hp_ipn/ipnserver.go b/internal/hp_ipn/ipnserver.go
index d299b70..7c378d8 100644
--- a/internal/hp_ipn/ipnserver.go
+++ b/internal/hp_ipn/ipnserver.go
@@ -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
}
diff --git a/internal/hp_ipn/jsfunc.go b/internal/hp_ipn/jsfunc.go
index 0b63185..691b903 100644
--- a/internal/hp_ipn/jsfunc.go
+++ b/internal/hp_ipn/jsfunc.go
@@ -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
-}
diff --git a/internal/hp_ipn/jsmap.go b/internal/hp_ipn/jsmap.go
index 3800873..9914332 100644
--- a/internal/hp_ipn/jsmap.go
+++ b/internal/hp_ipn/jsmap.go
@@ -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
diff --git a/internal/hp_ipn/notify.go b/internal/hp_ipn/notify.go
index c236654..7121d9f 100644
--- a/internal/hp_ipn/notify.go
+++ b/internal/hp_ipn/notify.go
@@ -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)
}
}
diff --git a/internal/hp_ipn/ssh.go b/internal/hp_ipn/ssh.go
index 7fc5e74..e1caf2f 100644
--- a/internal/hp_ipn/ssh.go
+++ b/internal/hp_ipn/ssh.go
@@ -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
}
diff --git a/nix/ssh-wasm.nix b/nix/ssh-wasm.nix
index 959de87..0ebfe92 100644
--- a/nix/ssh-wasm.nix
+++ b/nix/ssh-wasm.nix
@@ -26,7 +26,7 @@ in
# Patch Tailscale's derphttp to include DERPPort in WebSocket URLs.
# Without this, DERP servers on non-443 ports fail in WASM builds.
if [ -f patches/tailscale-derp-port.patch ]; then
- patch -d vendor -p1 < patches/tailscale-derp-port.patch
+ patch -d vendor/tailscale.com -p1 < patches/tailscale-derp-port.patch
fi
go build -mod=vendor -o hp_ssh.wasm ./cmd/hp_ssh
diff --git a/package.json b/package.json
index b3492af..c990a55 100644
--- a/package.json
+++ b/package.json
@@ -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",
@@ -55,6 +50,7 @@
"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",
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 4593822..3a14c89 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -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
@@ -121,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
@@ -2218,21 +2206,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'}
@@ -3760,6 +3733,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'}
@@ -3984,6 +3961,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==}
@@ -6182,18 +6164,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
@@ -6957,7 +6927,8 @@ snapshots:
jose@6.2.1: {}
- js-base64@3.7.8: {}
+ js-base64@3.7.8:
+ optional: true
js-md4@0.3.2: {}
@@ -7643,6 +7614,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: {}
@@ -7970,6 +7947,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: {}
diff --git a/public/fonts/JetBrainsMonoNLNerdFontMono-Bold.ttf b/public/fonts/JetBrainsMonoNLNerdFontMono-Bold.ttf
new file mode 100644
index 0000000..2515d7b
Binary files /dev/null and b/public/fonts/JetBrainsMonoNLNerdFontMono-Bold.ttf differ
diff --git a/public/fonts/JetBrainsMonoNLNerdFontMono-BoldItalic.ttf b/public/fonts/JetBrainsMonoNLNerdFontMono-BoldItalic.ttf
new file mode 100644
index 0000000..1ed2ee9
Binary files /dev/null and b/public/fonts/JetBrainsMonoNLNerdFontMono-BoldItalic.ttf differ
diff --git a/public/fonts/JetBrainsMonoNLNerdFontMono-Italic.ttf b/public/fonts/JetBrainsMonoNLNerdFontMono-Italic.ttf
new file mode 100644
index 0000000..c9156a0
Binary files /dev/null and b/public/fonts/JetBrainsMonoNLNerdFontMono-Italic.ttf differ
diff --git a/public/fonts/JetBrainsMonoNLNerdFontMono-Regular.ttf b/public/fonts/JetBrainsMonoNLNerdFontMono-Regular.ttf
new file mode 100644
index 0000000..bde439e
Binary files /dev/null and b/public/fonts/JetBrainsMonoNLNerdFontMono-Regular.ttf differ
diff --git a/public/fonts/SymbolsNerdFontMono-Regular.ttf b/public/fonts/SymbolsNerdFontMono-Regular.ttf
new file mode 100644
index 0000000..d898eea
Binary files /dev/null and b/public/fonts/SymbolsNerdFontMono-Regular.ttf differ