mirror of
https://github.com/tale/headplane.git
synced 2026-08-29 16:37:10 +00:00
feat(ssh): bump restty and follow upstream tailscale ssh
This commit is contained in:
@@ -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
@@ -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)}
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
}
|
||||
|
||||
Vendored
+116
@@ -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 {};
|
||||
Reference in New Issue
Block a user