"use client"; import "@xterm/xterm/css/xterm.css"; import { useEffect, useRef, useState } from "react"; import { useForm } from "react-hook-form"; import * as z from "zod"; import { Button } from "@app/components/ui/button"; import { Input } from "@app/components/ui/input"; import { Textarea } from "@app/components/ui/textarea"; import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from "@app/components/ui/form"; import { GetBrowserTargetResponse } from "@server/routers/browserGatewayTarget"; import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@app/components/ui/card"; import Link from "next/link"; import { ExternalLink, Loader2 } from "lucide-react"; import { Alert, AlertDescription } from "@app/components/ui/alert"; import { HorizontalTabs } from "@app/components/HorizontalTabs"; import type { SignSshKeyResponse } from "@server/routers/ssh/types"; import { useTranslations } from "next-intl"; import BrandedAuthSurface from "@app/components/BrandedAuthSurface"; import PoweredByPangolin from "@app/components/PoweredByPangolin"; import AuthPageFooterNotices from "@app/components/AuthPageFooterNotices"; import { loadEncryptedLocalStorage, saveEncryptedLocalStorage } from "@app/lib/secureLocalStorage"; import { createApiClient } from "@app/lib/api"; import { useEnvContext } from "@app/hooks/useEnvContext"; type AuthTab = "password" | "privateKey"; type SshCredentialsForm = { username: string; password: string; privateKey: string; }; type ConnectCredentials = { username: string; password?: string; privateKey?: string; certificate?: string; }; const DEFAULT_SSH_CREDENTIALS: SshCredentialsForm = { username: "", password: "", privateKey: "" }; export default function SshClient({ target, error, signedKeyData, privateKey: signedPrivateKey, primaryColor }: { target: GetBrowserTargetResponse | null; error: string | null; signedKeyData?: SignSshKeyResponse | null; privateKey?: string | null; primaryColor?: string | null; }) { const STORAGE_KEY = "pangolin_ssh_credentials"; const t = useTranslations(); const api = createApiClient(useEnvContext()); const resourceName = target?.name?.trim() || null; const passwordTabSchema = z.object({ username: z.string().min(1, { message: t("usernameRequired") }), password: z.string().min(1, { message: t("passwordRequired") }) }); const privateKeyTabSchema = z.object({ username: z.string().min(1, { message: t("usernameRequired") }), privateKey: z.string().min(1, { message: t("sshPrivateKeyRequired") }) }); const form = useForm({ defaultValues: DEFAULT_SSH_CREDENTIALS }); useEffect(() => { let cancelled = false; void loadEncryptedLocalStorage( STORAGE_KEY, target?.authToken ).then((saved) => { if (cancelled || !saved) return; form.reset({ ...DEFAULT_SSH_CREDENTIALS, ...saved }); }); return () => { cancelled = true; }; }, [form, target?.authToken]); 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") { form.setValue("privateKey", text, { shouldDirty: true }); } }; reader.readAsText(file); e.target.value = ""; } const [connected, setConnected] = useState(false); const [connecting, setConnecting] = useState(false); const [connectError, setConnectError] = useState(null); const [sessionClosedCode, setSessionClosedCode] = 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; terminal.onData((data) => { if (wsRef.current?.readyState === WebSocket.OPEN) { wsRef.current.send(JSON.stringify({ type: "data", data })); } }); terminal.onResize(({ cols, rows }) => { if (wsRef.current?.readyState === WebSocket.OPEN) { wsRef.current.send( JSON.stringify({ type: "resize", cols, rows }) ); } }); 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]); useEffect(() => { const onResize = () => fitAddonRef.current?.fit(); window.addEventListener("resize", onResize); return () => window.removeEventListener("resize", onResize); }, []); useEffect(() => { return () => { wsRef.current?.close(); xtermRef.current?.dispose(); }; }, []); useEffect(() => { if (signedKeyData && signedPrivateKey && target) { connect({ username: signedKeyData.sshUsername, privateKey: signedPrivateKey, certificate: signedKeyData.certificate }); } }, []); function connect( override?: ConnectCredentials, authMethod: AuthTab = "password" ) { setConnecting(true); setSessionClosedCode(null); setConnectError(null); if (!target) { setConnectError(t("sshErrorNoTarget")); setConnecting(false); return; } const values = form.getValues(); const username = override?.username ?? values.username; const password = override?.password ?? (authMethod === "password" ? values.password : ""); const privateKey = override?.privateKey ?? (authMethod === "privateKey" ? values.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; let authConfirmed = false; let authErrorShown = false; let socketOpened = false; let auditLogged = false; const logAudit = (action: boolean) => { if (auditLogged || !target) return; auditLogged = true; void api.post(`/org/${target.orgId}/logs/access/attempt`, { resourceId: target.resourceId, action, type: "ssh" }); }; ws.onopen = () => { socketOpened = true; ws.send( JSON.stringify({ type: "auth", password, privateKey, certificate }) ); if (!override) { void saveEncryptedLocalStorage( STORAGE_KEY, form.getValues(), target.authToken ); } }; 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; logAudit(true); setConnecting(false); setConnected(true); } xtermRef.current?.write(msg.data); } else if (msg.type === "error") { if (!authConfirmed) { authErrorShown = true; logAudit(false); setConnecting(false); setConnectError( msg.error ?? t("sshErrorAuthFailed") ); } else { xtermRef.current?.writeln( `\r\n\x1b[31m${t("sshTerminalError", { error: 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((text) => { if (!authConfirmed) { authConfirmed = true; logAudit(true); setConnecting(false); setConnected(true); } xtermRef.current?.write(text); }); } }; ws.onerror = () => { logAudit(false); setConnecting(false); setConnected(false); setConnectError(t("sshErrorWebSocket")); }; ws.onclose = (evt) => { wsRef.current = null; setConnecting(false); const isCleanClose = evt.wasClean || evt.code === 1000; if (isCleanClose && (authConfirmed || socketOpened)) { xtermRef.current?.dispose(); xtermRef.current = null; setConnected(false); setSessionClosedCode(evt.code); return; } if (authConfirmed) { setConnected(false); xtermRef.current?.writeln( `\r\n\x1b[33m${t("sshConnectionClosedCode", { code: evt.code })}\x1b[0m\r\n` ); } if (!authConfirmed && !authErrorShown) { logAudit(false); setConnectError(t("sshErrorConnectionClosed")); } }; } function disconnect() { wsRef.current?.close(); xtermRef.current?.dispose(); xtermRef.current = null; setConnected(false); } function applyTabSchemaErrors( schema: z.ZodObject, values: SshCredentialsForm ) { form.clearErrors(); const result = schema.safeParse(values); if (result.success) return true; for (const issue of result.error.issues) { const field = issue.path[0]; if (typeof field === "string") { form.setError(field as keyof SshCredentialsForm, { message: issue.message }); } } return false; } function onPasswordSubmit(e: React.FormEvent) { e.preventDefault(); setConnectError(null); const values = form.getValues(); if (!applyTabSchemaErrors(passwordTabSchema, values)) return; connect(undefined, "password"); } function onPrivateKeySubmit(e: React.FormEvent) { e.preventDefault(); setConnectError(null); const values = form.getValues(); if (!applyTabSchemaErrors(privateKeyTabSchema, values)) return; connect(undefined, "privateKey"); } if (signedKeyData && signedPrivateKey) { return ( <> {!connected && (
{t("sshTitle")} {t("sshConnectingDescription")} {!connectError && (
{connecting ? t("sshConnecting") : t("sshInitializing")}
)} {connectError && ( {connectError} )}
)} {connected && (
)} ); } if (error) { return ( {t("sshTitle")} {error} ); } if (sessionClosedCode !== null) { return ( {t("sshTitle")} {t("sshConnectionClosedCode", { code: sessionClosedCode })} This session has ended. You can close this tab now. ); } return ( <> {!connected && ( {t("sshSignInTitle")} {resourceName ? `${t("sshSignInDescription")} (${resourceName})` : t("sshSignInDescription")}
( {t("username")} )} /> ( {t("password")} )} />
{connectError && ( {connectError} )}

{t("sshPrivateKeyDisclaimer")}{" "} {t("sshLearnMore")}

( {t("username")} )} /> ( {t( "sshPrivateKeyField" )}