From 436996a43d53b9a6b4217dd32b6ca7f848b6f30f Mon Sep 17 00:00:00 2001 From: Owen Date: Mon, 11 May 2026 16:53:03 -0700 Subject: [PATCH] Add first iteration of ssh proxy --- package-lock.json | 30 +++- package.json | 3 + src/app/ssh/SshClient.tsx | 355 +++++++++++++++++++++++++++++++++++++ src/app/ssh/page.tsx | 11 ++ src/types/css-modules.d.ts | 3 + 5 files changed, 399 insertions(+), 3 deletions(-) create mode 100644 src/app/ssh/SshClient.tsx create mode 100644 src/app/ssh/page.tsx create mode 100644 src/types/css-modules.d.ts diff --git a/package-lock.json b/package-lock.json index 463bae493..f9678e7b8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,7 +11,7 @@ "dependencies": { "@asteasolutions/zod-to-openapi": "8.4.1", "@aws-sdk/client-s3": "3.1011.0", - "@devolutions/iron-remote-desktop": "https://s3.us-east-1.amazonaws.com/static.pangolin.net/packages/devolutions-iron-remote-desktop-0.0.0.tgz", + "@devolutions/iron-remote-desktop": "https://static.pangolin.net/packages/devolutions-iron-remote-desktop-0.0.0.tgz", "@devolutions/iron-remote-desktop-rdp": "https://static.pangolin.net/packages/devolutions-iron-remote-desktop-rdp-0.0.0.tgz", "@faker-js/faker": "10.3.0", "@headlessui/react": "2.2.9", @@ -46,6 +46,9 @@ "@tailwindcss/forms": "0.5.11", "@tanstack/react-query": "5.90.21", "@tanstack/react-table": "8.21.3", + "@xterm/addon-fit": "^0.11.0", + "@xterm/addon-web-links": "^0.12.0", + "@xterm/xterm": "^6.0.0", "arctic": "3.7.0", "axios": "1.15.0", "better-sqlite3": "11.9.1", @@ -1463,8 +1466,8 @@ }, "node_modules/@devolutions/iron-remote-desktop": { "version": "0.0.0", - "resolved": "https://s3.us-east-1.amazonaws.com/static.pangolin.net/packages/devolutions-iron-remote-desktop-0.0.0.tgz", - "integrity": "sha512-96z7WShjpJJhr4I2RzhXB52GcdmVFMEVvUgoQ0a20n3gATNJ+n2V3W2i8AUeMqVR38uvcyK3e+loY5T050NgQg==" + "resolved": "https://static.pangolin.net/packages/devolutions-iron-remote-desktop-0.0.0.tgz", + "integrity": "sha512-9o7PkCw9fdvGTPs0hgsUJG10QleGgcdsSCw1ekLpUOlVXtWCuiuPH+0bPDFhLWxqbVA+8pyVhwqdOI+t1T3TNA==" }, "node_modules/@devolutions/iron-remote-desktop-rdp": { "version": "0.0.0", @@ -9663,6 +9666,27 @@ "win32" ] }, + "node_modules/@xterm/addon-fit": { + "version": "0.11.0", + "resolved": "https://registry.npmjs.org/@xterm/addon-fit/-/addon-fit-0.11.0.tgz", + "integrity": "sha512-jYcgT6xtVYhnhgxh3QgYDnnNMYTcf8ElbxxFzX0IZo+vabQqSPAjC3c1wJrKB5E19VwQei89QCiZZP86DCPF7g==", + "license": "MIT" + }, + "node_modules/@xterm/addon-web-links": { + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/@xterm/addon-web-links/-/addon-web-links-0.12.0.tgz", + "integrity": "sha512-4Smom3RPyVp7ZMYOYDoC/9eGJJJqYhnPLGGqJ6wOBfB8VxPViJNSKdgRYb8NpaM6YSelEKbA2SStD7lGyqaobw==", + "license": "MIT" + }, + "node_modules/@xterm/xterm": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/@xterm/xterm/-/xterm-6.0.0.tgz", + "integrity": "sha512-TQwDdQGtwwDt+2cgKDLn0IRaSxYu1tSUjgKarSDkUM0ZNiSRXFpjxEsvc/Zgc5kq5omJ+V0a8/kIM2WD3sMOYg==", + "license": "MIT", + "workspaces": [ + "addons/*" + ] + }, "node_modules/accepts": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", diff --git a/package.json b/package.json index 7ed8f53fa..33e54c3d0 100644 --- a/package.json +++ b/package.json @@ -69,6 +69,9 @@ "@tailwindcss/forms": "0.5.11", "@tanstack/react-query": "5.90.21", "@tanstack/react-table": "8.21.3", + "@xterm/addon-fit": "^0.11.0", + "@xterm/addon-web-links": "^0.12.0", + "@xterm/xterm": "^6.0.0", "arctic": "3.7.0", "axios": "1.15.0", "better-sqlite3": "11.9.1", diff --git a/src/app/ssh/SshClient.tsx b/src/app/ssh/SshClient.tsx new file mode 100644 index 000000000..1c0cf73d9 --- /dev/null +++ b/src/app/ssh/SshClient.tsx @@ -0,0 +1,355 @@ +"use client"; + +import "@xterm/xterm/css/xterm.css"; +import { useEffect, useRef, useState } from "react"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; + +type FormState = { + gatewayAddress: string; + hostname: string; + port: string; + username: string; + password: string; + authToken: string; +}; + +export default function SshClient() { + const [form, setForm] = useState({ + gatewayAddress: "ws://localhost:7171/jet/ssh", + hostname: "", + port: "22", + username: "", + password: "", + authToken: "abc123" + }); + + const [connected, setConnected] = useState(false); + const [connecting, setConnecting] = useState(false); + const [error, setError] = useState(null); + + const terminalRef = useRef(null); + const xtermRef = useRef(null); + const fitAddonRef = useRef( + null + ); + const wsRef = useRef(null); + + // Mount the terminal div once connected. + useEffect(() => { + if (!connected || !terminalRef.current) return; + + let cancelled = false; + + (async () => { + const [{ Terminal }, { FitAddon }, { WebLinksAddon }] = + await Promise.all([ + import("@xterm/xterm"), + import("@xterm/addon-fit"), + import("@xterm/addon-web-links") + ]); + if (cancelled || !terminalRef.current) return; + + const terminal = new Terminal({ + cursorBlink: true, + fontSize: 14, + fontFamily: "Menlo, Monaco, 'Courier New', monospace", + theme: { + background: "#0d0d0d", + foreground: "#f0f0f0" + }, + scrollback: 5000 + }); + + const fitAddon = new FitAddon(); + const webLinksAddon = new WebLinksAddon(); + terminal.loadAddon(fitAddon); + terminal.loadAddon(webLinksAddon); + + terminal.open(terminalRef.current); + fitAddon.fit(); + + xtermRef.current = terminal; + fitAddonRef.current = fitAddon; + + // Send user keystrokes to the WebSocket. + terminal.onData((data) => { + if (wsRef.current?.readyState === WebSocket.OPEN) { + wsRef.current.send(JSON.stringify({ type: "data", data })); + } + }); + + // Send resize events. + terminal.onResize(({ cols, rows }) => { + if (wsRef.current?.readyState === WebSocket.OPEN) { + wsRef.current.send( + JSON.stringify({ type: "resize", cols, rows }) + ); + } + }); + + // Send the initial size once the terminal is rendered. + const { cols, rows } = terminal; + if (wsRef.current?.readyState === WebSocket.OPEN) { + wsRef.current.send( + JSON.stringify({ type: "resize", cols, rows }) + ); + } + })().catch(console.error); + + return () => { + cancelled = true; + }; + }, [connected]); + + // Refit terminal when the window resizes. + useEffect(() => { + const onResize = () => fitAddonRef.current?.fit(); + window.addEventListener("resize", onResize); + return () => window.removeEventListener("resize", onResize); + }, []); + + // Cleanup on unmount. + useEffect(() => { + return () => { + wsRef.current?.close(); + xtermRef.current?.dispose(); + }; + }, []); + + function connect() { + setError(null); + setConnecting(true); + + const url = new URL(form.gatewayAddress); + // Pass connection parameters as query params so the proxy can route + // before any application-level framing is needed. + url.searchParams.set("host", form.hostname); + url.searchParams.set("port", form.port); + url.searchParams.set("username", form.username); + // Auth token is sent as a query param; the proxy validates it before + // forwarding any data. + url.searchParams.set("authToken", form.authToken); + + const ws = new WebSocket(url.toString(), ["ssh"]); + wsRef.current = ws; + + ws.onopen = () => { + // Send the password (or empty string) as the first frame so the + // proxy can complete SSH authentication before piping pty data. + ws.send(JSON.stringify({ type: "auth", password: form.password })); + setConnecting(false); + setConnected(true); + }; + + ws.onmessage = (evt) => { + if (typeof evt.data === "string") { + try { + const msg = JSON.parse(evt.data as string) as { + type: string; + data?: string; + error?: string; + }; + if (msg.type === "data" && msg.data) { + xtermRef.current?.write(msg.data); + } else if (msg.type === "error") { + xtermRef.current?.writeln( + `\r\n\x1b[31mError: ${msg.error}\x1b[0m\r\n` + ); + } + } catch { + xtermRef.current?.write(evt.data); + } + } else if (evt.data instanceof Blob) { + evt.data.text().then((t) => xtermRef.current?.write(t)); + } + }; + + ws.onerror = () => { + setConnecting(false); + setConnected(false); + setError("WebSocket connection failed"); + }; + + ws.onclose = (evt) => { + setConnecting(false); + setConnected(false); + xtermRef.current?.writeln( + `\r\n\x1b[33mConnection closed (code ${evt.code})\x1b[0m\r\n` + ); + }; + } + + function disconnect() { + wsRef.current?.close(); + xtermRef.current?.dispose(); + xtermRef.current = null; + setConnected(false); + } + + return ( +
+

SSH Terminal

+ + {!connected && ( +
+
+
+ + + setForm({ + ...form, + gatewayAddress: e.target.value + }) + } + placeholder="ws://localhost:7171/jet/ssh" + className="bg-neutral-800 border-neutral-700 text-white" + /> +
+ +
+ + + setForm({ + ...form, + hostname: e.target.value + }) + } + placeholder="192.168.1.1" + className="bg-neutral-800 border-neutral-700 text-white" + /> +
+ +
+ + + setForm({ ...form, port: e.target.value }) + } + placeholder="22" + className="bg-neutral-800 border-neutral-700 text-white" + /> +
+ +
+ + + setForm({ + ...form, + username: e.target.value + }) + } + placeholder="root" + className="bg-neutral-800 border-neutral-700 text-white" + /> +
+ +
+ + + setForm({ + ...form, + password: e.target.value + }) + } + className="bg-neutral-800 border-neutral-700 text-white" + /> +
+ +
+ + + setForm({ + ...form, + authToken: e.target.value + }) + } + className="bg-neutral-800 border-neutral-700 text-white" + /> +
+
+ + {error &&

{error}

} + + +
+ )} + + {connected && ( +
+
+ +
+
+
+ )} +
+ ); +} diff --git a/src/app/ssh/page.tsx b/src/app/ssh/page.tsx new file mode 100644 index 000000000..62159f301 --- /dev/null +++ b/src/app/ssh/page.tsx @@ -0,0 +1,11 @@ +import SshClient from "./SshClient"; + +export const dynamic = "force-dynamic"; + +export const metadata = { + title: "SSH Terminal" +}; + +export default function SshPage() { + return ; +} diff --git a/src/types/css-modules.d.ts b/src/types/css-modules.d.ts new file mode 100644 index 000000000..0857acac4 --- /dev/null +++ b/src/types/css-modules.d.ts @@ -0,0 +1,3 @@ +// Allow importing plain CSS files as side-effect imports (e.g. xterm.css). +declare module "*.css" {} +declare module "@xterm/xterm/css/xterm.css" {}