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
+16 -1
View File
@@ -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. > 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.** - **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. - Removed `react-aria`, `react-stately`, and `tailwindcss-react-aria-components` as dependencies.
- **Replaced `openid-client` with a clean-room OIDC implementation.** - **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 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 agent working directory being wiped on restart.
- Fixed a race condition where the SSE controller could be used after being closed. - 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 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 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 Dockerfile WASM copy paths.
- Fixed CodeMirror version mismatch override in the ACL editor. - Fixed CodeMirror version mismatch override in the ACL editor.
- Fixed cookie secret generation using incorrect byte length (via [#501](https://github.com/tale/headplane/pull/501)). - Fixed cookie secret generation using incorrect byte length (via [#501](https://github.com/tale/headplane/pull/501)).
+1 -3
View File
@@ -2,7 +2,6 @@ import { Outlet, redirect, type ShouldRevalidateFunction } from "react-router";
import { ErrorBanner } from "~/components/error-banner"; import { ErrorBanner } from "~/components/error-banner";
import StatusBanner from "~/components/status-banner"; import StatusBanner from "~/components/status-banner";
import { pruneEphemeralNodes } from "~/server/db/pruner";
import { isDataUnauthorizedError } from "~/server/headscale/api/error-client"; import { isDataUnauthorizedError } from "~/server/headscale/api/error-client";
import { usersResource } from "~/server/headscale/live-store"; import { usersResource } from "~/server/headscale/live-store";
import { Capabilities } from "~/server/web/roles"; import { Capabilities } from "~/server/web/roles";
@@ -30,7 +29,7 @@ export const shouldRevalidate: ShouldRevalidateFunction = ({
return false; return false;
}; };
export async function loader({ request, context, ...rest }: Route.LoaderArgs) { export async function loader({ request, context }: Route.LoaderArgs) {
try { try {
const principal = await context.auth.require(request); const principal = await context.auth.require(request);
@@ -53,7 +52,6 @@ export async function loader({ request, context, ...rest }: Route.LoaderArgs) {
if (isHealthy) { if (isHealthy) {
try { try {
await api.getApiKeys(); await api.getApiKeys();
await pruneEphemeralNodes({ context, request, ...rest });
} catch (error) { } catch (error) {
if (isDataUnauthorizedError(error)) { if (isDataUnauthorizedError(error)) {
const displayName = const displayName =
+1 -1
View File
@@ -13,7 +13,7 @@ export default [
route("/logout", "routes/auth/logout.ts"), route("/logout", "routes/auth/logout.ts"),
route("/oidc/callback", "routes/auth/oidc-callback.ts"), route("/oidc/callback", "routes/auth/oidc-callback.ts"),
route("/oidc/start", "routes/auth/oidc-start.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 // All the main logged-in routes
layout("layout/app.tsx", [ layout("layout/app.tsx", [
@@ -50,8 +50,8 @@ export default function MachineRow({
}, [magic, node.ipAddresses]); }, [magic, node.ipAddresses]);
return ( return (
<tr className="group hover:bg-mist-50 dark:hover:bg-mist-950" key={node.id}> <tr className="group hover:bg-mist-100 dark:hover:bg-mist-800" key={node.id}>
<td className="py-2 pl-0.5 focus-within:ring-3"> <td className="py-2 pl-2 focus-within:ring-3">
<Link className={cn("group/link h-full focus:outline-hidden")} to={`/machines/${node.id}`}> <Link className={cn("group/link h-full focus:outline-hidden")} to={`/machines/${node.id}`}>
<p <p
className={cn( className={cn(
+2 -2
View File
@@ -107,7 +107,7 @@ export default function MachineMenu({
// in a new WINDOW since href can only // in a new WINDOW since href can only
// do a new TAB. // do a new TAB.
window.open( window.open(
`${__PREFIX__}/ssh?hostname=${node.givenName}`, `${__PREFIX__}/ssh/${node.givenName}`,
"_blank", "_blank",
"noopener,noreferrer,width=800,height=600", "noopener,noreferrer,width=800,height=600",
); );
@@ -127,7 +127,7 @@ export default function MachineMenu({
variant="light" variant="light"
onClick={() => { onClick={() => {
window.open( window.open(
`${__PREFIX__}/ssh?hostname=${node.givenName}`, `${__PREFIX__}/ssh/${node.givenName}`,
"_blank", "_blank",
"noopener,noreferrer,width=800,height=600", "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 Button from "~/components/button";
import Card from "~/components/card"; import Card from "~/components/card";
import Code from "~/components/code"; import Code from "~/components/code";
import Input from "~/components/input"; import Input from "~/components/input";
import Link from "~/components/link";
interface UserPromptProps { interface UserPromptProps {
hostname: string; hostname: string;
} }
export default function UserPrompt({ hostname }: UserPromptProps) { export default function UserPrompt({ hostname }: UserPromptProps) {
const [username, setUsername] = useState("");
return ( return (
<div className="flex h-screen items-center justify-center"> <div className="flex h-screen items-center justify-center">
<Card> <Card>
@@ -19,27 +18,47 @@ export default function UserPrompt({ hostname }: UserPromptProps) {
<Card.Text className="mb-4"> <Card.Text className="mb-4">
Enter the username you want to use to connect to <Code>{hostname}</Code> 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> </Card.Text>
<Input <Form
labelHidden method="GET"
type="text" onSubmit={(e) => {
label="Username" const formData = new FormData(e.currentTarget);
placeholder="Username" const username = formData.get("user");
className="mb-2" if (!username) {
onChange={setUsername} e.preventDefault();
/> return;
<Button }
variant="heavy"
className="w-full" // We have to do a full navigation, since the page needs a full
onClick={() => { // reload to initialize the SSH connection due to us disabling the
// We can't use the navigate hook here as we need to do a // revalidator.
// full page reload to ensure the SSH connection is established const url = new URL(window.location.href);
window.location.href = `${__PREFIX__}/ssh?hostname=${hostname}&username=${username}`; url.searchParams.set("user", username.toString());
window.location.assign(url.toString());
}} }}
> >
Connect <Input
</Button> 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> </Card>
</div> </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; const displayEmail = user.linkedHeadscaleUser?.email ?? user.email;
return ( return (
<tr className="group hover:bg-mist-50 dark:hover:bg-mist-950" key={user.id}> <tr className="group hover:bg-mist-100 dark:hover:bg-mist-800" key={user.id}>
<td className="py-2 pl-0.5"> <td className="py-2 pl-2">
<div className="flex items-center"> <div className="flex items-center">
{user.profilePicUrl ? ( {user.profilePicUrl ? (
<img alt={displayName} className="h-10 w-10 rounded-full" src={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"; 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", { export const hostInfo = sqliteTable("host_info", {
host_id: text("host_id").primaryKey(), host_id: text("host_id").primaryKey(),
payload: text("payload", { mode: "json" }).$type<HostInfo>(), 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 log from "~/utils/log";
import { HeadplaneConfig } from "./config/config-schema"; import { HeadplaneConfig } from "./config/config-schema";
import { ephemeralNodes, hostInfo } from "./db/schema"; import { hostInfo } from "./db/schema";
import { RuntimeApiClient } from "./headscale/api/endpoints"; import { RuntimeApiClient } from "./headscale/api/endpoints";
export interface AgentManager { 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() { async function pruneStaleHostInfo() {
try { try {
const nodes = await apiClient.getNodes(); const nodes = await apiClient.getNodes();
@@ -294,23 +298,12 @@ export async function createAgentManager(
async function pruneEphemeralNodes() { async function pruneEphemeralNodes() {
try { try {
const rows = await db.select().from(ephemeralNodes);
if (rows.length === 0) {
return;
}
const nodes = await apiClient.getNodes(); 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) { for (const node of toPrune) {
if (!row.node_key) { await apiClient.deleteNode(node.id);
continue; log.info("agent", "Pruned offline ephemeral node %s", node.givenName);
}
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);
}
} }
} catch (error) { } catch (error) {
log.debug( log.debug(
+43 -57
View File
@@ -12,96 +12,82 @@ import (
func main() { func main() {
log.Printf("Loading WASM Headplane SSH module") 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 { factory := js.FuncOf(func(this js.Value, args []js.Value) any {
log.Fatal("Usage: TsWasmNet(config, callbacks)") if len(args) != 1 {
log.Printf("Usage: create(config)")
return nil return nil
} }
options, err := hp_ipn.ParseTsWasmNetOptions(args[0]) config, err := hp_ipn.ParseIPNConfig(args[0])
if err != nil { if err != nil {
log.Fatal("Error parsing options:", err) log.Printf("Error parsing config: %v", err)
return nil 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 { if err != nil {
log.Fatal("Error parsing callbacks:", err) callbacks.OnError(err.Error())
return nil return nil
} }
ipn, err := hp_ipn.NewTsWasmIpn(options, callbacks) go func() {
if err != nil { if err := ipn.Start(context.Background()); err != nil {
log.Fatal("Error creating TsWasmIpn:", err) callbacks.OnError(err.Error())
return nil }
} }()
return map[string]any{ return map[string]any{
"Start": js.FuncOf(func(this js.Value, args []js.Value) any { "openTunnel": js.FuncOf(func(this js.Value, args []js.Value) any {
ipn.Start(context.Background()) if len(args) != 1 {
return nil log.Printf("Usage: openTunnel(config)")
}),
"OpenSSH": js.FuncOf(func(this js.Value, args []js.Value) any {
if len(args) != 3 {
log.Fatal("Usage: OpenSSH(host, user, options)")
return nil return nil
} }
hostname := args[0] tunnelConfig, err := hp_ipn.ParseTunnelConfig(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])
if err != nil { if err != nil {
log.Fatal("Error parsing SSH options:", err) log.Printf("Error parsing tunnel config: %v", err)
return nil return nil
} }
session := ipn.NewSSHSession(hostname.String(), username.String(), sshOptions) session := ipn.NewSSHSession(tunnelConfig)
go session.ConnectAndRun() go session.ConnectAndRun()
return map[string]any{ return map[string]any{
"Close": js.FuncOf(func(this js.Value, args []js.Value) any { "writeInput": js.FuncOf(func(this js.Value, args []js.Value) any {
return session.Close() != nil 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 { if len(args) != 2 {
log.Fatal("Usage: Resize(cols, rows)")
return nil return nil
} }
session.Resize(args[0].Int(), args[1].Int())
return nil
}),
rows := args[0].Int() "close": js.FuncOf(func(this js.Value, args []js.Value) any {
cols := args[1].Int() session.Close()
if cols <= 0 || rows <= 0 { return nil
log.Fatal("Columns and rows must be positive integers")
return nil
}
return session.Resize(cols, rows) == 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") log.Printf("WASM Headplane SSH module loaded successfully")
<-make(chan bool) <-make(chan bool)
+1 -1
View File
@@ -46,7 +46,7 @@ export default defineConfig({
items: [ items: [
{ text: "Single Sign-On (SSO)", link: "/features/sso" }, { text: "Single Sign-On (SSO)", link: "/features/sso" },
{ text: "Headplane Agent", link: "/features/agent" }, { text: "Headplane Agent", link: "/features/agent" },
{ text: "WebSSH", link: "/features/ssh" }, { text: "Browser SSH", link: "/features/ssh" },
], ],
}, },
{ {
Binary file not shown.

After

Width:  |  Height:  |  Size: 337 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 147 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 596 KiB

+196 -42
View File
@@ -1,73 +1,227 @@
--- ---
title: WebSSH title: Browser SSH
description: Open SSH sessions to your Tailnet nodes directly from the browser. description: Open SSH sessions to your Tailnet nodes directly from the browser.
--- ---
# WebSSH # Browser SSH
<figure> <figure>
<img src="../assets/ssh.png" /> <img src="../assets/ssh-btop.png" style="width: 100%;" />
<figcaption>SSH access via the browser</figcaption> <figcaption><code>btop</code> running over browser SSH</figcaption>
</figure> </figure>
WebSSH lets you open an SSH session to any node directly from the browser. It Browser SSH allows a user to open an SSH session to any accesible node in the
uses a Go-based WASM shim that creates an ephemeral Tailscale node in the Tailnet directly from the browser. It spins up an ephemeral Tailscale node that
browser and connects to the target node over the Tailnet. joins the tailnet for the duration of the SSH session.
<figure>
<img src="../assets/ssh-fastfetch.png" style="width: 100%;" />
<figcaption><code>fastfetch</code> with Nerd Font icons</figcaption>
</figure>
## Prerequisites ## Prerequisites
- **Headscale 0.28 or newer** is required. - **Headscale 0.28 or newer** is required.
- Target nodes must have **Tailscale SSH** enabled. - Target nodes must have **Tailscale SSH** enabled (`tailscale up --ssh`).
- Users must be authenticated via **OIDC** (API key logins cannot use WebSSH). - Users must be logged-in via **OIDC** (API key logins cannot use browser SSH).
- The **Headplane Agent** must be - The **Headplane Agent** must be [enabled and configured](/features/agent).
[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`.
## How It Works ## 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: When a user opens an SSH session from the UI, the browser:
1. Loads a Go WASM binary that implements a minimal Tailscale node. 1. Loads a WASM module that runs a minimal Tailscale node using userspace
2. Authenticates to the Tailnet using an ephemeral pre-auth key generated WireGuard. This node will connect to the tailnet via a pre-auth key.
server-side. 2. Opens an SSH session to the target node's Tailscale IP address over the
3. Connects to the target node over the Tailnet using DERP relay servers. tunnel and passes it to the browser.
4. Opens an SSH session and renders it in an [xterm.js](https://xtermjs.org) 3. Using [restty](https://restty.dev) (a Ghostty-based WASM terminal emulator),
terminal. 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 ### Required Headers
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.
::: warning Your reverse proxy must forward these headers for Headscale's DERP endpoint:
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 | Header | Value |
before compiling. See the `build_wasm()` function in `build.sh` for reference. | ------------------------ | --------------------------------------- |
::: | `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 ## Troubleshooting
### "WebSSH is not configured in this build" ### SSH Not Available
The WASM assets are missing. Rebuild with `./build.sh --wasm` or ensure your **Error:** "This version of Headplane was not built with browser SSH support."
Docker image was built with the `--wasm` flag.
### "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 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. **Error:** "You'll need to link your user account to a Headscale user before
- If using custom DERP servers on non-standard ports, ensure you are running you can use Browser SSH."
a build that includes the DERP port patch (any build from `build.sh` or
Docker includes it automatically). 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. - Check the browser console for WASM errors or DERP connection failures.
### "failed to look up local user \*"
This error appears in the terminal when Headscale SSH ACLs use
`"users": ["*"]`, which some Tailscale versions interpret as a literal
username rather than a wildcard. To fix this, change your ACL SSH rules to
use `"autogroup:nonroot"` or explicit usernames instead:
```jsonc
// Before (broken on some versions)
{ "action": "accept", "src": ["autogroup:member"], "dst": ["autogroup:self"], "users": ["*"] }
// After (recommended)
{ "action": "accept", "src": ["autogroup:member"], "dst": ["autogroup:self"], "users": ["autogroup:nonroot"] }
```
### Terminal opens but input doesn't work
- Ensure the target node's Tailscale SSH ACLs permit the user. The SSH
connection succeeds at the transport level but the session may be rejected
by the node's SSH policy.
- Check that the username you entered is a valid Linux user on the target
node. If the user doesn't exist, the SSH session will appear to connect
but immediately fail.
### "SSH error: ssh: handshake failed: ssh: no common algorithm"
The target node's SSH server doesn't support any of the algorithms offered
by the Go SSH client. This usually means the target node is running a very
old or very new version of OpenSSH with non-default algorithm configuration.
Updating Tailscale on the target node typically resolves this.
### SSH session connects but immediately disconnects
- The target node may not have an SSH server installed or running. Tailscale
SSH (`tailscale up --ssh`) runs its own SSH server, but if Tailscale SSH
is not enabled, the node needs a standard SSH server (e.g. `openssh-server`)
listening on port 22.
- The node may have a firewall blocking port 22 even for Tailnet connections.
@@ -0,0 +1 @@
DROP TABLE `ephemeral_nodes`;
@@ -0,0 +1,266 @@
{
"version": "7",
"dialect": "sqlite",
"id": "dd3ce0c0-106f-4628-8c36-bdf9747e5f41",
"prevIds": ["b0533d83-6eb6-422f-925f-c59e4db89384"],
"ddl": [
{
"name": "auth_sessions",
"entityType": "tables"
},
{
"name": "host_info",
"entityType": "tables"
},
{
"name": "users",
"entityType": "tables"
},
{
"type": "text",
"notNull": false,
"autoincrement": false,
"default": null,
"generated": null,
"name": "id",
"entityType": "columns",
"table": "auth_sessions"
},
{
"type": "text",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "kind",
"entityType": "columns",
"table": "auth_sessions"
},
{
"type": "text",
"notNull": false,
"autoincrement": false,
"default": null,
"generated": null,
"name": "user_id",
"entityType": "columns",
"table": "auth_sessions"
},
{
"type": "text",
"notNull": false,
"autoincrement": false,
"default": null,
"generated": null,
"name": "api_key_hash",
"entityType": "columns",
"table": "auth_sessions"
},
{
"type": "text",
"notNull": false,
"autoincrement": false,
"default": null,
"generated": null,
"name": "api_key_display",
"entityType": "columns",
"table": "auth_sessions"
},
{
"type": "integer",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "expires_at",
"entityType": "columns",
"table": "auth_sessions"
},
{
"type": "integer",
"notNull": false,
"autoincrement": false,
"default": null,
"generated": null,
"name": "created_at",
"entityType": "columns",
"table": "auth_sessions"
},
{
"type": "text",
"notNull": false,
"autoincrement": false,
"default": null,
"generated": null,
"name": "host_id",
"entityType": "columns",
"table": "host_info"
},
{
"type": "text",
"notNull": false,
"autoincrement": false,
"default": null,
"generated": null,
"name": "payload",
"entityType": "columns",
"table": "host_info"
},
{
"type": "integer",
"notNull": false,
"autoincrement": false,
"default": null,
"generated": null,
"name": "updated_at",
"entityType": "columns",
"table": "host_info"
},
{
"type": "text",
"notNull": false,
"autoincrement": false,
"default": null,
"generated": null,
"name": "id",
"entityType": "columns",
"table": "users"
},
{
"type": "text",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "sub",
"entityType": "columns",
"table": "users"
},
{
"type": "text",
"notNull": false,
"autoincrement": false,
"default": null,
"generated": null,
"name": "name",
"entityType": "columns",
"table": "users"
},
{
"type": "text",
"notNull": false,
"autoincrement": false,
"default": null,
"generated": null,
"name": "email",
"entityType": "columns",
"table": "users"
},
{
"type": "text",
"notNull": false,
"autoincrement": false,
"default": null,
"generated": null,
"name": "picture",
"entityType": "columns",
"table": "users"
},
{
"type": "text",
"notNull": true,
"autoincrement": false,
"default": "'member'",
"generated": null,
"name": "role",
"entityType": "columns",
"table": "users"
},
{
"type": "text",
"notNull": false,
"autoincrement": false,
"default": null,
"generated": null,
"name": "headscale_user_id",
"entityType": "columns",
"table": "users"
},
{
"type": "integer",
"notNull": false,
"autoincrement": false,
"default": null,
"generated": null,
"name": "created_at",
"entityType": "columns",
"table": "users"
},
{
"type": "integer",
"notNull": false,
"autoincrement": false,
"default": null,
"generated": null,
"name": "updated_at",
"entityType": "columns",
"table": "users"
},
{
"type": "integer",
"notNull": false,
"autoincrement": false,
"default": null,
"generated": null,
"name": "last_login_at",
"entityType": "columns",
"table": "users"
},
{
"type": "integer",
"notNull": true,
"autoincrement": false,
"default": "0",
"generated": null,
"name": "caps",
"entityType": "columns",
"table": "users"
},
{
"columns": ["id"],
"nameExplicit": false,
"name": "auth_sessions_pk",
"table": "auth_sessions",
"entityType": "pks"
},
{
"columns": ["host_id"],
"nameExplicit": false,
"name": "host_info_pk",
"table": "host_info",
"entityType": "pks"
},
{
"columns": ["id"],
"nameExplicit": false,
"name": "users_pk",
"table": "users",
"entityType": "pks"
},
{
"columns": ["sub"],
"nameExplicit": false,
"name": "users_sub_unique",
"entityType": "uniques",
"table": "users"
},
{
"columns": ["headscale_user_id"],
"nameExplicit": false,
"name": "users_headscale_user_id_unique",
"entityType": "uniques",
"table": "users"
}
],
"renames": []
}
+21 -60
View File
@@ -14,7 +14,6 @@ import (
"tailscale.com/ipn/ipnlocal" "tailscale.com/ipn/ipnlocal"
"tailscale.com/ipn/ipnserver" "tailscale.com/ipn/ipnserver"
"tailscale.com/ipn/store/mem" "tailscale.com/ipn/store/mem"
// "tailscale.com/net/netmon"
"tailscale.com/net/netns" "tailscale.com/net/netns"
"tailscale.com/net/tsdial" "tailscale.com/net/tsdial"
"tailscale.com/safesocket" "tailscale.com/safesocket"
@@ -24,102 +23,74 @@ import (
"tailscale.com/wgengine/netstack" "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 { type TsWasmIpn struct {
// The options used to initialize the TsWasmNet module. options *IPNConfig
options *TsWasmNetOptions dialer *tsdial.Dialer
server *ipnserver.Server
// 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.
backend *ipnlocal.LocalBackend backend *ipnlocal.LocalBackend
} }
// NewTsWasmIpn initializes a new TsWasmIpn instance with the provided options. func NewTsWasmIpn(options *IPNConfig, callbacks *IPNCallbacks) (*TsWasmIpn, error) {
// This intentionally does not initialize Logtail, as it is only available in logf := log.Printf
// the Tailscale SaaS and not on self-hosted instances. netns.SetEnabled(false)
func NewTsWasmIpn(options *TsWasmNetOptions, callbacks *TsWasmNetCallbacks) (*TsWasmIpn, error) {
logf := log.Printf // TODO: Update
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() sys := tsd.NewSystem()
// bus := sys.Bus.Get()
sys.Set(new(mem.Store)) sys.Set(new(mem.Store))
dialer := &tsdial.Dialer{Logf: logf} 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{ engine, err := wgengine.NewUserspaceEngine(logf, wgengine.Config{
Dialer: dialer, Dialer: dialer,
// NetMon: netmon,
SetSubsystem: sys.Set, SetSubsystem: sys.Set,
ControlKnobs: sys.ControlKnobs(), ControlKnobs: sys.ControlKnobs(),
HealthTracker: sys.HealthTracker(), HealthTracker: sys.HealthTracker(),
Metrics: sys.UserMetricsRegistry(), Metrics: sys.UserMetricsRegistry(),
EventBus: sys.Bus.Get(), EventBus: sys.Bus.Get(),
}) })
if err != nil { if err != nil {
return nil, err return nil, fmt.Errorf("failed to create userspace engine: %w", err)
} }
sys.Set(engine) sys.Set(engine)
tun := sys.Tun.Get() tun := sys.Tun.Get()
msock := sys.MagicSock.Get() msock := sys.MagicSock.Get()
dnsman := sys.DNSManager.Get() dnsman := sys.DNSManager.Get()
proxymap := sys.ProxyMapper() 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 { 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.ProcessLocalIPs = true
wgstack.ProcessSubnets = true wgstack.ProcessSubnets = true
dialer.UseNetstackForIP = func(ip netip.Addr) bool { dialer.UseNetstackForIP = func(ip netip.Addr) bool {
return true return true
} }
dialer.NetstackDialTCP = func(ctx context.Context, dst netip.AddrPort) (net.Conn, error) { dialer.NetstackDialTCP = func(ctx context.Context, dst netip.AddrPort) (net.Conn, error) {
return wgstack.DialContextTCP(ctx, dst) return wgstack.DialContextTCP(ctx, dst)
} }
dialer.NetstackDialUDP = func(ctx context.Context, dst netip.AddrPort) (net.Conn, error) { dialer.NetstackDialUDP = func(ctx context.Context, dst netip.AddrPort) (net.Conn, error) {
return wgstack.DialContextUDP(ctx, dst) return wgstack.DialContextUDP(ctx, dst)
} }
// Dummy logid for the Tailscale backend logID := logid.PublicID{}
logid := logid.PublicID{}
sys.NetstackRouter.Set(true) sys.NetstackRouter.Set(true)
sys.Tun.Get().Start() sys.Tun.Get().Start()
server := ipnserver.New(logf, logid, sys.NetMon.Get()) server := ipnserver.New(logf, logID, sys.NetMon.Get())
flags := controlclient.LoginDefault | controlclient.LoginEphemeral | controlclient.LocalBackendStartKeyOSNeutral
backend, err := ipnlocal.NewLocalBackend(logf, logid, sys, flags) backend, err := ipnlocal.NewLocalBackend(logf, logID, sys, controlclient.LoginEphemeral)
if err != nil { if err != nil {
return nil, err return nil, fmt.Errorf("failed to create local backend: %w", err)
} }
err = wgstack.Start(backend) if err := wgstack.Start(backend); err != nil {
if err != nil { return nil, fmt.Errorf("failed to start netstack: %w", err)
return nil, err
} }
server.SetLocalBackend(backend) server.SetLocalBackend(backend)
@@ -133,26 +104,18 @@ func NewTsWasmIpn(options *TsWasmNetOptions, callbacks *TsWasmNetCallbacks) (*Ts
}, nil }, 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 { 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("") listener, err := safesocket.Listen("")
if err != nil { if err != nil {
return fmt.Errorf("failed to create safesocket listener: %w", err) return fmt.Errorf("failed to create safesocket listener: %w", err)
} }
// Start the server BEFORE the LocalBackend is started
go func() { go func() {
err := t.server.Run(ctx, listener) if err := t.server.Run(ctx, listener); err != nil {
if err != nil { log.Printf("Tailscale server exited: %v", err)
// TODO: Handle this dispatch using a chan
log.Printf("Failed to run Tailscale server: %v", err)
} }
}() }()
// Start the LocalBackend
err = t.backend.Start(ipn.Options{ err = t.backend.Start(ipn.Options{
AuthKey: t.options.PreAuthKey, AuthKey: t.options.PreAuthKey,
UpdatePrefs: &ipn.Prefs{ UpdatePrefs: &ipn.Prefs{
@@ -163,11 +126,9 @@ func (t *TsWasmIpn) Start(ctx context.Context) error {
LoggedOut: false, LoggedOut: false,
}, },
}) })
if err != nil { if err != nil {
return fmt.Errorf("failed to start Tailscale backend: %w", err) return fmt.Errorf("failed to start Tailscale backend: %w", err)
} }
log.Printf("Tailscale backend started successfully with hostname: %s", t.options.Hostname)
return nil return nil
} }
+24 -87
View File
@@ -3,15 +3,35 @@
package hp_ipn package hp_ipn
import ( import (
"errors"
"fmt"
"syscall/js" "syscall/js"
"tailscale.com/ipn" "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{ var BackendState = map[ipn.State]string{
ipn.NoState: "NoState", ipn.NoState: "NoState",
ipn.Stopped: "Stopped", ipn.Stopped: "Stopped",
@@ -21,86 +41,3 @@ var BackendState = map[ipn.State]string{
ipn.NeedsMachineAuth: "NeedsMachineAuth", ipn.NeedsMachineAuth: "NeedsMachineAuth",
ipn.NeedsLogin: "NeedsLogin", ipn.NeedsLogin: "NeedsLogin",
} }
// Represents the callbacks that the TsWasmNet module can invoke to register
// data retrieval and notifications on the frontend.
type TsWasmNetCallbacks struct {
// Changes in the backend state.
NotifyState func(ipn.State)
// Updates to the backend's network map.
NotifyNetMap func(*netmap.NetworkMap)
// If interactive login is required, this passes a login URL.
NotifyBrowseToURL func(string)
// If the process panics, this function is called in go.recover.
NotifyPanicRecover func(string)
}
// Parses a JavaScript object containing the necessary callbacks for the
// TsWasmNet module to properly interact with the frontend.
func ParseTsWasmNetCallbacks(obj js.Value) (*TsWasmNetCallbacks, error) {
if obj.IsUndefined() || obj.IsNull() {
return nil, errors.New("callbacks object is undefined or null")
}
state, err := validateCallback("NotifyState", obj)
if err != nil {
return nil, fmt.Errorf("invalid callback NotifyState: %w", err)
}
// TODO: This is complicated, as the NetworkMap is a complex type.
_, err = validateCallback("NotifyNetMap", obj)
if err != nil {
return nil, fmt.Errorf("invalid callback NotifyNetMap: %w", err)
}
browseURL, err := validateCallback("NotifyBrowseToURL", obj)
if err != nil {
return nil, fmt.Errorf("invalid callback NotifyBrowseToURL: %w", err)
}
panicRecover, err := validateCallback("NotifyPanicRecover", obj)
if err != nil {
return nil, fmt.Errorf("invalid callback NotifyPanicRecover: %w", err)
}
return &TsWasmNetCallbacks{
NotifyState: func(ipnState ipn.State) {
state.Invoke(BackendState[ipnState])
},
NotifyNetMap: func(nm *netmap.NetworkMap) {
// We need to build a JSON representation of the NetworkMap
// For now we just pass the NodeKey since that's what we need.
jsObj := js.ValueOf(map[string]any{
"NodeKey": nm.NodeKey.String(),
})
obj.Get("NotifyNetMap").Invoke(jsObj)
},
NotifyBrowseToURL: func(url string) {
browseURL.Invoke(url)
},
NotifyPanicRecover: func(msg string) {
panicRecover.Invoke(msg)
},
}, nil
}
// Validates the specified key is a JS function and returns it.
func validateCallback(key string, obj js.Value) (*js.Value, error) {
val := obj.Get(key)
if val.IsUndefined() || val.IsNull() {
return nil, errors.New("callback is undefined or null")
}
if val.Type() != js.TypeFunction {
return nil, errors.New("callback is not a function")
}
return &val, nil
}
+38 -71
View File
@@ -7,114 +7,83 @@ import (
"syscall/js" "syscall/js"
) )
// Represents the options needed to initialize the TsWasmNet module. type IPNConfig struct {
type TsWasmNetOptions struct {
// Tailscale control URL, e.g., "https://controlplane.tailscale.com"
ControlURL string ControlURL string
// Pre-authentication key for the Tailnet
PreAuthKey string PreAuthKey string
Hostname string
// Optional hostname, autogenerated if not provided.
Hostname string
} }
// Parses the provided JS object to validate and extract the TsWasmNetOptions. func ParseIPNConfig(obj js.Value) (*IPNConfig, error) {
func ParseTsWasmNetOptions(obj js.Value) (*TsWasmNetOptions, error) {
if obj.IsUndefined() || obj.IsNull() { 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) controlURL := safeString("controlURL", obj)
preAuthKey := safeString("PreAuthKey", obj) preAuthKey := safeString("preAuthKey", obj)
hostname := safeString("Hostname", obj) hostname := safeString("hostname", obj)
if cUrl == "" || preAuthKey == "" || hostname == "" { if controlURL == "" || preAuthKey == "" || hostname == "" {
return nil, errors.New("missing required fields in TsWasmNetOptions") return nil, errors.New("missing required fields: controlURL, preAuthKey, hostname")
} }
return &TsWasmNetOptions{ return &IPNConfig{
ControlURL: cUrl, ControlURL: controlURL,
PreAuthKey: preAuthKey, PreAuthKey: preAuthKey,
Hostname: hostname, Hostname: hostname,
}, nil }, nil
} }
// Options passed from the JS side to pass data to xterm.js. type TunnelConfig struct {
type SSHXtermConfig struct { IPAddress string
Timeout int // Timeout in seconds for the PTY connection. Username string
Rows int // Number of rows in the PTY. Timeout int
Cols int // Number of columns in the PTY. OnData func(data string)
OnStdout func(data js.Value) // Fires when the PTY has output. OnConnect func()
OnStderr func(error js.Value) // Fires when the PTY has an error. OnDisconnect func()
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.
} }
// Parses the provided JS object to validate and extract SSHXtermConfig. func ParseTunnelConfig(obj js.Value) (*TunnelConfig, error) {
func ParseSSHXtermConfig(obj js.Value) (*SSHXtermConfig, error) {
if obj.IsUndefined() || obj.IsNull() { 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) 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 { if timeout <= 0 {
timeout = 30 // Default timeout to 30 seconds if not specified timeout = 30
} }
config := &SSHXtermConfig{ config := &TunnelConfig{
Timeout: timeout, IPAddress: ipAddress,
Rows: rows, Username: username,
Cols: cols, Timeout: timeout,
} }
onStdout := obj.Get("onStdout") onData := obj.Get("onData")
if onStdout.IsUndefined() || onStdout.IsNull() || (onStdout.Type() != js.TypeFunction) { if onData.IsUndefined() || onData.IsNull() || onData.Type() != js.TypeFunction {
return nil, errors.New("`onStdout` is required and must be a function") return nil, errors.New("`onData` is required and must be a function")
} }
config.OnData = func(data string) {
config.OnStdout = func(data js.Value) { onData.Invoke(data)
onStdout.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") 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") return nil, errors.New("`onConnect` is required and must be a function")
} }
config.OnConnect = func() { config.OnConnect = func() {
onConnect.Invoke() onConnect.Invoke()
} }
onDisconnect := obj.Get("onDisconnect") 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") return nil, errors.New("`onDisconnect` is required and must be a function")
} }
config.OnDisconnect = func() { config.OnDisconnect = func() {
onDisconnect.Invoke() onDisconnect.Invoke()
} }
@@ -122,7 +91,6 @@ func ParseSSHXtermConfig(obj js.Value) (*SSHXtermConfig, error) {
return config, nil return config, nil
} }
// Retrieves a string value from a JS object safely.
func safeString(key string, obj js.Value) string { func safeString(key string, obj js.Value) string {
if obj.IsUndefined() || obj.IsNull() { if obj.IsUndefined() || obj.IsNull() {
return "" return ""
@@ -136,7 +104,6 @@ func safeString(key string, obj js.Value) string {
return val.String() return val.String()
} }
// Retrieves an integer value from a JS object safely.
func safeInt(key string, obj js.Value) int { func safeInt(key string, obj js.Value) int {
if obj.IsUndefined() || obj.IsNull() { if obj.IsUndefined() || obj.IsNull() {
return 0 return 0
+11 -69
View File
@@ -6,94 +6,36 @@ import (
"context" "context"
"fmt" "fmt"
"log" "log"
"sync"
"tailscale.com/ipn" "tailscale.com/ipn"
"tailscale.com/ipn/ipnlocal" "tailscale.com/ipn/ipnlocal"
) )
func registerNotifyCallback(callbacks *TsWasmNetCallbacks, lb *ipnlocal.LocalBackend) { func registerNotifyCallback(callbacks *IPNCallbacks, lb *ipnlocal.LocalBackend) {
lb.SetNotifyCallback(func(n ipn.Notify) { var readyOnce sync.Once
// 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.
lb.SetNotifyCallback(func(n ipn.Notify) {
defer func() { defer func() {
rec := recover() if rec := recover(); rec != nil {
if rec != nil { callbacks.OnError(fmt.Sprint(rec))
callbacks.NotifyPanicRecover(fmt.Sprint(rec))
} }
}() }()
if n.State != nil { if n.State != nil {
callbacks.NotifyState(*n.State) if *n.State == ipn.Running {
readyOnce.Do(callbacks.OnReady)
}
if *n.State == ipn.NeedsLogin { if *n.State == ipn.NeedsLogin {
// If the state is NeedsLogin, we need to force an interactive login.
go forceInteractiveLogin(lb) 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) { func forceInteractiveLogin(lb *ipnlocal.LocalBackend) {
err := lb.StartLoginInteractive(context.Background()) if err := lb.StartLoginInteractive(context.Background()); err != nil {
if err != nil { log.Printf("Error starting interactive login: %v", err)
fmt.Printf("Error starting interactive login: %v\n", err)
} }
} }
+72 -126
View File
@@ -8,83 +8,71 @@ import (
"io" "io"
"log" "log"
"net" "net"
"syscall/js"
"time" "time"
"golang.org/x/crypto/ssh" "golang.org/x/crypto/ssh"
) )
// Represents an SSH session over the Tailnet.
type SSHSession struct { type SSHSession struct {
// Hostname on the Tailnet. IPAddress string
Hostname string Username string
Config *TunnelConfig
Ipn *TsWasmIpn
Pty *ssh.Session
// Username for the SSH connection. stdin io.Writer
Username string resizeCols int
resizeRows int
// Xterm configuration for the SSH session. cancel context.CancelFunc
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
} }
// Creates a new SSH session given a hostname and username. func (i *TsWasmIpn) NewSSHSession(config *TunnelConfig) *SSHSession {
func (i *TsWasmIpn) NewSSHSession(hostname, username string, termConfig *SSHXtermConfig) *SSHSession {
return &SSHSession{ return &SSHSession{
Hostname: hostname, IPAddress: config.IPAddress,
Username: username, Username: config.Username,
TermConfig: termConfig, Config: config,
Ipn: i, Ipn: i,
} }
} }
func (s *SSHSession) ConnectAndRun() { 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() defer cancel()
// TODO: Log here conn, err := s.Ipn.dialer.UserDial(ctx, "tcp", net.JoinHostPort(s.IPAddress, "22"))
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"))
if err != nil { if err != nil {
log.Printf("SSH dial error: %v", err)
s.writeError("Dial", err) s.writeError("Dial", err)
return return
} }
defer conn.Close() 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{ sshConf := &ssh.ClientConfig{
User: s.Username, User: s.Username,
HostKeyCallback: func(hostname string, remote net.Addr, key ssh.PublicKey) error { 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 return nil
}, },
} }
// TODO: LOG: Starting SSH Client sshConn, chans, reqs, err := ssh.NewClientConn(conn, s.IPAddress, sshConf)
sshConn, _, _, err := ssh.NewClientConn(conn, s.Hostname, sshConf)
if err != nil { if err != nil {
s.writeError("SSH", err) s.writeError("SSH", err)
return return
} }
defer sshConn.Close() defer sshConn.Close()
sshClient := ssh.NewClient(sshConn, nil, nil)
conn.SetReadDeadline(time.Time{})
sshClient := ssh.NewClient(sshConn, chans, reqs)
defer sshClient.Close() defer sshClient.Close()
pty, err := sshClient.NewSession() pty, err := sshClient.NewSession()
@@ -92,30 +80,28 @@ func (s *SSHSession) ConnectAndRun() {
s.writeError("SSH", err) s.writeError("SSH", err)
return return
} }
defer pty.Close() defer pty.Close()
s.Pty = pty s.Pty = pty
rows := s.TermConfig.Rows rows := 24
if s.ResizeRows != 0 { if s.resizeRows != 0 {
rows = s.ResizeRows rows = s.resizeRows
} }
cols := s.TermConfig.Cols cols := 80
if s.ResizeCols != 0 { if s.resizeCols != 0 {
cols = s.ResizeCols cols = s.resizeCols
} }
err = pty.RequestPty("xterm", rows, cols, ssh.TerminalModes{ err = pty.RequestPty("xterm-256color", rows, cols, ssh.TerminalModes{
ssh.ECHO: 1, // enable echoing ssh.ECHO: 1,
ssh.ICANON: 1, // canonical mode ssh.ICANON: 1,
ssh.ISIG: 1, // enable signals ssh.ISIG: 1,
ssh.ICRNL: 1, // map CR to NL on input ssh.ICRNL: 1,
ssh.IUTF8: 1, // input is UTF-8 ssh.IUTF8: 1,
ssh.TTY_OP_ISPEED: 14400, // input speed = 14.4kbaud ssh.TTY_OP_ISPEED: 14400,
ssh.TTY_OP_OSPEED: 14400, // output speed = 14.4kbaud ssh.TTY_OP_OSPEED: 14400,
}) })
if err != nil { if err != nil {
s.writeError("SSH", err) s.writeError("SSH", err)
return return
@@ -126,8 +112,7 @@ func (s *SSHSession) ConnectAndRun() {
s.writeError("SSH", err) s.writeError("SSH", err)
return return
} }
s.stdin = stdin
s.wireStdinHandler(stdin)
stdout, err := pty.StdoutPipe() stdout, err := pty.StdoutPipe()
if err != nil { if err != nil {
@@ -141,100 +126,61 @@ func (s *SSHSession) ConnectAndRun() {
return return
} }
go io.Copy(XtermPipe{s.TermConfig.OnStdout}, stdout) go io.Copy(DataPipe{s.Config.OnData}, stdout)
go io.Copy(XtermPipe{s.TermConfig.OnStderr}, stderr) go io.Copy(DataPipe{s.Config.OnData}, stderr)
// Create our shell
err = pty.Shell() err = pty.Shell()
if err != nil { if err != nil {
s.writeError("SSH", err) s.writeError("SSH", err)
return return
} }
s.TermConfig.OnConnect() s.Config.OnConnect()
err = pty.Wait() if err := pty.Wait(); err != nil {
if err != nil { log.Printf("SSH session ended: %v", err)
s.writeError("SSH", err)
return
} }
} }
// Resize resizes the terminal for the SSH session. func (s *SSHSession) WriteInput(data string) {
// TODO: This does NOT work correctly from Xterm.js if s.stdin != nil {
func (s *SSHSession) Resize(rows, cols int) error { s.stdin.Write([]byte(data))
// Used to handle resizes while still connecting. }
}
// 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 { if s.Pty == nil {
s.ResizeRows = rows s.resizeCols = cols
s.ResizeCols = cols s.resizeRows = rows
return nil return nil
} }
return s.Pty.WindowChange(cols, rows) return s.Pty.WindowChange(rows, cols)
} }
// Closes the SSH session.
func (s *SSHSession) Close() error { func (s *SSHSession) Close() error {
if s.stdinHandler != nil { if s.cancel != nil {
s.stdinHandler.Release() s.cancel()
s.stdinHandler = nil s.cancel = nil
} }
if s.Pty != nil { if s.Pty != nil {
err := s.Pty.Close() return s.Pty.Close()
if err != nil {
return err
}
} }
return nil 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) { func (s *SSHSession) writeError(label string, err error) {
o := fmt.Sprintf("%s error: %v\r\n", label, err) s.Config.OnData(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)
} }
// io.Writer "emulator" to pass to the ssh module. type DataPipe struct {
type XtermPipe struct { Send func(data string)
// Function to call when data is written.
Send func(data js.Value)
} }
// Write implements the io.Writer interface for XtermPipe. func (p DataPipe) Write(data []byte) (int, error) {
func (x XtermPipe) Write(data []byte) (int, error) { p.Send(string(data))
uint8Array := js.Global().Get("Uint8Array").New(len(data))
js.CopyBytesToJS(uint8Array, data)
x.Send(uint8Array)
return len(data), nil return len(data), nil
} }
+1 -1
View File
@@ -26,7 +26,7 @@ in
# Patch Tailscale's derphttp to include DERPPort in WebSocket URLs. # Patch Tailscale's derphttp to include DERPPort in WebSocket URLs.
# Without this, DERP servers on non-443 ports fail in WASM builds. # Without this, DERP servers on non-443 ports fail in WASM builds.
if [ -f patches/tailscale-derp-port.patch ]; then 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 fi
go build -mod=vendor -o hp_ssh.wasm ./cmd/hp_ssh go build -mod=vendor -o hp_ssh.wasm ./cmd/hp_ssh
+1 -5
View File
@@ -33,11 +33,6 @@
"@uiw/codemirror-theme-github": "4.25.1", "@uiw/codemirror-theme-github": "4.25.1",
"@uiw/codemirror-theme-xcode": "4.25.5", "@uiw/codemirror-theme-xcode": "4.25.5",
"@uiw/react-codemirror": "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", "arktype": "^2.1.29",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"drizzle-orm": "1.0.0-beta.16-c2458b2", "drizzle-orm": "1.0.0-beta.16-c2458b2",
@@ -55,6 +50,7 @@
"react-router-dom": "^7.13.1", "react-router-dom": "^7.13.1",
"react-router-hono-server": "2.25.0", "react-router-hono-server": "2.25.0",
"remix-utils": "^9.0.1", "remix-utils": "^9.0.1",
"restty": "^0.1.35",
"tailwind-merge": "3.5.0", "tailwind-merge": "3.5.0",
"ulidx": "2.4.1", "ulidx": "2.4.1",
"undici": "7.22.0", "undici": "7.22.0",
+24 -43
View File
@@ -55,21 +55,6 @@ importers:
'@uiw/react-codemirror': '@uiw/react-codemirror':
specifier: 4.25.5 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) 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: arktype:
specifier: ^2.1.29 specifier: ^2.1.29
version: 2.1.29 version: 2.1.29
@@ -121,6 +106,9 @@ importers:
remix-utils: remix-utils:
specifier: ^9.0.1 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) 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: tailwind-merge:
specifier: 3.5.0 specifier: 3.5.0
version: 3.5.0 version: 3.5.0
@@ -2218,21 +2206,6 @@ packages:
peerDependencies: peerDependencies:
vue: ^3.5.0 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: abort-controller@3.0.0:
resolution: {integrity: sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==} resolution: {integrity: sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==}
engines: {node: '>=6.5'} engines: {node: '>=6.5'}
@@ -3760,6 +3733,10 @@ packages:
resolve-pkg-maps@1.0.0: resolve-pkg-maps@1.0.0:
resolution: {integrity: sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==} 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: retry@0.12.0:
resolution: {integrity: sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow==} resolution: {integrity: sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow==}
engines: {node: '>= 4'} engines: {node: '>= 4'}
@@ -3984,6 +3961,11 @@ packages:
text-decoder@1.2.3: text-decoder@1.2.3:
resolution: {integrity: sha512-3/o9z3X0X0fTupwsYvR03pJ/DjWuqqrfwBgTQzdWDiQSm9KitAyz/9WqsT2JQW7KV2m+bC2ol/zqpW37NHxLaA==} 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: tinybench@2.9.0:
resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==}
@@ -6182,18 +6164,6 @@ snapshots:
dependencies: dependencies:
vue: 3.5.18(typescript@5.9.3) 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: abort-controller@3.0.0:
dependencies: dependencies:
event-target-shim: 5.0.1 event-target-shim: 5.0.1
@@ -6957,7 +6927,8 @@ snapshots:
jose@6.2.1: {} jose@6.2.1: {}
js-base64@3.7.8: {} js-base64@3.7.8:
optional: true
js-md4@0.3.2: {} js-md4@0.3.2: {}
@@ -7643,6 +7614,12 @@ snapshots:
resolve-pkg-maps@1.0.0: {} 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: {} retry@0.12.0: {}
rfc4648@1.5.4: {} rfc4648@1.5.4: {}
@@ -7970,6 +7947,10 @@ snapshots:
transitivePeerDependencies: transitivePeerDependencies:
- react-native-b4a - react-native-b4a
text-shaper@0.1.22(typescript@5.9.3):
dependencies:
typescript: 5.9.3
tinybench@2.9.0: {} tinybench@2.9.0: {}
tinyexec@1.0.2: {} tinyexec@1.0.2: {}
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.