/* * SSH-Terminal-Client: xterm.js <-> /ws/ssh/{host_id}. * * Vollbild: native Browser-Fullscreen-API auf dem Session-Container (Konzept 4.3). * Copy & Paste: xterm.js liefert dies bei SSH bereits nativ ueber die * System-Zwischenablage (Markieren-zum-Kopieren / Strg+Umschalt+V) -- keine * serverseitige Sonderbehandlung noetig, im Gegensatz zu RDP (siehe rdp.js). * * Dateitransfer: eigenes Panel (statt frueherem prompt()-basiertem Upload) * mit Formularen fuer Upload UND Download in beide Richtungen. Der Download * laeuft ueber fetch() + Blob + synthetischen -Link, damit * Fehler inline im Panel angezeigt werden koennen statt die Seite zu * verlassen (siehe GET /ssh/{host_id}/files/download). */ (() => { "use strict"; const shell = document.getElementById("session-shell"); const hostId = shell.dataset.hostId; const statusEl = document.getElementById("status"); const term = new Terminal({ cursorBlink: true, fontFamily: "Menlo, Consolas, monospace", fontSize: 14, theme: { background: "#000000" }, }); const fitAddon = new FitAddon.FitAddon(); term.loadAddon(fitAddon); term.open(document.getElementById("terminal")); fitAddon.fit(); function b64encode(str) { return btoa(unescape(encodeURIComponent(str))); } // Bugfix: NICHT ueber decodeURIComponent(escape(atob(...))) als JS-String // dekodieren. Jedes "output"-Frame entspricht genau einem Chunk aus // process.stdout.read() auf dem Server (app/ssh_proxy/terminal_ws.py) -- // ein Mehrbyte-UTF-8-Zeichen (Umlaute, Box-Drawing-Zeichen, Emoji in // Banner/Prompt/htop etc.) kann dabei GENAU AN DER CHUNK-GRENZE // auseinandergerissen werden. decodeURIComponent() wirft dann einen // "URI malformed"-Fehler fuer diesen einen Chunk (siehe // static/js/admin.js::createSshPlayer fuer die noch schwerwiegendere // Auswirkung bei der Wiedergabe -- dort blieb die gesamte Wiedergabe an // genau dieser Stelle haengen). xterm.js' term.write() nimmt daher direkt // die rohen Bytes als Uint8Array entgegen -- xterm.js ist ein echter // Terminal-Emulator mit eigenem, chunk-uebergreifend korrektem // UTF-8-Parser, genau fuer diesen Fall gebaut. function b64decodeBytes(b64) { const bin = atob(b64); const bytes = new Uint8Array(bin.length); for (let i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i); return bytes; } // Troubleshooting: der Server liefert bei einem Fehlschlag entweder eine // {"type":"error",...}-Nachricht ODER (wenn die Sitzung nie so weit kam, // dass eine Nachricht gesendet werden konnte -- z.B. fehlende // Berechtigung) eine WebSocket-Close-Reason (siehe // app/ssh_proxy/terminal_ws.py::_reject). Beides wird angezeigt statt // eines generischen "Verbindung beendet" ohne jeden Grund. let lastErrorMessage = null; const proto = window.location.protocol === "https:" ? "wss:" : "ws:"; const ws = new WebSocket(`${proto}//${window.location.host}/ws/ssh/${hostId}`); ws.addEventListener("open", () => { statusEl.textContent = "Verbunden"; sendResize(); }); ws.addEventListener("close", (ev) => { let text; if (lastErrorMessage) { text = "Verbindung beendet: " + lastErrorMessage; } else if (ev.reason) { text = `Verbindung beendet (Code ${ev.code}): ${ev.reason}`; } else if (ev.code && ev.code !== 1000) { text = `Verbindung beendet (Code ${ev.code})`; } else { text = "Verbindung beendet"; } statusEl.textContent = text; statusEl.classList.add("error"); console.error("SSH-Sitzung beendet:", { code: ev.code, reason: ev.reason, lastErrorMessage }); }); ws.addEventListener("error", (ev) => { statusEl.textContent = "Verbindungsfehler (WebSocket) -- Details siehe Browser-Konsole"; statusEl.classList.add("error"); console.error("SSH-WebSocket-Fehler:", ev); }); ws.addEventListener("message", (ev) => { const msg = JSON.parse(ev.data); if (msg.type === "output") { term.write(b64decodeBytes(msg.data)); } else if (msg.type === "error") { lastErrorMessage = msg.message; statusEl.textContent = "Fehler: " + msg.message; statusEl.classList.add("error"); } }); term.onData((data) => { if (ws.readyState === WebSocket.OPEN) { ws.send(JSON.stringify({ type: "input", data: b64encode(data) })); } }); function sendResize() { fitAddon.fit(); if (ws.readyState === WebSocket.OPEN) { ws.send(JSON.stringify({ type: "resize", cols: term.cols, rows: term.rows })); } } window.addEventListener("resize", sendResize); document.getElementById("fullscreen-btn").addEventListener("click", () => { if (!document.fullscreenElement) { shell.requestFullscreen().catch(() => {}); } else { document.exitFullscreen(); } }); document.addEventListener("fullscreenchange", sendResize); document.getElementById("exit-btn").addEventListener("click", () => { // Sofortiges sichtbares Feedback, damit erkennbar ist, dass der Klick // ueberhaupt ankam -- unabhaengig davon, ob window.close() gleich // klappt oder der Fallback (siehe unten) erst nach der Wartezeit greift. statusEl.textContent = "Sitzung wird beendet ..."; statusEl.classList.remove("error"); try { if (ws.readyState === WebSocket.OPEN || ws.readyState === WebSocket.CONNECTING) { ws.close(1000, "Sitzung durch Benutzer beendet"); } } catch (_) { /* bereits getrennt */ } window.close(); // window.close() schliesst den Tab nur, wenn er per Skript/Link mit // target="_blank" geoeffnet wurde (siehe dashboard.js) UND noch keine // weitere Navigation darin stattfand -- sonst verweigern Browser das // lautlos, ohne Fehler oder Rueckgabewert. Deshalb IMMER zusaetzlich // nach kurzer Wartezeit zum Dashboard weiterleiten: war das Schliessen // erfolgreich, existiert die Seite dann laengst nicht mehr und der // Timeout laeuft ins Leere; sonst bleibt so ein Weg zurueck. window.setTimeout(() => { window.location.href = "/dashboard"; }, 300); }); // --- Dateitransfer-Panel ------------------------------------------------ const ftOverlay = document.getElementById("ft-overlay"); const ftStatus = document.getElementById("ft-status"); const ftList = document.getElementById("ft-list"); const transfers = []; function openFt() { ftOverlay.classList.remove("hidden"); ftStatus.textContent = ""; } function closeFt() { ftOverlay.classList.add("hidden"); } document.getElementById("filetransfer-btn").addEventListener("click", openFt); document.getElementById("ft-close-btn").addEventListener("click", closeFt); // Klick auf den abgedunkelten Hintergrund (nicht auf das Panel selbst) // schliesst das Overlay ebenfalls -- Standardverhalten fuer solche Panels. ftOverlay.addEventListener("click", (ev) => { if (ev.target === ftOverlay) closeFt(); }); function formatSize(bytes) { if (bytes < 1024) return `${bytes} B`; if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; } function recordTransfer(direction, filename, size, ok, message) { transfers.unshift({ direction, filename, size, ok, message }); if (transfers.length > 20) transfers.length = 20; renderTransfers(); } function renderTransfers() { ftList.innerHTML = ""; if (transfers.length === 0) { const empty = document.createElement("div"); empty.className = "hint"; empty.textContent = "Noch keine Transfers in dieser Sitzung."; ftList.appendChild(empty); return; } for (const t of transfers) { const row = document.createElement("div"); row.className = "ft-list-row"; const dir = document.createElement("span"); dir.className = "badge " + (t.ok ? "ok" : "danger"); dir.textContent = t.direction === "upload" ? "↑ Upload" : "↓ Download"; row.appendChild(dir); const name = document.createElement("span"); name.className = "name"; name.textContent = t.ok ? t.filename : `${t.filename} -- ${t.message}`; row.appendChild(name); if (t.ok) { const size = document.createElement("span"); size.className = "size"; size.textContent = formatSize(t.size); row.appendChild(size); } ftList.appendChild(row); } } renderTransfers(); // Upload const uploadRemotePathInput = document.getElementById("ft-upload-remote-path"); const uploadFileInput = document.getElementById("ft-upload-file-input"); const uploadFilenameLabel = document.getElementById("ft-upload-filename"); document.getElementById("ft-upload-pick-btn").addEventListener("click", () => uploadFileInput.click()); uploadFileInput.addEventListener("change", () => { const file = uploadFileInput.files[0]; uploadFilenameLabel.textContent = file ? file.name : "Keine Datei ausgewaehlt"; if (file && !uploadRemotePathInput.value) { uploadRemotePathInput.value = `/tmp/${file.name}`; } }); document.getElementById("ft-upload-submit-btn").addEventListener("click", async () => { const file = uploadFileInput.files[0]; const remotePath = uploadRemotePathInput.value.trim(); ftStatus.textContent = ""; if (!file) { ftStatus.textContent = "Bitte zuerst eine Datei auswaehlen."; return; } if (!remotePath) { ftStatus.textContent = "Bitte einen Zielpfad angeben."; return; } const formData = new FormData(); formData.append("file", file); ftStatus.textContent = `Lade ${file.name} hoch ...`; try { const res = await fetch( `/ssh/${hostId}/files/upload?remote_path=${encodeURIComponent(remotePath)}`, { method: "POST", credentials: "same-origin", body: formData } ); // Robust gegen Nicht-JSON-Antworten (z.B. ein unbehandelter serverseitiger // Fehler, den Starlette als Klartext "Internal Server Error" statt JSON // ausliefert) -- sonst crasht hier res.json() mit einer verwirrenden // "Unexpected token 'I' ..."-Meldung statt der eigentlichen Fehlerursache // (siehe GET .../files/download, das dasselbe Muster schon nutzt). let data = null; try { data = await res.json(); } catch (_) { /* Antwort war kein JSON */ } if (!res.ok) throw new Error((data && data.detail) || `Upload fehlgeschlagen (HTTP ${res.status})`); ftStatus.textContent = `Upload abgeschlossen (AV: ${data.av_scan_result})`; recordTransfer("upload", file.name, data.size, true, ""); uploadFileInput.value = ""; uploadFilenameLabel.textContent = "Keine Datei ausgewaehlt"; uploadRemotePathInput.value = ""; } catch (err) { ftStatus.textContent = "Upload-Fehler: " + err.message; recordTransfer("upload", file.name, 0, false, err.message); } }); // Download const downloadRemotePathInput = document.getElementById("ft-download-remote-path"); document.getElementById("ft-download-submit-btn").addEventListener("click", async () => { const remotePath = downloadRemotePathInput.value.trim(); ftStatus.textContent = ""; if (!remotePath) { ftStatus.textContent = "Bitte einen Pfad angeben."; return; } const filename = remotePath.split("/").filter(Boolean).pop() || "download"; ftStatus.textContent = `Lade ${filename} herunter ...`; try { const res = await fetch( `/ssh/${hostId}/files/download?remote_path=${encodeURIComponent(remotePath)}`, { method: "GET", credentials: "same-origin" } ); if (!res.ok) { let message = "Download fehlgeschlagen"; try { const data = await res.json(); message = data.detail || message; } catch (_) { /* Antwort war kein JSON */ } throw new Error(message); } const blob = await res.blob(); const url = URL.createObjectURL(blob); const link = document.createElement("a"); link.href = url; link.download = filename; document.body.appendChild(link); link.click(); link.remove(); URL.revokeObjectURL(url); ftStatus.textContent = `Download abgeschlossen: ${filename}`; recordTransfer("download", filename, blob.size, true, ""); downloadRemotePathInput.value = ""; } catch (err) { ftStatus.textContent = "Download-Fehler: " + err.message; recordTransfer("download", filename, 0, false, err.message); } }); })();