Files
ssh-jumphost/static/js/rdp.js
2026-09-02 20:30:44 +02:00

372 lines
17 KiB
JavaScript

/*
* RDP-Sitzungs-Baustein: guacamole-common-js <-> /ws/rdp/{host_id}.
*
* F1 (Umsetzungsauftrag_Sonnet5.md Teil F.3.1): vorher ein Dokument-Singleton
* (ein Skript pro Seite, feste Element-IDs). Jetzt eine Klasse
* (RdpSession) mit genau einer Instanz pro Sitzung: eigenes, dynamisch
* gebautes DOM-Fragment (this.element), connect(), dispose(), setActive(bool)
* und ein Statusrueckruf (options.onStatusChange). Die eigenstaendige Seite
* /rdp/{host_id} (templates/rdp.html) ist ab jetzt nur noch ein duenner
* Rahmen, der GENAU EINE Instanz erzeugt (F.2, letzter Absatz).
*
* ACHTUNG Tastatur/Zwischenablage (F.3.2, noch NICHT geloest -- folgt in
* Stufe F4): guacamole-common-js' Guacamole.Keyboard haengt sich immer an
* ein von aussen uebergebenes Element -- bisher `document`, und das bleibt
* in dieser Stufe zunaechst so, weil die Bibliothek keine saubere
* Abmeldung/Umschaltung bietet. Bei mehreren GLEICHZEITIGEN RdpSession-
* Instanzen wuerden ohne Gegenmassnahme alle Tastatureingaben an ALLE
* Instanzen gehen. Deshalb: jede Instanz filtert selbst ueber setActive() --
* Tastatur-/Paste-Ereignisse werden nur weitergeleitet, wenn genau diese
* Instanz aktiv ist (this._active). Das ist die Grundlage fuer F.3.2, aber
* noch NICHT die vollstaendige Loesung (fehlende Modifiertasten-Ruecksetzung
* beim Umschalten, siehe F.3.2 Satz 2) -- die kommt mit der eigentlichen
* Mehrfach-Sitzungs-Umschaltung in Stufe F4, wenn es ueberhaupt zwei
* gleichzeitig existierende Instanzen geben kann. Bei genau einer Instanz
* (heutiger Stand: Stufe F1/F2/F3, dort niemals zwei RdpSession gleichzeitig
* auf derselben Seite) ist das Verhalten exakt wie vorher.
*
* Vollbild: native Browser-Fullscreen-API (Konzept 4.3).
* Copy & Paste: bidirektionale Synchronisation ueber Guacamole.Client
* onclipboard-Event (Ziel -> Browser) und den `paste`-Browser-Event
* (Browser -> Ziel, sendet eine "clipboard"-Instruktion). Wird serverseitig
* zusaetzlich blockiert, wenn fuer den Host clipboard_enabled=false ist
* (siehe app/rdp_proxy/ws_tunnel.py) -- das UI blendet den Hinweis dann ein.
*/
(() => {
"use strict";
let _nextInstanceId = 1;
class RdpSession {
/**
* @param {number|string} hostId
* @param {object} [options]
* @param {(text: string, isError: boolean) => void} [options.onStatusChange]
* @param {() => void} [options.onExitRequested]
* Wird aufgerufen, NACHDEM der interne Exit-Knopf dispose()
* bereits ausgeloest hat.
*/
constructor(hostId, options = {}) {
this.hostId = hostId;
this.options = options;
this._uid = _nextInstanceId++;
this._active = false;
this._disposed = false;
this._intentionalDisconnect = false;
this._resizeDebounceHandle = null;
this.element = this._buildElement();
this._cacheRefs();
this._wireToolbar();
}
// --- Aufbau -------------------------------------------------------
_buildElement() {
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="ctrlaltdel-btn">Strg+Alt+Entf</button>
<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-rdp-display">
<!-- B.1 (Umsetzungsauftrag Teil B): Overlay-Panel fuer
Verbindungsfehler, siehe rdp-error-overlay unten. -->
<div class="rdp-error-overlay" hidden>
<div class="rdp-error-panel">
<h2 class="rdp-error-title">Verbindungsfehler</h2>
<p class="rdp-error-message"></p>
<dl class="rdp-error-meta">
<dt>Sitzungs-ID</dt><dd class="rdp-error-session">-</dd>
<dt>Zeitpunkt</dt><dd class="rdp-error-time">-</dd>
<dt>Ziel-Host</dt><dd class="rdp-error-host">-</dd>
</dl>
<button type="button" class="rdp-error-retry-btn">Wiederholen</button>
</div>
</div>
</div>
</div>
`;
return wrapper;
}
_cacheRefs() {
const el = this.element;
this.shellEl = el.querySelector(".session-shell");
this.statusEl = el.querySelector(".status");
this.displayDiv = el.querySelector(".session-rdp-display");
this.fullscreenBtn = el.querySelector(".fullscreen-btn");
this.ctrlAltDelBtn = el.querySelector(".ctrlaltdel-btn");
this.exitBtn = el.querySelector(".exit-btn");
this.errorOverlay = el.querySelector(".rdp-error-overlay");
this.errorTitleEl = el.querySelector(".rdp-error-title");
this.errorMessageEl = el.querySelector(".rdp-error-message");
this.errorSessionEl = el.querySelector(".rdp-error-session");
this.errorTimeEl = el.querySelector(".rdp-error-time");
this.errorHostEl = el.querySelector(".rdp-error-host");
this.errorRetryBtn = el.querySelector(".rdp-error-retry-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 */ }
}
}
// --- Verbindungsaufbau ------------------------------------------------
connect() {
if (this.client) return; // bereits verbunden/am Verbinden
const proto = window.location.protocol === "https:" ? "wss:" : "ws:";
const width = Math.round(window.innerWidth);
const height = Math.round(window.innerHeight - 40);
const dpi = Math.round(window.devicePixelRatio * 96) || 96;
// WICHTIG: die Verbindungsparameter gehoeren NICHT in die Tunnel-URL.
// Guacamole.WebSocketTunnel.connect(data) baut die Socket-URL selbst als
// `tunnelURL + "?" + data` zusammen. Standen die Parameter schon in der
// URL, entstand daraus `...?width=1280&height=800&dpi=96?undefined` --
// der letzte Query-Parameter war damit kein gueltiger Integer mehr,
// FastAPI wies den WebSocket noch vor dem Routenhandler ab (HTTP 422).
const tunnelUrl = `${proto}//${window.location.host}/ws/rdp/${this.hostId}`;
const connectParams = `width=${width}&height=${height}&dpi=${dpi}`;
this.tunnel = new Guacamole.WebSocketTunnel(tunnelUrl);
this.client = new Guacamole.Client(this.tunnel);
this.displayDiv.appendChild(this.client.getDisplay().getElement());
this.client.onstatechange = (state) => {
// 0=idle,1=connecting,2=waiting,3=connected,4=disconnecting,5=disconnected
const labels = ["Idle", "Verbinde ...", "Warte auf Server ...", "Verbunden", "Trenne ...", "Getrennt"];
this._setStatus(labels[state] || `Status ${state}`, false);
};
const showError = (err) => {
// Ein vom Benutzer ueber den Exit-Knopf ausgeloestes client.disconnect()
// schliesst den Tunnel sauber (Guacamole.Status.Code.SUCCESS) und loest
// damit laut guacamole-common-js KEIN onerror aus -- dieser Schutz greift
// trotzdem zusaetzlich, falls sich das je aendert.
if (this._intentionalDisconnect) return;
const message = (err && err.message) || "Unbekannter Fehler (keine Meldung vom Server empfangen)";
this._setStatus("Fehler: " + message, true);
this.errorTitleEl.textContent = "Verbindungsfehler";
this.errorMessageEl.textContent = message;
this.errorSessionEl.textContent = this.tunnel.uuid || "unbekannt";
this.errorTimeEl.textContent = new Date().toLocaleString("de-AT");
this.errorHostEl.textContent = `Host-ID ${this.hostId}`;
this.errorOverlay.hidden = false;
console.error("RDP-Sitzung beendet/Fehler:", err);
};
this.client.onerror = showError;
this.tunnel.onerror = showError;
this.errorRetryBtn.addEventListener("click", () => {
// Einfachste zuverlaessige Wiederholung: die Sitzung (Tunnel, Client,
// Maus-/Tastatur-Listener) neu aufzubauen wuerde denselben Zustand wie
// ein frischer Seitenaufruf erfordern -- ein Reload erreicht das direkt
// und erzeugt serverseitig eine neue Korrelations-ID/Sitzung. Betrifft
// bei /workspace (Stufe F3/F4) nur diese eine Kachel -- ein voller
// Seiten-Reload dort wuerde ALLE offenen Sitzungen der Arbeitsflaeche
// beenden (siehe F.3.7: kein Wiederanschluss nach Reload).
window.location.reload();
});
this.client.onclipboard = (stream, mimetype) => {
if (!mimetype.startsWith("text/")) return;
const reader = new Guacamole.StringReader(stream);
let data = "";
reader.ontext = (text) => { data += text; };
reader.onend = () => {
if (navigator.clipboard && navigator.clipboard.writeText) {
navigator.clipboard.writeText(data).catch(() => {});
}
};
};
this.client.connect(connectParams);
this._onBeforeUnload = () => { try { this.client.disconnect(); } catch (_) { /* ignorieren */ } };
window.addEventListener("beforeunload", this._onBeforeUnload);
// D4 (Umsetzungsauftrag Teil A): der Verbindungsaufbau setzt bereits
// "resize-method: display-update" -- das RDP-Ziel unterstuetzt damit
// dynamische Aufloesungsaenderungen waehrend der laufenden Sitzung.
// Debounce: "resize" kann waehrend eines Fenster-Drags sehr haeufig
// feuern -- ohne Verzoegerung wuerde jede Zwischengroesse eine eigene
// "size"-Instruktion ueber den Tunnel schicken.
// F4 (Umsetzungsauftrag_Sonnet5.md Teil F.3.3): mehrere Instanzen
// koennen jetzt gleichzeitig existieren (Arbeitsflaeche /workspace) --
// eine ausgeblendete Instanz hat Groesse 0 (display:none). Ein Refit
// waehrend ausgeblendet waere sinnlos und wuerde bei Sichtbarwerden
// ohnehin durch setActive(true) -> _sendCurrentSize() nachgeholt --
// daher nur reagieren, wenn diese Instanz gerade sichtbar ist.
this._onWindowResize = () => { if (this._active) this._scheduleResize(); };
window.addEventListener("resize", this._onWindowResize);
document.addEventListener("fullscreenchange", this._onWindowResize);
this.mouse = new Guacamole.Mouse(this.client.getDisplay().getElement());
this.mouse.onmousedown = this.mouse.onmouseup = this.mouse.onmousemove = (mouseState) => {
this.client.sendMouseState(mouseState);
};
// Siehe Moduldoc oben: dokumentweit angeheftet (Bibliotheksgrenze),
// Weiterleitung aber ueber this._active gefiltert.
this.keyboard = new Guacamole.Keyboard(document);
this.keyboard.onkeydown = (keysym) => { if (this._active) this.client.sendKeyEvent(1, keysym); };
this.keyboard.onkeyup = (keysym) => { if (this._active) this.client.sendKeyEvent(0, keysym); };
this._onPaste = (ev) => {
if (!this._active) return;
const text = (ev.clipboardData || window.clipboardData).getData("text");
if (!text) return;
const stream = this.client.createClipboardStream("text/plain");
const writer = new Guacamole.StringWriter(stream);
writer.sendText(text);
writer.sendEnd();
};
document.addEventListener("paste", this._onPaste);
}
_sendCurrentSize() {
const newWidth = Math.round(this.displayDiv.clientWidth || window.innerWidth);
const newHeight = Math.round(this.displayDiv.clientHeight || (window.innerHeight - 40));
if (newWidth > 0 && newHeight > 0) {
this.client.sendSize(newWidth, newHeight);
}
}
_scheduleResize() {
if (this._resizeDebounceHandle !== null) {
window.clearTimeout(this._resizeDebounceHandle);
}
this._resizeDebounceHandle = window.setTimeout(() => {
this._resizeDebounceHandle = null;
this._sendCurrentSize();
}, 250);
}
/** Wird von einer kuenftigen Seitenleiste (Teil F.3/F.4) beim Umschalten
* aufgerufen. Steuert, ob diese Instanz Tastatur-/Paste-Ereignisse
* entgegennimmt (siehe Moduldoc) -- bei genau einer Instanz (heutiger
* Stand) ohne sichtbaren Effekt ausser dem anfaenglichen Aktivieren. */
setActive(active) {
const wasActive = this._active;
this._active = active;
// F4 (F.3.2, Satz 2): beim Umschalten muessen gedrueckte Modifiertasten
// zurueckgesetzt werden -- sonst bleibt z.B. Strg in der verlassenen
// Sitzung haengen (dort kommt ja kein keyup mehr an, sobald diese
// Instanz keine Ereignisse mehr weiterleitet). Keyboard.reset() ist
// Teil der guacamole-common-js-API (dieselbe Methode, die der
// offizielle Client bei Fokusverlust des Fensters aufruft) -- per
// Feature-Check abgesichert, falls eine kuenftige Bibliotheksversion
// sie umbenennt.
if (!active && wasActive && this.keyboard && typeof this.keyboard.reset === "function") {
this.keyboard.reset();
}
if (active && this.client) {
this._sendCurrentSize();
if (this.keyboard && typeof this.keyboard.reset === "function") {
this.keyboard.reset();
}
}
}
// --- Toolbar --------------------------------------------------------
_wireToolbar() {
this.fullscreenBtn.addEventListener("click", () => {
if (!document.fullscreenElement) {
this.shellEl.requestFullscreen().catch(() => {});
} else {
document.exitFullscreen();
}
});
// Strg+Alt+Entf: der Browser faengt diese Kombination selbst ab (Windows
// reserviert sie systemweit), sie kommt also nie als normales Tastatur-
// Event beim RDP-Ziel an. Abhilfe wie im offiziellen Guacamole-Client:
// die drei Tasten einzeln als synthetische Key-Events senden (X11-
// Keysyms: Ctrl=0xFFE3, Alt=0xFFE9, Delete=0xFFFF), erst alle drei
// herunter- dann in umgekehrter Reihenfolge wieder hochdruecken.
this.ctrlAltDelBtn.addEventListener("click", () => {
if (!this.client) return;
const keys = [0xffe3, 0xffe9, 0xffff];
for (const keysym of keys) this.client.sendKeyEvent(1, keysym);
for (const keysym of keys.slice().reverse()) this.client.sendKeyEvent(0, keysym);
});
this.exitBtn.addEventListener("click", () => {
this._intentionalDisconnect = true;
this._setStatus("Sitzung wird beendet ...", false);
this.dispose();
if (this.options.onExitRequested) this.options.onExitRequested();
});
}
// --- Abbau ------------------------------------------------------------
/** Idempotent. Siehe terminal.js::SshTerminalSession.dispose() fuer die
* gleiche Begruendung. Einschraenkung: guacamole-common-js bietet keine
* saubere Abmeldung fuer Guacamole.Keyboard(document) -- der zugrunde-
* liegende DOM-Listener bleibt technisch bestehen, sendet aber nach
* dispose() nichts mehr (this._active bleibt false). Das ist bei genau
* einer Instanz (heutiger Stand) folgenlos; bei mehreren gleichzeitigen
* Instanzen (Stufe F4) muss dieser Punkt beim Kachel-Schliessen erneut
* betrachtet werden. */
dispose() {
if (this._disposed) return;
this._disposed = true;
try {
if (this.client) this.client.disconnect();
} catch (_) { /* bereits getrennt */ }
if (this._onWindowResize) {
window.removeEventListener("resize", this._onWindowResize);
document.removeEventListener("fullscreenchange", this._onWindowResize);
}
if (this._onBeforeUnload) window.removeEventListener("beforeunload", this._onBeforeUnload);
if (this._onPaste) document.removeEventListener("paste", this._onPaste);
if (this._resizeDebounceHandle !== null) {
window.clearTimeout(this._resizeDebounceHandle);
this._resizeDebounceHandle = null;
}
if (this.element.parentNode) this.element.parentNode.removeChild(this.element);
}
}
window.RdpSession = RdpSession;
// --- Bootstrap fuer die eigenstaendige Seite /rdp/{host_id} --------------
//
// F1: templates/rdp.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.
const container = document.getElementById("session-container");
if (container) {
const hostId = container.dataset.hostId;
const session = new RdpSession(hostId, {
onExitRequested: () => {
window.close();
// Siehe terminal.js fuer die ausfuehrliche Begruendung: window.close()
// schliesst den Tab nur unter bestimmten Voraussetzungen lautlos --
// deshalb zusaetzlich immer ein Fallback zum Dashboard.
window.setTimeout(() => { window.location.href = "/dashboard"; }, 300);
},
});
container.appendChild(session.element);
session.connect();
session.setActive(true);
}
})();