317 lines
12 KiB
JavaScript
317 lines
12 KiB
JavaScript
/*
|
|
* 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 <a download>-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);
|
|
}
|
|
});
|
|
})();
|