855 lines
38 KiB
JavaScript
855 lines
38 KiB
JavaScript
/*
|
|
* 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` versucht, eine Markierung sofort in die System-
|
|
* Zwischenablage zu kopieren (kein Strg+C/Cmd+C mehr noetig -- Strg+C bleibt
|
|
* ohne Markierung weiterhin SIGINT, xterm.js' Standardverhalten). Das ist
|
|
* aber eine Automatik OHNE direkte Benutzergeste (sie laeuft aus xterm.js'
|
|
* eigenem mouseup-Handler heraus) und schlaegt daher in der Praxis manchmal
|
|
* fehl, ohne dass die App das mitbekommt (Live-Test 2.9.: Markierung+Ziehen
|
|
* hat sichtbar funktioniert, in der Zwischenablage kam aber nichts an).
|
|
* Deshalb zusaetzlich ein Rechtsklick-Kontextmenue, das PuTTYs bekannte
|
|
* Konvention nachbildet: Rechtsklick MIT aktiver Markierung kopiert (direkter
|
|
* Versuch ueber die Clipboard-API, da ein Rechtsklick selbst eine
|
|
* "transiente Aktivierung"/Benutzergeste ist -- das ist deutlich
|
|
* zuverlaessiger als copyOnSelects impliziter Aufruf); schlaegt das fehl
|
|
* oder ist keine Markierung aktiv, faellt es auf "Einfuegen" zurueck --
|
|
* ebenfalls zuerst direkt ueber die Clipboard-API versucht, sonst ein
|
|
* winziges Kontextmenue mit "Kopieren"- bzw. "Einfuegen"-Eintrag, dessen
|
|
* Klick selbst wieder eine frische Benutzergeste ist (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 <select> in der Toolbar waehlbar (TERMINAL_THEMES unten),
|
|
* Wahl wird in localStorage gemerkt (schluessel THEME_STORAGE_KEY) und beim
|
|
* naechsten Sitzungsaufbau (auch in anderen Tabs) als Vorgabe uebernommen --
|
|
* kein Server-Roundtrip, rein clientseitige Praeferenz wie ueblich fuer
|
|
* Darstellungseinstellungen dieser Art. AENDERT NICHTS an bereits anderen,
|
|
* gleichzeitig offenen Sitzungen (F4: mehrere Instanzen) -- dieselbe Klasse
|
|
* von Eventual-Consistency wie an anderer Stelle im Projekt (kein Live-Sync
|
|
* zwischen Instanzen, nur beim naechsten Aufbau wirksam).
|
|
* Tastatur: xterm.js haengt seine Tastatur-Erfassung bereits an sein EIGENES
|
|
* Containerelement (nicht an document) -- die in F.3.2 beschriebene Gefahr
|
|
* ("Tastendruck landet auf drei Servern") betrifft daher nur rdp.js
|
|
* (Guacamole.Keyboard(document)), nicht dieses Modul. window resize/
|
|
* fullscreenchange bleiben dokumentweite Listener (siehe dispose()) -- bei
|
|
* genau einer aktiven Instanz (heutiger Stand: Stufe F1/F2/F3) unveraendert
|
|
* zum bisherigen Verhalten; die Mehrfach-Sitzungs-Feinheiten aus F.3.2/F.3.3
|
|
* (Refit erst beim Sichtbarwerden, Fokusmodell) folgen in Stufe F4.
|
|
*
|
|
* 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).
|
|
*
|
|
* F5-Nachtrag (Umsetzungsauftrag_Sonnet5.md Teil F.3.4): periodischer
|
|
* Leerlauf-Ping (this._pingTimer, alle 4 Minuten, siehe connect()) haelt
|
|
* eine wirklich untaetige Hintergrundsitzung am Leben -- ohne ihn trennt
|
|
* der server-seitige Idle-Timeout (E10, IDLE_TIMEOUT_SECONDS, Standard 15
|
|
* Minuten) eine Sitzung ohne jede Ein-/Ausgabe, obwohl Betreiberentscheidung
|
|
* #7 (FORTSETZUNG_Teil_F.md) "voll verbunden bleiben" verlangt. Betrifft nur
|
|
* SSH -- RDP (ws_tunnel.py) hat keinen Idle-Timeout.
|
|
*/
|
|
(() => {
|
|
"use strict";
|
|
|
|
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;
|
|
}
|
|
|
|
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`;
|
|
}
|
|
|
|
// --- Farbschemata (Aufgabe #11) -----------------------------------------
|
|
//
|
|
// Bewusst eine kleine, feste Auswahl statt eines vollen Theme-Editors --
|
|
// deckt den geaeusserten Wunsch ("vom Benutzer waehlbar") ab, ohne eine
|
|
// neue Einstellungsseite o.ae. einzufuehren. Felder folgen xterm.js'
|
|
// ITheme-Schnittstelle (siehe vendor/xterm.js, OptionsService/"theme").
|
|
const TERMINAL_THEMES = {
|
|
standard: {
|
|
label: "Standard (dunkel)",
|
|
theme: { background: "#000000", foreground: "#e6e8eb", cursor: "#e6e8eb" },
|
|
},
|
|
dunkel_kontrastreich: {
|
|
label: "Dunkel, kontrastreich",
|
|
theme: {
|
|
background: "#0d1117", foreground: "#c9d1d9", cursor: "#58a6ff",
|
|
selectionBackground: "#264f78",
|
|
black: "#484f58", red: "#ff7b72", green: "#3fb950", yellow: "#d29922",
|
|
blue: "#58a6ff", magenta: "#bc8cff", cyan: "#39c5cf", white: "#b1bac4",
|
|
brightBlack: "#6e7681", brightRed: "#ffa198", brightGreen: "#56d364",
|
|
brightYellow: "#e3b341", brightBlue: "#79c0ff", brightMagenta: "#d2a8ff",
|
|
brightCyan: "#56d4dd", brightWhite: "#f0f6fc",
|
|
},
|
|
},
|
|
solarized_dark: {
|
|
label: "Solarized Dark",
|
|
theme: {
|
|
background: "#002b36", foreground: "#839496", cursor: "#93a1a1",
|
|
selectionBackground: "#073642",
|
|
black: "#073642", red: "#dc322f", green: "#859900", yellow: "#b58900",
|
|
blue: "#268bd2", magenta: "#d33682", cyan: "#2aa198", white: "#eee8d5",
|
|
brightBlack: "#002b36", brightRed: "#cb4b16", brightGreen: "#586e75",
|
|
brightYellow: "#657b83", brightBlue: "#839496", brightMagenta: "#6c71c4",
|
|
brightCyan: "#93a1a1", brightWhite: "#fdf6e3",
|
|
},
|
|
},
|
|
solarized_light: {
|
|
label: "Solarized Light",
|
|
theme: {
|
|
background: "#fdf6e3", foreground: "#657b83", cursor: "#586e75",
|
|
selectionBackground: "#eee8d5",
|
|
black: "#073642", red: "#dc322f", green: "#859900", yellow: "#b58900",
|
|
blue: "#268bd2", magenta: "#d33682", cyan: "#2aa198", white: "#eee8d5",
|
|
brightBlack: "#002b36", brightRed: "#cb4b16", brightGreen: "#586e75",
|
|
brightYellow: "#657b83", brightBlue: "#839496", brightMagenta: "#6c71c4",
|
|
brightCyan: "#93a1a1", brightWhite: "#fdf6e3",
|
|
},
|
|
},
|
|
};
|
|
const DEFAULT_THEME_NAME = "standard";
|
|
const THEME_STORAGE_KEY = "jumphost_terminal_theme";
|
|
|
|
function loadStoredThemeName() {
|
|
try {
|
|
const stored = window.localStorage.getItem(THEME_STORAGE_KEY);
|
|
if (stored && TERMINAL_THEMES[stored]) return stored;
|
|
} catch (_) { /* localStorage blockiert (privater Modus o.ae.) -- Standard */ }
|
|
return DEFAULT_THEME_NAME;
|
|
}
|
|
|
|
function storeThemeName(name) {
|
|
try {
|
|
window.localStorage.setItem(THEME_STORAGE_KEY, name);
|
|
} catch (_) { /* siehe oben -- Auswahl gilt dann nur fuer diese Instanz */ }
|
|
}
|
|
|
|
let _nextInstanceId = 1;
|
|
|
|
class SshTerminalSession {
|
|
/**
|
|
* @param {number|string} hostId
|
|
* @param {object} [options]
|
|
* @param {(text: string, isError: boolean) => void} [options.onStatusChange]
|
|
* Statusrueckruf fuer eine kuenftige Seitenleiste (Teil F.3).
|
|
* @param {() => void} [options.onExitRequested]
|
|
* Wird aufgerufen, NACHDEM der interne Exit-Knopf dispose()
|
|
* bereits ausgeloest hat -- die Seite entscheidet, was danach
|
|
* passiert (Tab schliessen bei /terminal/{id}, Kachel aus der
|
|
* Seitenleiste entfernen bei /workspace).
|
|
*/
|
|
constructor(hostId, options = {}) {
|
|
this.hostId = hostId;
|
|
this.options = options;
|
|
this._uid = _nextInstanceId++;
|
|
this._active = false;
|
|
this._disposed = false;
|
|
this._lastErrorMessage = null;
|
|
this._transfers = [];
|
|
this._resizeDebounce = null;
|
|
this._clipboardMenuEl = null;
|
|
|
|
this.element = this._buildElement();
|
|
this._cacheRefs();
|
|
this._setupTerminal();
|
|
this._wireToolbar();
|
|
this._wireClipboard();
|
|
this._wireFileTransfer();
|
|
}
|
|
|
|
// --- Aufbau -------------------------------------------------------
|
|
|
|
_buildElement() {
|
|
const uploadPathId = `ssh-ft-upload-path-${this._uid}`;
|
|
const downloadPathId = `ssh-ft-download-path-${this._uid}`;
|
|
const themeOptions = Object.keys(TERMINAL_THEMES)
|
|
.map((key) => `<option value="${key}">${TERMINAL_THEMES[key].label}</option>`)
|
|
.join("");
|
|
const wrapper = document.createElement("div");
|
|
wrapper.className = "session-instance";
|
|
wrapper.innerHTML = `
|
|
<div class="session-shell">
|
|
<div class="session-toolbar">
|
|
<button type="button" class="fullscreen-btn">Vollbild</button>
|
|
<button type="button" class="filetransfer-btn">Dateitransfer</button>
|
|
<select class="theme-select" title="Farbschema">${themeOptions}</select>
|
|
<span class="spacer"></span>
|
|
<span class="status">Verbinde ...</span>
|
|
<button type="button" class="exit-btn btn-danger btn-small" title="Sitzung beenden">Exit</button>
|
|
</div>
|
|
<div class="session-terminal" title="Markieren: Klicken+Ziehen. In Vollbild-Programmen mit eigener Maussteuerung (tmux, vim, htop, mc, weniger/less --mouse, ...) Umschalt+Ziehen verwenden, sonst geht der Klick an das Programm statt an die Markierung."></div>
|
|
</div>
|
|
<div class="ft-overlay hidden">
|
|
<div class="ft-panel">
|
|
<h2>Dateitransfer
|
|
<button type="button" class="btn-secondary btn-small ft-close-btn">Schliessen</button>
|
|
</h2>
|
|
|
|
<h3>Hochladen</h3>
|
|
<label for="${uploadPathId}">Zielpfad auf dem Server</label>
|
|
<input type="text" id="${uploadPathId}" class="ft-upload-remote-path" placeholder="/tmp/datei.txt">
|
|
<input type="file" class="ft-upload-file-input hidden">
|
|
<div class="ft-btn-row">
|
|
<button type="button" class="btn-secondary btn-small ft-upload-pick-btn">Datei auswaehlen</button>
|
|
<span class="hint ft-upload-filename">Keine Datei ausgewaehlt</span>
|
|
</div>
|
|
<button type="button" class="btn-small ft-upload-submit-btn">Hochladen</button>
|
|
|
|
<h3>Herunterladen</h3>
|
|
<label for="${downloadPathId}">Pfad auf dem Server</label>
|
|
<input type="text" id="${downloadPathId}" class="ft-download-remote-path" placeholder="/etc/hosts">
|
|
<button type="button" class="btn-small ft-download-submit-btn">Herunterladen</button>
|
|
|
|
<div class="error ft-status"></div>
|
|
|
|
<h3>Letzte Transfers</h3>
|
|
<div class="ft-list"></div>
|
|
</div>
|
|
</div>
|
|
`;
|
|
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);
|
|
// Bug-Fix (Live-Test, F.6/Aufgabe#11): xterm.js' Standard-DOM-Renderer
|
|
// faerbt Zellen (inkl. Markierungs-Hintergrund) teils per dynamisch
|
|
// erzeugtem <style>-Element, teils per element.setAttribute("style", ...).
|
|
// Beides sind "inline styles" im Sinne der CSP (style-src 'self', kein
|
|
// 'unsafe-inline'/Nonce/Hash) -- der Browser verwirft diese Regeln
|
|
// stillschweigend (element.sheet bleibt null), wodurch u.a. die
|
|
// Text-Markierung unsichtbar bleibt, obwohl die Auswahl-Logik selbst
|
|
// funktioniert. Der Canvas-Renderer zeichnet Zeichen/Farben stattdessen
|
|
// direkt auf ein <canvas> (Pixel statt CSS) und ist von style-src nicht
|
|
// betroffen -- CSP bleibt dadurch unveraendert strikt.
|
|
//
|
|
// WICHTIG: erst NACH term.open() laden. Vor dem Oeffnen hat der
|
|
// Container noch keine echten Masse (Flex-Layout noch nicht berechnet);
|
|
// ein zu frueh geladener CanvasAddon legt sein <canvas> mit einer viel
|
|
// zu kleinen Zeilenzahl an. Ergebnis: das <canvas> deckt nur die
|
|
// obersten paar Zeilen ab, darunter blieb der reine DOM-Hintergrund
|
|
// (schwarz) sichtbar -- sah wie eine falsche/haengende Markierung aus,
|
|
// war aber ein Groessen-Mismatch (im Live-Test am 2.9. gefunden).
|
|
try {
|
|
this.canvasAddon = new CanvasAddon.CanvasAddon();
|
|
this.term.loadAddon(this.canvasAddon);
|
|
} catch (err) {
|
|
// Faellt der Canvas-Renderer aus irgendeinem Grund aus (z.B. sehr
|
|
// altes/eingeschraenktes Canvas-2D-Umfeld), bleibt der DOM-Renderer
|
|
// als Fallback aktiv -- Faerbung/Markierung waeren dann wieder vom
|
|
// CSP-Fix abhaengig, aber die Sitzung funktioniert weiterhin.
|
|
console.warn("CanvasAddon konnte nicht geladen werden, Fallback auf DOM-Renderer:", err);
|
|
}
|
|
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._closeClipboardMenu();
|
|
}
|
|
}
|
|
|
|
// --- 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, erweitert Live-Test 2.9.) ------------
|
|
//
|
|
// copyOnSelect (siehe _setupTerminal()) bleibt als Automatik aktiv, ist
|
|
// aber nicht zuverlaessig genug (siehe Datei-Kopfkommentar) -- deshalb
|
|
// zusaetzlich Rechtsklick nach PuTTY-Konvention: MIT aktiver Markierung
|
|
// kopieren, OHNE einfuegen. Beides zweistufig wie gehabt: direkter
|
|
// Clipboard-API-Versuch, bei Fehlschlag ein winziges Kontextmenue als
|
|
// Ruecksicherung (dessen Klick selbst wieder eine frische Benutzergeste
|
|
// ist und dadurch bessere Chancen auf die Berechtigung hat).
|
|
|
|
_wireClipboard() {
|
|
this.terminalContainer.addEventListener("contextmenu", (ev) => {
|
|
ev.preventDefault();
|
|
if (this.term.hasSelection()) {
|
|
this._attemptDirectCopy(ev.clientX, ev.clientY);
|
|
} else {
|
|
this._attemptDirectPaste(ev.clientX, ev.clientY);
|
|
}
|
|
});
|
|
}
|
|
|
|
async _attemptDirectCopy(x, y) {
|
|
const text = this.term.getSelection();
|
|
if (!navigator.clipboard || !navigator.clipboard.writeText) {
|
|
this._showCopyMenu(x, y, text);
|
|
return;
|
|
}
|
|
try {
|
|
await navigator.clipboard.writeText(text);
|
|
} catch (_) {
|
|
// Wie bei _attemptDirectPaste(): Ruecksicherung unten, deren Klick
|
|
// selbst wieder eine frische Benutzergeste ist.
|
|
this._showCopyMenu(x, y, text);
|
|
}
|
|
}
|
|
|
|
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);
|
|
}
|
|
}
|
|
|
|
_closeClipboardMenu() {
|
|
if (this._clipboardMenuEl) {
|
|
this._clipboardMenuEl.remove();
|
|
this._clipboardMenuEl = null;
|
|
}
|
|
if (this._clipboardMenuCleanup) {
|
|
this._clipboardMenuCleanup();
|
|
this._clipboardMenuCleanup = null;
|
|
}
|
|
}
|
|
|
|
// Gemeinsamer Aufbau fuer Kopieren- und Einfuegen-Mini-Kontextmenue --
|
|
// beide brauchen dieselbe Positionierung und dasselbe Schliessverhalten,
|
|
// nur Beschriftung und Klick-Aktion unterscheiden sich.
|
|
_showClipboardMenu(x, y, label, onButtonClick) {
|
|
this._closeClipboardMenu();
|
|
// 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 btn = document.createElement("button");
|
|
btn.type = "button";
|
|
btn.textContent = label;
|
|
btn.addEventListener("click", async () => {
|
|
this._closeClipboardMenu();
|
|
await onButtonClick();
|
|
this.term.focus();
|
|
});
|
|
menu.appendChild(btn);
|
|
|
|
// 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._clipboardMenuEl = 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._closeClipboardMenu();
|
|
};
|
|
const onKeydown = (ev) => {
|
|
if (ev.key === "Escape") this._closeClipboardMenu();
|
|
};
|
|
const onScroll = () => this._closeClipboardMenu();
|
|
// 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._clipboardMenuCleanup = () => {
|
|
document.removeEventListener("click", onDocClick, true);
|
|
document.removeEventListener("keydown", onKeydown);
|
|
window.removeEventListener("scroll", onScroll, true);
|
|
};
|
|
}
|
|
|
|
_showPasteMenu(x, y) {
|
|
this._showClipboardMenu(x, y, "Einfuegen", async () => {
|
|
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
|
|
);
|
|
}
|
|
});
|
|
}
|
|
|
|
_showCopyMenu(x, y, text) {
|
|
this._showClipboardMenu(x, y, "Kopieren", async () => {
|
|
try {
|
|
await navigator.clipboard.writeText(text);
|
|
} catch (err) {
|
|
console.error("Kopieren in die Zwischenablage nicht moeglich:", err);
|
|
this._setStatus(
|
|
"Kopieren nicht moeglich -- Browser verweigert Zwischenablage-Zugriff. Bitte Strg+Umschalt+C verwenden.",
|
|
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._closeClipboardMenu();
|
|
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);
|
|
}
|
|
})();
|