feat(ssh): bump restty and follow upstream tailscale ssh

This commit is contained in:
Aarnav Tale
2026-08-27 14:59:47 -07:00
parent b9aa99c45e
commit 29afac60f6
31 changed files with 1653 additions and 1814 deletions
+54 -45
View File
@@ -3,8 +3,6 @@ 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.
@@ -18,36 +16,50 @@ const HEADPLANE_THEME: GhosttyTheme = {
raw: {},
};
function createSSHTransport(ssh: HeadplaneSSH, ipAddress: string, username: string): PtyTransport {
let session: TunnelSession | null = null;
function createSSHTransport(
ipn: IPN,
ipAddress: string,
username: string,
onConnected: () => void,
): PtyTransport {
let session: IPNSSHSession | null = null;
let writeInput: ((data: string) => void) | null = null;
return {
connect(options) {
session = ssh.openTunnel({
ipAddress,
username,
onData: (data) => options.callbacks.onData?.(data),
onConnect: () => options.callbacks.onConnect?.(),
onDisconnect: () => {
session = ipn.ssh(ipAddress, username, {
writeFn: (data) => options.callbacks.onData?.(data),
writeErrorFn: (error) => options.callbacks.onData?.(error),
setReadFn: (readFn) => {
writeInput = readFn;
},
rows: options.rows ?? 24,
cols: options.cols ?? 80,
termType: "xterm-256color",
timeoutSeconds: 30,
onConnectionProgress: () => {},
onConnected: () => {
options.callbacks.onConnect?.();
onConnected();
},
onDone: () => {
options.callbacks.onDisconnect?.();
session = null;
writeInput = null;
},
});
if (options.cols && options.rows) {
session.resize(options.cols, options.rows);
}
},
disconnect() {
session?.close();
session = null;
},
sendInput(data) {
session?.writeInput(data);
writeInput?.(data);
return session != null;
},
// Restty passes cols first, the Tailscale session takes rows first.
resize(cols, rows) {
session?.resize(cols, rows);
session?.resize(rows, cols);
return session != null;
},
isConnected() {
@@ -61,66 +73,63 @@ function createSSHTransport(ssh: HeadplaneSSH, ipAddress: string, username: stri
}
interface GhosttyProps {
ssh: HeadplaneSSH;
ipn: IPN;
ipAddress: string;
username: string;
onConnected: () => void;
}
export default function Ghostty({ ssh, ipAddress, username, onConnected }: GhosttyProps) {
export default function Ghostty({ ipn, ipAddress, username, onConnected }: GhosttyProps) {
const divRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!divRef.current) return;
const transport = createSSHTransport(ssh, ipAddress, username);
const transport = createSSHTransport(ipn, ipAddress, username, onConnected);
const restty = new Restty({
root: divRef.current,
createInitialPane: true,
defaultContextMenu: false,
shortcuts: false,
searchUi: false,
paneStyles: {
inactivePaneOpacity: 1,
activePaneOpacity: 1,
surface: {
createInitialPane: true,
defaultContextMenu: false,
shortcuts: false,
searchUi: false,
paneStyles: {
inactivePaneOpacity: 1,
activePaneOpacity: 1,
},
},
appOptions: {
terminal: {
fontSize: 20,
ligatures: true,
fontPreset: "none",
fontSources: [
fonts: [
{
type: "url",
url: `${FONT_BASE}/JetBrainsMonoNLNerdFontMono-Regular.ttf`,
label: "JetBrains Mono Nerd Font",
name: "JetBrains Mono Nerd Font",
},
{
type: "url",
url: `${FONT_BASE}/JetBrainsMonoNLNerdFontMono-Bold.ttf`,
label: "JetBrains Mono Nerd Font Bold",
name: "JetBrains Mono Nerd Font Bold",
weight: 700,
},
{
type: "url",
url: `${FONT_BASE}/JetBrainsMonoNLNerdFontMono-Italic.ttf`,
label: "JetBrains Mono Nerd Font Italic",
name: "JetBrains Mono Nerd Font Italic",
style: "italic",
},
{
type: "url",
url: `${FONT_BASE}/JetBrainsMonoNLNerdFontMono-BoldItalic.ttf`,
label: "JetBrains Mono Nerd Font Bold Italic",
name: "JetBrains Mono Nerd Font Bold Italic",
weight: 700,
style: "italic",
},
{
type: "url",
url: `${FONT_BASE}/SymbolsNerdFontMono-Regular.ttf`,
label: "Symbols Nerd Font",
name: "Symbols Nerd Font",
},
],
},
services: {
ptyTransport: transport,
callbacks: {
onPtyStatus: (status) => {
if (status === "connected") onConnected();
},
},
},
});
@@ -131,7 +140,7 @@ export default function Ghostty({ ssh, ipAddress, username, onConnected }: Ghost
return () => {
restty.destroy();
};
}, [ssh, ipAddress, username]);
}, [ipn, ipAddress, username, onConnected]);
return <div className="min-h-0 min-w-0 flex-1 overflow-hidden bg-black" ref={divRef} />;
}
+24 -37
View File
@@ -18,8 +18,7 @@ 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";
import { connectTailnet } from "./wasm.client";
const WASM_MODULE_URL = `${__PREFIX__}/hp_ssh.wasm`;
const WASM_HELPER_URL = `${__PREFIX__}/wasm_exec.js`;
@@ -217,48 +216,36 @@ function SSHConsole({
username: string;
node: { ipAddress: string; controlURL: string; preAuthKey: string; ephemeralHostname: string };
}) {
const [ssh, setSsh] = useState<HeadplaneSSH | null>(null);
const [ipn, setIpn] = useState<IPN | null>(null);
const [connected, setConnected] = useState(false);
const [status, setStatus] = useState("Starting tunnel…");
const [status, setStatus] = useState("Joining Tailnet…");
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);
if (!cancelled) {
setStatus(`Failed to join Tailnet: ${msg}`);
}
},
});
console.log("[ssh] IPN instance created", instance);
});
connectTailnet({
controlURL: node.controlURL,
authKey: node.preAuthKey,
hostname: node.ephemeralHostname,
onPanic: (error) => {
if (!cancelled) setStatus(`Tailnet node stopped: ${error}`);
},
}).then(
(instance) => {
if (cancelled) return;
setStatus(`Connecting to ${hostname}`);
setIpn(instance);
},
(error: unknown) => {
if (cancelled) return;
setStatus(`Failed to join Tailnet: ${error instanceof Error ? error.message : error}`);
},
);
return () => {
cancelled = true;
};
}, [node]);
}, [node, hostname]);
return (
<div className="fixed inset-0 flex flex-col bg-black">
@@ -271,9 +258,9 @@ function SSHConsole({
</div>
)}
{ssh && (
{ipn && (
<Ghostty
ssh={ssh}
ipn={ipn}
username={username}
ipAddress={node.ipAddress}
onConnected={() => setConnected(true)}
+41 -54
View File
@@ -1,56 +1,21 @@
const WASM_MODULE_URL = `${__PREFIX__}/hp_ssh.wasm`;
const WASM_HELPER_URL = `${__PREFIX__}/wasm_exec.js`;
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 {
export interface TailnetConfig {
controlURL: string;
preAuthKey: string;
authKey: string;
hostname: string;
onReady: () => void;
onError?: (message: string) => void;
onPanic: (error: 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;
let goHelper: Promise<void> | null = null;
function loadGoHelper(): Promise<void> {
if (typeof globalThis.Go !== "undefined") {
return Promise.resolve();
}
return new Promise((resolve, reject) => {
goHelper ??= new Promise((resolve, reject) => {
const script = document.createElement("script");
script.src = WASM_HELPER_URL;
script.crossOrigin = "anonymous";
@@ -58,25 +23,47 @@ function loadGoHelper(): Promise<void> {
script.onerror = () => reject(new Error("Failed to load Go WASM helper"));
document.head.appendChild(script);
});
return goHelper;
}
/**
* One-shot function that loads the Go WASM binary and returns the SSH factory.
* Automatically loads the Go JS helper if it hasn't been loaded yet.
* Boots the Tailscale WASM node and resolves once it has joined the Tailnet.
* Rejects if the pre-auth key is refused or the Go runtime panics.
*/
export async function loadHeadplaneWASM(): Promise<HeadplaneSSHFactory> {
if (!resolvedFactory) {
await loadGoHelper();
export async function connectTailnet(config: TailnetConfig): Promise<IPN> {
await loadGoHelper();
const go = new Go();
const result = await WebAssembly.instantiateStreaming(fetch(WASM_MODULE_URL), go.importObject);
const go = new Go();
const module = await WebAssembly.instantiateStreaming(fetch(WASM_MODULE_URL), go.importObject);
resolvedFactory = new Promise<HeadplaneSSHFactory>((resolve) => {
globalThis.__hp_ssh_resolve = resolve;
// The Go process parks on a channel forever, so returning means it died.
go.run(module.instance).then(() => config.onPanic("Unexpected shutdown"));
const ipn = newIPN({
controlURL: config.controlURL,
authKey: config.authKey,
hostname: config.hostname,
});
let loginStarted = false;
return new Promise((resolve, reject) => {
ipn.run({
notifyState: (state) => {
if (state === "Running") resolve(ipn);
// The backend parks at NeedsLogin until login starts. With an auth key
// set this consumes it rather than opening an interactive flow.
if (state === "NeedsLogin" && !loginStarted) {
loginStarted = true;
ipn.login();
}
},
notifyNetMap: () => {},
// Only reached when the auth key was refused and the node wants a human.
notifyBrowseToURL: () => reject(new Error("Headscale rejected the pre-auth key")),
notifyPanicRecover: (error) => reject(new Error(error)),
});
go.run(result.instance);
}
return resolvedFactory;
});
}
+116
View File
@@ -0,0 +1,116 @@
// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
/**
* @fileoverview Type definitions for types exported by the wasm_js.go Go
* module.
*
* Vendored from tailscale.com/cmd/tsconnect/src/types/wasm_js.d.ts; see
* cmd/hp_ssh/wasm_js.go for the upstream ref. Local changes live in
* patches/tsconnect-types.patch and are already applied here.
*/
declare global {
function newIPN(config: IPNConfig): IPN;
var Go: {
new (): {
importObject: WebAssembly.Imports;
run(instance: WebAssembly.Instance): Promise<void>;
};
};
interface IPN {
run(callbacks: IPNCallbacks): void;
login(): void;
logout(): void;
ssh(
host: string,
username: string,
termConfig: {
writeFn: (data: string) => void;
writeErrorFn: (err: string) => void;
setReadFn: (readFn: (data: string) => void) => void;
rows: number;
cols: number;
/** Defaults to "xterm" */
termType?: string;
/** Defaults to 5 seconds */
timeoutSeconds?: number;
onConnectionProgress: (message: string) => void;
onConnected: () => void;
onDone: () => void;
},
): IPNSSHSession;
fetch(url: string): Promise<{
status: number;
statusText: string;
text: () => Promise<string>;
}>;
}
interface IPNSSHSession {
resize(rows: number, cols: number): boolean;
close(): boolean;
}
interface IPNStateStorage {
setState(id: string, value: string): void;
getState(id: string): string;
}
type IPNConfig = {
stateStorage?: IPNStateStorage;
authKey?: string;
controlURL?: string;
hostname?: string;
};
type IPNCallbacks = {
notifyState: (state: IPNState) => void;
notifyNetMap: (netMapStr: string) => void;
notifyBrowseToURL: (url: string) => void;
notifyPanicRecover: (err: string) => void;
};
type IPNNetMap = {
self: IPNNetMapSelfNode;
peers: IPNNetMapPeerNode[];
lockedOut: boolean;
};
type IPNNetMapNode = {
name: string;
addresses: string[];
machineKey: string;
nodeKey: string;
};
type IPNNetMapSelfNode = IPNNetMapNode & {
machineStatus: IPNMachineStatus;
};
type IPNNetMapPeerNode = IPNNetMapNode & {
online?: boolean;
tailscaleSSHEnabled: boolean;
};
/** Mirrors values from ipn/backend.go */
type IPNState =
| "NoState"
| "InUseOtherUser"
| "NeedsLogin"
| "NeedsMachineAuth"
| "Stopped"
| "Starting"
| "Running";
/** Mirrors values from MachineStatus in tailcfg.go */
type IPNMachineStatus =
| "MachineUnknown"
| "MachineUnauthorized"
| "MachineAuthorized"
| "MachineInvalid";
}
export {};