/* * SSH-Terminal-Baustein: xterm.js <-> /ws/ssh/{host_id}. * * F1 (Umsetzungsauftrag_Sonnet5.md Teil F.3.1): vorher ein Dokument-Singleton * (ein Skript pro Seite, feste Element-IDs). Jetzt eine Klasse * (SshTerminalSession) mit genau einer Instanz pro Sitzung: eigenes, * dynamisch gebautes DOM-Fragment (this.element), connect(), dispose(), * setActive(bool) und ein Statusrueckruf (options.onStatusChange) fuer eine * kuenftige Seitenleiste (Teil F.3: /workspace, noch nicht Teil dieser * Stufe). Die eigenstaendige Seite /terminal/{host_id} (templates/ * terminal.html) ist ab jetzt nur noch ein duenner Rahmen, der GENAU EINE * Instanz erzeugt -- keine zweite Implementierung (F.2, letzter Absatz). * * Vollbild: native Browser-Fullscreen-API auf dem Instanz-Wurzelelement * (Konzept 4.3). * Copy & Paste: xterm.js liefert Markieren-zum-Kopieren + Strg+Umschalt+V * bereits nativ. Aufgabe #11 (vom Nutzer nachtraeglich hinzugefuegt, siehe * FORTSETZUNG_Aufgabe11.md) ergaenzt zwei konkret gewuenschte Wege: * `copyOnSelect: true` kopiert eine Markierung sofort in die * System-Zwischenablage (kein Strg+C/Cmd+C mehr noetig -- Strg+C bleibt * ohne Markierung weiterhin SIGINT, xterm.js' Standardverhalten). Rechtsklick * fuegt ein: zuerst wird direkt versucht, ueber die Clipboard-API einzufuegen * (funktioniert in den meisten Chromium-Browsern, da ein Rechtsklick eine * "transiente Aktivierung" ist); schlaegt das fehl (Firefox/Safari ohne * Berechtigung, unsicherer Kontext), erscheint ersatzweise ein winziges * Kontextmenue mit einem "Einfuegen"-Eintrag -- der Klick darauf ist selbst * eine Benutzergeste und hat dadurch nochmal bessere Chancen auf die * Berechtigung (siehe _wireClipboard() unten). Keine serverseitige * Sonderbehandlung noetig, im Gegensatz zu RDP (siehe rdp.js). * Markieren mit gedrueckter Umschalttaste: xterm.js reicht Mausklicks an ein * Vollbild-Zielprogramm mit eigener Maussteuerung (tmux, vim, htop, mc, ...) * weiter, sobald dieses per ANSI-Escape-Sequenz Maus-Tracking anfordert -- * dann faengt ein normaler Linksklick+Ziehen KEINE lokale Textmarkierung * mehr ein (Standardverhalten jedes echten Terminals, kein Bug). Umschalt * (Shift)+Ziehen erzwingt die lokale Markierung trotzdem, xterm.js * unterstuetzt das nativ, ohne Zusatzcode. Als `title`-Tooltip auf * `.session-terminal` dokumentiert (siehe _buildElement()), da das sonst * leicht wie ein kaputtes Kopieren aussieht. * Farbschema: per ${themeOptions} Verbinde ...
`; return wrapper; } _cacheRefs() { const el = this.element; this.shellEl = el.querySelector(".session-shell"); this.statusEl = el.querySelector(".status"); this.terminalContainer = el.querySelector(".session-terminal"); this.fullscreenBtn = el.querySelector(".fullscreen-btn"); this.filetransferBtn = el.querySelector(".filetransfer-btn"); this.themeSelect = el.querySelector(".theme-select"); this.exitBtn = el.querySelector(".exit-btn"); this.ftOverlay = el.querySelector(".ft-overlay"); this.ftStatus = el.querySelector(".ft-status"); this.ftList = el.querySelector(".ft-list"); this.ftCloseBtn = el.querySelector(".ft-close-btn"); this.uploadRemotePathInput = el.querySelector(".ft-upload-remote-path"); this.uploadFileInput = el.querySelector(".ft-upload-file-input"); this.uploadFilenameLabel = el.querySelector(".ft-upload-filename"); this.uploadPickBtn = el.querySelector(".ft-upload-pick-btn"); this.uploadSubmitBtn = el.querySelector(".ft-upload-submit-btn"); this.downloadRemotePathInput = el.querySelector(".ft-download-remote-path"); this.downloadSubmitBtn = el.querySelector(".ft-download-submit-btn"); } _setStatus(text, isError) { this.statusEl.textContent = text; this.statusEl.classList.toggle("error", !!isError); if (this.options.onStatusChange) { try { this.options.onStatusChange(text, !!isError); } catch (_) { /* Aufrufer-Fehler duerfen die Sitzung nicht stoeren */ } } } // --- Terminal/WebSocket -------------------------------------------- _setupTerminal() { this._themeName = loadStoredThemeName(); this.term = new Terminal({ cursorBlink: true, fontFamily: "Menlo, Consolas, monospace", fontSize: 14, theme: TERMINAL_THEMES[this._themeName].theme, // Aufgabe #11: eine Markierung wird sofort in die System- // Zwischenablage kopiert -- kein zusaetzliches Strg+C/Cmd+C noetig. // Strg+C OHNE Markierung bleibt unveraendert SIGINT (xterm.js' // Standardverhalten, wird von copyOnSelect nicht beruehrt). copyOnSelect: true, }); this.fitAddon = new FitAddon.FitAddon(); this.term.loadAddon(this.fitAddon); this.term.open(this.terminalContainer); if (this.themeSelect) this.themeSelect.value = this._themeName; this.term.onData((data) => { if (this.ws && this.ws.readyState === WebSocket.OPEN) { this.ws.send(JSON.stringify({ type: "input", data: b64encode(data) })); } }); } /** Verbindungsaufbau -- getrennt vom Konstruktor, damit eine kuenftige * Seitenleiste (Teil F.3) eine Kachel erst anzeigen und DANACH verbinden * kann (z.B. um zuerst die Ressourcen-/Obergrenzen-Pruefung serverseitig * abzuwarten). */ connect() { if (this.ws) return; // bereits verbunden/am Verbinden const proto = window.location.protocol === "https:" ? "wss:" : "ws:"; this.ws = new WebSocket(`${proto}//${window.location.host}/ws/ssh/${this.hostId}`); this.ws.addEventListener("open", () => { this._setStatus("Verbunden", false); this.sendResize(); // F5-Nachtrag (Umsetzungsauftrag Teil F.3.4): E10 (terminal_ws.py) // trennt eine Sitzung nach IDLE_TIMEOUT_SECONDS (Standard 15 Min.) // ohne JEDE Ein-/Ausgabe. Vor F4 war das unproblematisch -- es gab // immer nur eine Sitzung, entweder aktiv genutzt oder der Benutzer // war ohnehin weg. Seit mehrere Sitzungen still im Hintergrund // liegen koennen (F.3.4: "duerfen nicht sterben"), reicht ein // wirklich untaetiger Hintergrund-Prompt (kein tail -f, kein Build) // aus, um das F.6-Abnahmekriterium ("nach 30 Minuten alle vier noch // verbunden") zu verfehlen. Ein periodischer, inhaltsloser Ping // haelt jede offene Sitzung am Leben -- der Server zaehlt JEDE // empfangene Nachricht als Aktivitaet (terminal_ws.py, noch vor der // type-Weiche), ein unbekannter type wird danach folgenlos // ignoriert. Laeuft unabhaengig von _active, auch im Hintergrund. this._pingTimer = window.setInterval(() => { if (this.ws && this.ws.readyState === WebSocket.OPEN) { this.ws.send(JSON.stringify({ type: "ping" })); } }, 240000); // 4 Minuten, deutlich unter IDLE_TIMEOUT_SECONDS (Standard 900s) }); this.ws.addEventListener("close", (ev) => { if (this._pingTimer) { window.clearInterval(this._pingTimer); this._pingTimer = null; } let text; if (this._lastErrorMessage) { text = "Verbindung beendet: " + this._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"; } this._setStatus(text, true); console.error("SSH-Sitzung beendet:", { code: ev.code, reason: ev.reason, lastErrorMessage: this._lastErrorMessage }); }); this.ws.addEventListener("error", (ev) => { this._setStatus("Verbindungsfehler (WebSocket) -- Details siehe Browser-Konsole", true); console.error("SSH-WebSocket-Fehler:", ev); }); this.ws.addEventListener("message", (ev) => { const msg = JSON.parse(ev.data); if (msg.type === "output") { this.term.write(b64decodeBytes(msg.data)); } else if (msg.type === "error") { this._lastErrorMessage = msg.message; this._setStatus("Fehler: " + msg.message, true); } else if (msg.type === "warning") { // E11 (Umsetzungsauftrag Teil E): Vorwarnung vor dem automatischen // Trennen bei Erreichen der maximalen Sitzungsdauer. Nicht // destruktiv -- der Statustext wird nach kurzer Zeit wieder auf // den vorherigen Wert zurueckgesetzt, damit er nicht dauerhaft // "Verbunden" verdraengt. const previousStatus = this.statusEl.textContent; const previousWasError = this.statusEl.classList.contains("error"); this.statusEl.textContent = "Hinweis: " + msg.message; this.statusEl.classList.add("warning"); window.setTimeout(() => { if (this._disposed) return; this.statusEl.textContent = previousStatus; this.statusEl.classList.remove("warning"); if (!previousWasError) this.statusEl.classList.remove("error"); }, 10000); } }); // F4 (Umsetzungsauftrag_Sonnet5.md Teil F.3.3): mehrere Instanzen // koennen jetzt gleichzeitig existieren (Arbeitsflaeche /workspace) -- // eine ausgeblendete Instanz hat Groesse 0 (display:none); fitAddon.fit() // wuerde dort mit Cols/Rows nahe 0 enden. Sichtbarwerden holt den Refit // ueber setActive(true) -> sendResize() ohnehin nach. this._onWindowResize = () => { if (this._active) this.sendResize(); }; window.addEventListener("resize", this._onWindowResize); document.addEventListener("fullscreenchange", this._onWindowResize); } sendResize() { this.fitAddon.fit(); if (this.ws && this.ws.readyState === WebSocket.OPEN) { this.ws.send(JSON.stringify({ type: "resize", cols: this.term.cols, rows: this.term.rows })); } } /** Wird von einer kuenftigen Seitenleiste (Teil F.3/F.4) beim Umschalten * aufgerufen. Bei genau einer Instanz (heutiger Stand) ohne sichtbaren * Effekt ausser einem Refit+Fokus. */ setActive(active) { this._active = active; if (active) { this.sendResize(); this.term.focus(); } else { this._closePasteMenu(); } } // --- Toolbar -------------------------------------------------------- _wireToolbar() { this.fullscreenBtn.addEventListener("click", () => { if (!document.fullscreenElement) { this.shellEl.requestFullscreen().catch(() => {}); } else { document.exitFullscreen(); } }); this.themeSelect.addEventListener("change", () => { const name = TERMINAL_THEMES[this.themeSelect.value] ? this.themeSelect.value : DEFAULT_THEME_NAME; this._themeName = name; this.term.options.theme = TERMINAL_THEMES[name].theme; storeThemeName(name); this.term.focus(); }); this.exitBtn.addEventListener("click", () => { // Sofortiges sichtbares Feedback, damit erkennbar ist, dass der Klick // ueberhaupt ankam -- unabhaengig davon, was der Aufrufer danach tut // (Tab schliessen bei /terminal/{id}, Kachel entfernen bei /workspace). this._setStatus("Sitzung wird beendet ...", false); this.dispose(); if (this.options.onExitRequested) this.options.onExitRequested(); }); } // --- Zwischenablage (Aufgabe #11) -------------------------------------- // // Kopieren laeuft ueber copyOnSelect (siehe _setupTerminal()) -- hier nur // Einfuegen per Rechtsklick. Zwei Stufen, wie im Datei-Kopfkommentar // beschrieben: direkter Versuch, dann ein winziges Kontextmenue als // Ruecksicherung, wenn der Browser die Berechtigung ohne explizite // Benutzergeste verweigert. _wireClipboard() { this.terminalContainer.addEventListener("contextmenu", (ev) => { ev.preventDefault(); this._attemptDirectPaste(ev.clientX, ev.clientY); }); } async _attemptDirectPaste(x, y) { if (!navigator.clipboard || !navigator.clipboard.readText) { this._showPasteMenu(x, y); return; } try { const text = await navigator.clipboard.readText(); this.term.paste(text); } catch (_) { // Kein Zugriff ohne eine Benutzergeste, die NACH diesem Handler // beginnt (z.B. Firefox/Safari) -- Ruecksicherung unten, deren // Klick selbst wieder eine frische Geste ist. this._showPasteMenu(x, y); } } _closePasteMenu() { if (this._pasteMenuEl) { this._pasteMenuEl.remove(); this._pasteMenuEl = null; } if (this._pasteMenuCleanup) { this._pasteMenuCleanup(); this._pasteMenuCleanup = null; } } _showPasteMenu(x, y) { this._closePasteMenu(); // CSP (style-src 'self', kein 'unsafe-inline', siehe app.css- // Kopfkommentar zu .hidden) verbietet JS-gesetzte element.style- // Aenderungen genauso wie style="..."-Attribute -- daher KEINE // pixelgenaue Positionierung ueber this.menu.style.left/top, sondern // vier feste CSS-Klassen fuer das Viertel von .session-shell, in dem // rechtsgeklickt wurde (siehe app.css .term-paste-menu-*). const rect = this.shellEl.getBoundingClientRect(); const isRight = x > rect.left + rect.width / 2; const isBottom = y > rect.top + rect.height / 2; const menu = document.createElement("div"); menu.className = "term-paste-menu " + (isBottom ? "term-paste-menu-bottom" : "term-paste-menu-top") + " " + (isRight ? "term-paste-menu-right" : "term-paste-menu-left"); const pasteBtn = document.createElement("button"); pasteBtn.type = "button"; pasteBtn.textContent = "Einfuegen"; pasteBtn.addEventListener("click", async () => { this._closePasteMenu(); try { const text = await navigator.clipboard.readText(); this.term.paste(text); } catch (err) { console.error("Einfuegen aus der Zwischenablage nicht moeglich:", err); this._setStatus( "Einfuegen nicht moeglich -- Browser verweigert Zwischenablage-Zugriff. Bitte Strg+Umschalt+V verwenden.", true ); } this.term.focus(); }); menu.appendChild(pasteBtn); // Als Kind von .session-shell (nicht .session-terminal) angehaengt -- // dort mischt sich nichts mit xterm.js' eigenem DOM-Teilbaum, das // dieses Element seit term.open() exklusiv verwaltet. this.shellEl.appendChild(menu); this._pasteMenuEl = menu; // Schliessen bei Klick daneben, Escape oder Scrollen -- Standard- // verhalten fuer ein solches Mini-Kontextmenue (siehe auch // workspace.js fuer dasselbe Muster bei anderen Overlays). const onDocClick = (ev) => { if (!menu.contains(ev.target)) this._closePasteMenu(); }; const onKeydown = (ev) => { if (ev.key === "Escape") this._closePasteMenu(); }; const onScroll = () => this._closePasteMenu(); // capture:true fuer den Klick-Listener, damit er VOR einem moeglichen // contextmenu-Listener eines anderen Elements greift; im naechsten // Tick registriert, sonst schliesst der Rechtsklick, der das Menue // gerade erst geoeffnet hat, es sofort wieder (derselbe Event). window.setTimeout(() => { document.addEventListener("click", onDocClick, true); document.addEventListener("keydown", onKeydown); window.addEventListener("scroll", onScroll, true); }, 0); this._pasteMenuCleanup = () => { document.removeEventListener("click", onDocClick, true); document.removeEventListener("keydown", onKeydown); window.removeEventListener("scroll", onScroll, true); }; } // --- Dateitransfer-Panel --------------------------------------------- _wireFileTransfer() { const openFt = () => { this.ftOverlay.classList.remove("hidden"); this.ftStatus.textContent = ""; }; const closeFt = () => { this.ftOverlay.classList.add("hidden"); }; this.filetransferBtn.addEventListener("click", openFt); this.ftCloseBtn.addEventListener("click", closeFt); // Klick auf den abgedunkelten Hintergrund (nicht auf das Panel selbst) // schliesst das Overlay ebenfalls -- Standardverhalten fuer solche Panels. this.ftOverlay.addEventListener("click", (ev) => { if (ev.target === this.ftOverlay) closeFt(); }); this._renderTransfers(); this.uploadPickBtn.addEventListener("click", () => this.uploadFileInput.click()); this.uploadFileInput.addEventListener("change", () => { const file = this.uploadFileInput.files[0]; this.uploadFilenameLabel.textContent = file ? file.name : "Keine Datei ausgewaehlt"; if (file && !this.uploadRemotePathInput.value) { this.uploadRemotePathInput.value = `/tmp/${file.name}`; } }); this.uploadSubmitBtn.addEventListener("click", () => this._doUpload()); this.downloadSubmitBtn.addEventListener("click", () => this._doDownload()); } _recordTransfer(direction, filename, size, ok, message) { this._transfers.unshift({ direction, filename, size, ok, message }); if (this._transfers.length > 20) this._transfers.length = 20; this._renderTransfers(); } _renderTransfers() { this.ftList.innerHTML = ""; if (this._transfers.length === 0) { const empty = document.createElement("div"); empty.className = "hint"; empty.textContent = "Noch keine Transfers in dieser Sitzung."; this.ftList.appendChild(empty); return; } for (const t of this._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); } this.ftList.appendChild(row); } } async _doUpload() { const file = this.uploadFileInput.files[0]; const remotePath = this.uploadRemotePathInput.value.trim(); this.ftStatus.textContent = ""; if (!file) { this.ftStatus.textContent = "Bitte zuerst eine Datei auswaehlen."; return; } if (!remotePath) { this.ftStatus.textContent = "Bitte einen Zielpfad angeben."; return; } const formData = new FormData(); formData.append("file", file); this.ftStatus.textContent = `Lade ${file.name} hoch ...`; try { const res = await fetch( `/ssh/${this.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. 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})`); this.ftStatus.textContent = `Upload abgeschlossen (AV: ${data.av_scan_result})`; this._recordTransfer("upload", file.name, data.size, true, ""); this.uploadFileInput.value = ""; this.uploadFilenameLabel.textContent = "Keine Datei ausgewaehlt"; this.uploadRemotePathInput.value = ""; } catch (err) { this.ftStatus.textContent = "Upload-Fehler: " + err.message; this._recordTransfer("upload", file.name, 0, false, err.message); } } async _doDownload() { const remotePath = this.downloadRemotePathInput.value.trim(); this.ftStatus.textContent = ""; if (!remotePath) { this.ftStatus.textContent = "Bitte einen Pfad angeben."; return; } const filename = remotePath.split("/").filter(Boolean).pop() || "download"; this.ftStatus.textContent = `Lade ${filename} herunter ...`; try { const res = await fetch( `/ssh/${this.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); this.ftStatus.textContent = `Download abgeschlossen: ${filename}`; this._recordTransfer("download", filename, blob.size, true, ""); this.downloadRemotePathInput.value = ""; } catch (err) { this.ftStatus.textContent = "Download-Fehler: " + err.message; this._recordTransfer("download", filename, 0, false, err.message); } } // --- Abbau ------------------------------------------------------------ /** Idempotent: schliesst WebSocket + Terminal, entfernt dokumentweite * Listener und das eigene DOM-Element. Sicher mehrfach aufrufbar (z.B. * einmal ueber den Exit-Knopf, danach nochmal defensiv vom Aufrufer). */ dispose() { if (this._disposed) return; this._disposed = true; if (this._pingTimer) { window.clearInterval(this._pingTimer); this._pingTimer = null; } this._closePasteMenu(); try { if (this.ws && (this.ws.readyState === WebSocket.OPEN || this.ws.readyState === WebSocket.CONNECTING)) { this.ws.close(1000, "Sitzung durch Benutzer beendet"); } } catch (_) { /* bereits getrennt */ } if (this._onWindowResize) { window.removeEventListener("resize", this._onWindowResize); document.removeEventListener("fullscreenchange", this._onWindowResize); } try { this.term.dispose(); } catch (_) { /* ignorieren */ } if (this.element.parentNode) this.element.parentNode.removeChild(this.element); } } window.SshTerminalSession = SshTerminalSession; // --- Bootstrap fuer die eigenstaendige Seite /terminal/{host_id} -------- // // F1: templates/terminal.html liefert nur noch einen leeren Container mit // data-host-id -- diese Seite bleibt als duenner Rahmen um denselben // Baustein bestehen (F.2), keine zweite Implementierung. Die kuenftige // Arbeitsflaeche /workspace (Teil F.3, spaetere Stufe) hat KEIN Element // mit dieser ID und erzeugt ihre Instanzen stattdessen selbst -- dieser // Bootstrap-Block bleibt dafuer wirkungslos, wenn terminal.js dort // eingebunden wird. const container = document.getElementById("session-container"); if (container) { const hostId = container.dataset.hostId; const session = new SshTerminalSession(hostId, { onExitRequested: () => { 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); }, }); container.appendChild(session.element); session.connect(); session.setActive(true); } })();