mirror of
https://github.com/fosrl/pangolin.git
synced 2026-05-22 16:55:44 +00:00
Add first iteration of ssh proxy
This commit is contained in:
30
package-lock.json
generated
30
package-lock.json
generated
@@ -11,7 +11,7 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@asteasolutions/zod-to-openapi": "8.4.1",
|
"@asteasolutions/zod-to-openapi": "8.4.1",
|
||||||
"@aws-sdk/client-s3": "3.1011.0",
|
"@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",
|
"@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",
|
"@faker-js/faker": "10.3.0",
|
||||||
"@headlessui/react": "2.2.9",
|
"@headlessui/react": "2.2.9",
|
||||||
@@ -46,6 +46,9 @@
|
|||||||
"@tailwindcss/forms": "0.5.11",
|
"@tailwindcss/forms": "0.5.11",
|
||||||
"@tanstack/react-query": "5.90.21",
|
"@tanstack/react-query": "5.90.21",
|
||||||
"@tanstack/react-table": "8.21.3",
|
"@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",
|
"arctic": "3.7.0",
|
||||||
"axios": "1.15.0",
|
"axios": "1.15.0",
|
||||||
"better-sqlite3": "11.9.1",
|
"better-sqlite3": "11.9.1",
|
||||||
@@ -1463,8 +1466,8 @@
|
|||||||
},
|
},
|
||||||
"node_modules/@devolutions/iron-remote-desktop": {
|
"node_modules/@devolutions/iron-remote-desktop": {
|
||||||
"version": "0.0.0",
|
"version": "0.0.0",
|
||||||
"resolved": "https://s3.us-east-1.amazonaws.com/static.pangolin.net/packages/devolutions-iron-remote-desktop-0.0.0.tgz",
|
"resolved": "https://static.pangolin.net/packages/devolutions-iron-remote-desktop-0.0.0.tgz",
|
||||||
"integrity": "sha512-96z7WShjpJJhr4I2RzhXB52GcdmVFMEVvUgoQ0a20n3gATNJ+n2V3W2i8AUeMqVR38uvcyK3e+loY5T050NgQg=="
|
"integrity": "sha512-9o7PkCw9fdvGTPs0hgsUJG10QleGgcdsSCw1ekLpUOlVXtWCuiuPH+0bPDFhLWxqbVA+8pyVhwqdOI+t1T3TNA=="
|
||||||
},
|
},
|
||||||
"node_modules/@devolutions/iron-remote-desktop-rdp": {
|
"node_modules/@devolutions/iron-remote-desktop-rdp": {
|
||||||
"version": "0.0.0",
|
"version": "0.0.0",
|
||||||
@@ -9663,6 +9666,27 @@
|
|||||||
"win32"
|
"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": {
|
"node_modules/accepts": {
|
||||||
"version": "2.0.0",
|
"version": "2.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz",
|
||||||
|
|||||||
@@ -69,6 +69,9 @@
|
|||||||
"@tailwindcss/forms": "0.5.11",
|
"@tailwindcss/forms": "0.5.11",
|
||||||
"@tanstack/react-query": "5.90.21",
|
"@tanstack/react-query": "5.90.21",
|
||||||
"@tanstack/react-table": "8.21.3",
|
"@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",
|
"arctic": "3.7.0",
|
||||||
"axios": "1.15.0",
|
"axios": "1.15.0",
|
||||||
"better-sqlite3": "11.9.1",
|
"better-sqlite3": "11.9.1",
|
||||||
|
|||||||
355
src/app/ssh/SshClient.tsx
Normal file
355
src/app/ssh/SshClient.tsx
Normal file
@@ -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<FormState>({
|
||||||
|
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<string | null>(null);
|
||||||
|
|
||||||
|
const terminalRef = useRef<HTMLDivElement>(null);
|
||||||
|
const xtermRef = useRef<import("@xterm/xterm").Terminal | null>(null);
|
||||||
|
const fitAddonRef = useRef<import("@xterm/addon-fit").FitAddon | null>(
|
||||||
|
null
|
||||||
|
);
|
||||||
|
const wsRef = useRef<WebSocket | null>(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 (
|
||||||
|
<div className="flex flex-col h-screen bg-black text-white p-4 gap-4">
|
||||||
|
<h1 className="text-xl font-semibold text-white">SSH Terminal</h1>
|
||||||
|
|
||||||
|
{!connected && (
|
||||||
|
<div className="bg-neutral-900 rounded-lg p-6 max-w-lg w-full mx-auto flex flex-col gap-4">
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div className="col-span-2 flex flex-col gap-1">
|
||||||
|
<Label
|
||||||
|
htmlFor="gatewayAddress"
|
||||||
|
className="text-neutral-300"
|
||||||
|
>
|
||||||
|
Gateway Address
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id="gatewayAddress"
|
||||||
|
value={form.gatewayAddress}
|
||||||
|
onChange={(e) =>
|
||||||
|
setForm({
|
||||||
|
...form,
|
||||||
|
gatewayAddress: e.target.value
|
||||||
|
})
|
||||||
|
}
|
||||||
|
placeholder="ws://localhost:7171/jet/ssh"
|
||||||
|
className="bg-neutral-800 border-neutral-700 text-white"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<Label
|
||||||
|
htmlFor="hostname"
|
||||||
|
className="text-neutral-300"
|
||||||
|
>
|
||||||
|
Host
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id="hostname"
|
||||||
|
value={form.hostname}
|
||||||
|
onChange={(e) =>
|
||||||
|
setForm({
|
||||||
|
...form,
|
||||||
|
hostname: e.target.value
|
||||||
|
})
|
||||||
|
}
|
||||||
|
placeholder="192.168.1.1"
|
||||||
|
className="bg-neutral-800 border-neutral-700 text-white"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<Label htmlFor="port" className="text-neutral-300">
|
||||||
|
Port
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id="port"
|
||||||
|
value={form.port}
|
||||||
|
onChange={(e) =>
|
||||||
|
setForm({ ...form, port: e.target.value })
|
||||||
|
}
|
||||||
|
placeholder="22"
|
||||||
|
className="bg-neutral-800 border-neutral-700 text-white"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<Label
|
||||||
|
htmlFor="username"
|
||||||
|
className="text-neutral-300"
|
||||||
|
>
|
||||||
|
Username
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id="username"
|
||||||
|
value={form.username}
|
||||||
|
onChange={(e) =>
|
||||||
|
setForm({
|
||||||
|
...form,
|
||||||
|
username: e.target.value
|
||||||
|
})
|
||||||
|
}
|
||||||
|
placeholder="root"
|
||||||
|
className="bg-neutral-800 border-neutral-700 text-white"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<Label
|
||||||
|
htmlFor="password"
|
||||||
|
className="text-neutral-300"
|
||||||
|
>
|
||||||
|
Password
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id="password"
|
||||||
|
type="password"
|
||||||
|
value={form.password}
|
||||||
|
onChange={(e) =>
|
||||||
|
setForm({
|
||||||
|
...form,
|
||||||
|
password: e.target.value
|
||||||
|
})
|
||||||
|
}
|
||||||
|
className="bg-neutral-800 border-neutral-700 text-white"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="col-span-2 flex flex-col gap-1">
|
||||||
|
<Label
|
||||||
|
htmlFor="authToken"
|
||||||
|
className="text-neutral-300"
|
||||||
|
>
|
||||||
|
Auth Token
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id="authToken"
|
||||||
|
value={form.authToken}
|
||||||
|
onChange={(e) =>
|
||||||
|
setForm({
|
||||||
|
...form,
|
||||||
|
authToken: e.target.value
|
||||||
|
})
|
||||||
|
}
|
||||||
|
className="bg-neutral-800 border-neutral-700 text-white"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && <p className="text-red-400 text-sm">{error}</p>}
|
||||||
|
|
||||||
|
<Button
|
||||||
|
onClick={connect}
|
||||||
|
disabled={
|
||||||
|
connecting ||
|
||||||
|
!form.hostname ||
|
||||||
|
!form.username ||
|
||||||
|
!form.authToken
|
||||||
|
}
|
||||||
|
className="w-full"
|
||||||
|
>
|
||||||
|
{connecting ? "Connecting…" : "Connect"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{connected && (
|
||||||
|
<div className="flex flex-col flex-1 gap-2 min-h-0">
|
||||||
|
<div className="flex justify-end">
|
||||||
|
<Button
|
||||||
|
variant="destructive"
|
||||||
|
size="sm"
|
||||||
|
onClick={disconnect}
|
||||||
|
>
|
||||||
|
Disconnect
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
ref={terminalRef}
|
||||||
|
className="flex-1 rounded overflow-hidden"
|
||||||
|
style={{ minHeight: 0 }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
11
src/app/ssh/page.tsx
Normal file
11
src/app/ssh/page.tsx
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
import SshClient from "./SshClient";
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
export const metadata = {
|
||||||
|
title: "SSH Terminal"
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function SshPage() {
|
||||||
|
return <SshClient />;
|
||||||
|
}
|
||||||
3
src/types/css-modules.d.ts
vendored
Normal file
3
src/types/css-modules.d.ts
vendored
Normal file
@@ -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" {}
|
||||||
Reference in New Issue
Block a user