mirror of
https://github.com/fosrl/pangolin.git
synced 2026-05-30 03:59:51 +00:00
390 lines
13 KiB
TypeScript
390 lines
13 KiB
TypeScript
"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";
|
|
import { Textarea } from "@/components/ui/textarea";
|
|
|
|
type Target = {
|
|
ip: string;
|
|
port: number;
|
|
authToken: string;
|
|
};
|
|
|
|
type FormState = {
|
|
username: string;
|
|
password: string;
|
|
privateKey: string;
|
|
};
|
|
|
|
export default function SshClient({
|
|
target,
|
|
error
|
|
}: {
|
|
target: Target | null;
|
|
error: string | null;
|
|
}) {
|
|
const STORAGE_KEY = "pangolin_ssh_credentials";
|
|
|
|
const [form, setForm] = useState<FormState>(() => {
|
|
try {
|
|
const saved = localStorage.getItem(STORAGE_KEY);
|
|
if (saved) return JSON.parse(saved) as FormState;
|
|
} catch {
|
|
// ignore
|
|
}
|
|
return { username: "", password: "", privateKey: "" };
|
|
});
|
|
|
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
|
|
|
function handleKeyFile(e: React.ChangeEvent<HTMLInputElement>) {
|
|
const file = e.target.files?.[0];
|
|
if (!file) return;
|
|
const reader = new FileReader();
|
|
reader.onload = (ev) => {
|
|
const text = ev.target?.result;
|
|
if (typeof text === "string") {
|
|
setForm((prev) => ({ ...prev, privateKey: text }));
|
|
}
|
|
};
|
|
reader.readAsText(file);
|
|
// Reset input so the same file can be re-selected if needed.
|
|
e.target.value = "";
|
|
}
|
|
|
|
const [connected, setConnected] = useState(false);
|
|
const [connecting, setConnecting] = useState(false);
|
|
const [connectError, setConnectError] = 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() {
|
|
setConnectError(null);
|
|
setConnecting(true);
|
|
|
|
if (!target) {
|
|
setConnectError("No target specified");
|
|
setConnecting(false);
|
|
return;
|
|
}
|
|
|
|
const proxyAddress = `${window.location.protocol === "https:" ? "wss" : "ws"}://${window.location.host}/gateway/ssh`;
|
|
const url = new URL(proxyAddress);
|
|
url.searchParams.set("host", target.ip ?? "");
|
|
url.searchParams.set("port", String(target.port ?? 22));
|
|
url.searchParams.set("username", form.username);
|
|
url.searchParams.set("authToken", target.authToken ?? "");
|
|
|
|
const ws = new WebSocket(url.toString(), ["ssh"]);
|
|
wsRef.current = ws;
|
|
|
|
ws.onopen = () => {
|
|
// Send credentials as the first frame so the proxy can complete
|
|
// SSH authentication before piping pty data.
|
|
ws.send(
|
|
JSON.stringify({
|
|
type: "auth",
|
|
password: form.password,
|
|
privateKey: form.privateKey
|
|
})
|
|
);
|
|
try {
|
|
localStorage.setItem(STORAGE_KEY, JSON.stringify(form));
|
|
} catch {
|
|
// ignore
|
|
}
|
|
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);
|
|
setConnectError("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);
|
|
}
|
|
|
|
if (error) {
|
|
return (
|
|
<div className="min-h-screen bg-background flex items-center justify-center">
|
|
<p className="text-destructive">{error}</p>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className="min-h-screen bg-background">
|
|
{!connected && (
|
|
<div className="mx-auto max-w-2xl p-6">
|
|
<h1 className="mb-4 text-2xl font-semibold">SSH</h1>
|
|
|
|
<div className="space-y-4">
|
|
<Field label="Username" id="username">
|
|
<Input
|
|
id="username"
|
|
value={form.username}
|
|
onChange={(e) =>
|
|
setForm({
|
|
...form,
|
|
username: e.target.value
|
|
})
|
|
}
|
|
placeholder="root"
|
|
/>
|
|
</Field>
|
|
<Field label="Password" id="password">
|
|
<Input
|
|
id="password"
|
|
type="password"
|
|
value={form.password}
|
|
onChange={(e) =>
|
|
setForm({
|
|
...form,
|
|
password: e.target.value
|
|
})
|
|
}
|
|
placeholder={
|
|
form.privateKey
|
|
? "Optional with key auth"
|
|
: ""
|
|
}
|
|
/>
|
|
</Field>
|
|
|
|
<Field label="Private Key (optional)" id="privateKey">
|
|
<Textarea
|
|
id="privateKey"
|
|
value={form.privateKey}
|
|
onChange={(e) =>
|
|
setForm({
|
|
...form,
|
|
privateKey: e.target.value
|
|
})
|
|
}
|
|
placeholder="Paste your private key here (PEM format)…"
|
|
rows={5}
|
|
className="font-mono text-xs"
|
|
/>
|
|
<div className="mt-1.5 flex items-center gap-2">
|
|
<Button
|
|
type="button"
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={() =>
|
|
fileInputRef.current?.click()
|
|
}
|
|
>
|
|
Upload key file
|
|
</Button>
|
|
{form.privateKey && (
|
|
<button
|
|
type="button"
|
|
className="text-xs text-muted-foreground underline"
|
|
onClick={() =>
|
|
setForm((prev) => ({
|
|
...prev,
|
|
privateKey: ""
|
|
}))
|
|
}
|
|
>
|
|
Clear
|
|
</button>
|
|
)}
|
|
</div>
|
|
<input
|
|
ref={fileInputRef}
|
|
type="file"
|
|
className="hidden"
|
|
accept=".pem,.key,.pub,*"
|
|
onChange={handleKeyFile}
|
|
/>
|
|
</Field>
|
|
|
|
{connectError && (
|
|
<p className="text-destructive text-sm">
|
|
{connectError}
|
|
</p>
|
|
)}
|
|
|
|
<Button
|
|
onClick={connect}
|
|
loading={connecting}
|
|
disabled={
|
|
!form.username ||
|
|
(!form.password && !form.privateKey)
|
|
}
|
|
className="w-full"
|
|
>
|
|
{connecting ? "Connecting..." : "Connect"}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{connected && (
|
|
<div className="flex h-screen flex-col bg-neutral-900">
|
|
<div className="flex flex-wrap items-center gap-2 bg-black p-2 text-white">
|
|
<Button
|
|
size="sm"
|
|
variant="destructive"
|
|
onClick={disconnect}
|
|
>
|
|
Terminate
|
|
</Button>
|
|
</div>
|
|
<div
|
|
ref={terminalRef}
|
|
className="flex-1 overflow-hidden"
|
|
style={{ minHeight: 0 }}
|
|
/>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function Field({
|
|
label,
|
|
id,
|
|
children
|
|
}: {
|
|
label: string;
|
|
id: string;
|
|
children: React.ReactNode;
|
|
}) {
|
|
return (
|
|
<div className="space-y-1.5">
|
|
<Label htmlFor={id}>{label}</Label>
|
|
{children}
|
|
</div>
|
|
);
|
|
}
|