"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"; import { GetBrowserTargetResponse } from "@server/routers/browserGatewayTarget"; import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@app/components/ui/card"; import Link from "next/link"; import { ExternalLink } from "lucide-react"; import { cn } from "@app/lib/cn"; import type { SignSshKeyResponse } from "@server/routers/ssh/types"; type AuthTab = "password" | "privateKey"; type FormState = { username: string; password: string; privateKey: string; }; type ConnectCredentials = { username: string; password?: string; privateKey?: string; certificate?: string; }; export default function SshClient({ target, error, signedKeyData, privateKey: signedPrivateKey }: { target: GetBrowserTargetResponse | null; error: string | null; signedKeyData?: SignSshKeyResponse | null; privateKey?: string | null; }) { const STORAGE_KEY = "pangolin_ssh_credentials"; const [form, setForm] = useState(() => { try { const saved = localStorage.getItem(STORAGE_KEY); if (saved) return JSON.parse(saved) as FormState; } catch { // ignore } return { username: "", password: "", privateKey: "" }; }); const [authTab, setAuthTab] = useState("password"); function handleKeyFile(e: React.ChangeEvent) { 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(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(); }; }, []); // Auto-connect when signed key data is provided (push PAM mode). useEffect(() => { if (signedKeyData && signedPrivateKey && target) { connect({ username: signedKeyData.sshUsername, privateKey: signedPrivateKey, certificate: signedKeyData.certificate }); } // eslint-disable-next-line react-hooks/exhaustive-deps }, []); function connect(override?: ConnectCredentials) { setConnectError(null); setConnecting(true); if (!target) { setConnectError("No target specified"); setConnecting(false); return; } const username = override?.username ?? form.username; const password = override?.password ?? (authTab === "password" ? form.password : ""); const privateKey = override?.privateKey ?? (authTab === "privateKey" ? form.privateKey : ""); const certificate = override?.certificate; const proxyAddress = `${window.location.protocol === "https:" ? "wss" : "ws"}://${window.location.host}/gateway/ssh`; const url = new URL(proxyAddress); url.searchParams.set( "mode", target.authDaemonMode === "native" ? "native" : "proxy" ); if (target.authDaemonMode !== "native") { url.searchParams.set("host", target.ip ?? ""); url.searchParams.set("port", String(target.port ?? 22)); } url.searchParams.set("username", username); url.searchParams.set("authToken", target.authToken ?? ""); const ws = new WebSocket(url.toString(), ["ssh"]); wsRef.current = ws; // Track whether the server has confirmed auth by sending the first // data frame. Until then, errors are shown in the login form. let authConfirmed = false; let authErrorShown = false; ws.onopen = () => { // Send credentials as the first frame so the proxy can complete // SSH authentication before piping pty data. Stay in "connecting" // state until the server responds — this prevents the flash to the // terminal page that would occur if we set connected=true here. ws.send( JSON.stringify({ type: "auth", password, privateKey, certificate }) ); if (!override) { try { localStorage.setItem(STORAGE_KEY, JSON.stringify(form)); } catch { // ignore } } }; 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) { if (!authConfirmed) { authConfirmed = true; setConnecting(false); setConnected(true); } xtermRef.current?.write(msg.data); } else if (msg.type === "error") { if (!authConfirmed) { // Auth-phase error — show in the login form. authErrorShown = true; setConnecting(false); setConnectError( msg.error ?? "Authentication failed" ); } else { xtermRef.current?.writeln( `\r\n\x1b[31mError: ${msg.error}\x1b[0m\r\n` ); } } } catch { if (!authConfirmed) { authConfirmed = true; setConnecting(false); setConnected(true); } xtermRef.current?.write(evt.data); } } else if (evt.data instanceof Blob) { evt.data.text().then((t) => { if (!authConfirmed) { authConfirmed = true; setConnecting(false); setConnected(true); } xtermRef.current?.write(t); }); } }; ws.onerror = () => { setConnecting(false); setConnected(false); setConnectError("WebSocket connection failed"); }; ws.onclose = (evt) => { setConnecting(false); if (authConfirmed) { setConnected(false); xtermRef.current?.writeln( `\r\n\x1b[33mConnection closed (code ${evt.code})\x1b[0m\r\n` ); } // If auth was never confirmed the login form is already visible; // a generic error is shown only when no specific error was received. if (!authConfirmed && !authErrorShown) { setConnectError( "Connection closed before authentication completed" ); } }; } function disconnect() { wsRef.current?.close(); xtermRef.current?.dispose(); xtermRef.current = null; setConnected(false); } // In push mode, show a connecting/connected state without the login form. if (signedKeyData && signedPrivateKey) { return ( <> {!connected && (

{connectError ? connectError : connecting ? "Connecting…" : "Initializing…"}

)} {connected && (
)} ); } if (error) { return (
Powered by{" "} Pangolin
SSH

{error}

); } return ( <> {!connected && (
Powered by{" "} Pangolin
Sign in to SSH Enter credentials to access xxxx {/* Tab row */}
{(["password", "privateKey"] as const).map( (tab) => ( ) )}
{authTab === "password" && (
setForm({ ...form, username: e.target.value }) } placeholder="root" /> setForm({ ...form, password: e.target.value }) } />
)} {authTab === "privateKey" && (

Your private key is not stored or visible to Pangolin. Alternatively, you can use short-lived certificates for seamless authentication using your existing Pangolin identity.{" "} Learn more

setForm({ ...form, username: e.target.value }) } placeholder="root" />