From 5c95b21be7fe07723d32a910c0c974dee1178826 Mon Sep 17 00:00:00 2001 From: Midas Wollinger Date: Wed, 2 Sep 2026 20:30:44 +0200 Subject: [PATCH] umbau 1.0 --- FORTSETZUNG_Aufgabe11.md | 169 +++ FORTSETZUNG_Teil_C.md | 312 +++++ FORTSETZUNG_Teil_D.md | 702 ++++++++++ FORTSETZUNG_Teil_F.md | 742 +++++++++++ Jumphost_Konzept.md | 2 +- README.md | 94 +- Umsetzungsauftrag_Sonnet5.md | 942 ++++++++++++++ {app => _to_delete}/tenancy.py | 0 {tests => _to_delete}/test_tenants.py | 0 ansible/inventory/group_vars/all.yml | 41 +- ansible/roles/fail2ban/tasks/main.yml | 7 +- .../templates/jumphost.nft.j2 | 9 + ansible/roles/guacd/tasks/main.yml | 125 ++ .../templates/jumphost-app.service.j2 | 21 +- .../nginx_proxy/templates/jumphost.conf.j2 | 8 +- ansible/roles/sqlite_init/tasks/main.yml | 13 +- app/admin/log_ws.py | 4 +- app/admin/routes.py | 1146 +++++++++-------- app/auth/deps.py | 102 +- app/auth/routes.py | 42 +- app/catalog/routes.py | 238 ++-- app/config.py | 61 + app/db.py | 29 + .../0013_fix_placeholder_rdp_username.sql | 21 + app/db/migrations/0014_drop_tenants.sql | 196 +++ app/db/migrations/0015_remove_dead_roles.sql | 34 + .../0016_ssh_password_credential_objects.sql | 49 + app/db/migrations/0017_personal_groups.sql | 64 + .../0018_credential_group_grants.sql | 91 ++ .../0019_retire_direct_role_grants.sql | 26 + .../0020_session_user_ended_index.sql | 22 + app/main.py | 77 +- app/models/schemas.py | 57 +- app/rbac.py | 171 ++- app/rdp_proxy/guacd_client.py | 430 +++++-- app/rdp_proxy/ws_tunnel.py | 344 ++++- app/recordings/recorder.py | 283 +++- app/recordings/recorder.py.bak_1788208152 | 63 + app/security/active_sessions.py | 21 +- app/security/audit.py | 53 +- app/security/rate_limit.py | 18 +- app/security/session_reaper.py | 83 ++ app/ssh_proxy/proxy.py | 104 +- app/ssh_proxy/sftp.py | 252 +++- app/ssh_proxy/terminal_ws.py | 119 +- pytest.ini | 1 + scripts/diff_effective_rights.py | 147 +++ scripts/pre_schritt4_checks.py | 147 +++ scripts/promote_tenant_admins.py | 99 ++ static/css/app.css | 146 ++- static/js/admin.js | 334 ++--- static/js/dashboard.js | 15 +- static/js/rdp.js | 456 +++++-- static/js/session-keepalive.js | 37 + static/js/terminal.js | 979 ++++++++++---- static/js/workspace.js | 532 ++++++++ templates/admin.html | 155 +-- templates/rdp.html | 21 +- templates/terminal.html | 49 +- templates/workspace.html | 63 + tests/test_admin_crud.py | 397 ++++++ tests/test_audit_chain.py | 33 + tests/test_csp_compliance.py | 6 +- tests/test_pentest_security.py | 55 +- tests/test_phase10.py | 44 +- tests/test_phase12.py | 69 +- tests/test_phase13.py | 76 +- tests/test_phase15.py | 102 +- tests/test_phase9.py | 86 +- tests/test_rbac.py | 62 +- tests/test_session_reaper.py | 108 ++ tests/test_session_recorder.py | 108 ++ tests/test_teil_d_schritt5.py | 341 +++++ tests/test_teil_d_schritt6.py | 166 +++ tests/test_teil_f_schritt2.py | 180 +++ tests/test_teil_f_schritt3.py | 76 ++ tests/test_teil_f_schritt5.py | 170 +++ 77 files changed, 10733 insertions(+), 1914 deletions(-) create mode 100644 FORTSETZUNG_Aufgabe11.md create mode 100644 FORTSETZUNG_Teil_C.md create mode 100644 FORTSETZUNG_Teil_D.md create mode 100644 FORTSETZUNG_Teil_F.md create mode 100644 Umsetzungsauftrag_Sonnet5.md rename {app => _to_delete}/tenancy.py (100%) rename {tests => _to_delete}/test_tenants.py (100%) create mode 100644 app/db/migrations/0013_fix_placeholder_rdp_username.sql create mode 100644 app/db/migrations/0014_drop_tenants.sql create mode 100644 app/db/migrations/0015_remove_dead_roles.sql create mode 100644 app/db/migrations/0016_ssh_password_credential_objects.sql create mode 100644 app/db/migrations/0017_personal_groups.sql create mode 100644 app/db/migrations/0018_credential_group_grants.sql create mode 100644 app/db/migrations/0019_retire_direct_role_grants.sql create mode 100644 app/db/migrations/0020_session_user_ended_index.sql create mode 100644 app/recordings/recorder.py.bak_1788208152 create mode 100644 app/security/session_reaper.py create mode 100644 scripts/diff_effective_rights.py create mode 100644 scripts/pre_schritt4_checks.py create mode 100644 scripts/promote_tenant_admins.py create mode 100644 static/js/session-keepalive.js create mode 100644 static/js/workspace.js create mode 100644 templates/workspace.html create mode 100644 tests/test_admin_crud.py create mode 100644 tests/test_session_reaper.py create mode 100644 tests/test_session_recorder.py create mode 100644 tests/test_teil_d_schritt5.py create mode 100644 tests/test_teil_d_schritt6.py create mode 100644 tests/test_teil_f_schritt2.py create mode 100644 tests/test_teil_f_schritt3.py create mode 100644 tests/test_teil_f_schritt5.py diff --git a/FORTSETZUNG_Aufgabe11.md b/FORTSETZUNG_Aufgabe11.md new file mode 100644 index 0000000..45ef102 --- /dev/null +++ b/FORTSETZUNG_Aufgabe11.md @@ -0,0 +1,169 @@ +# Fortsetzungsdokument: Aufgabe #11 (SSH Copy-Paste + Terminal-Farben) + +Stand: 2026-09-02, Code-Stand abgeschlossen und regressionsfrei verifiziert; +Live-Browser-Test weiterhin ausstehend (siehe Abschnitt 3 + Nachtrag in +Abschnitt 4 -- dieselbe Einschraenkung wie bei Teil F). +Repo: `ssh_jumphost`, erreichbar ueber die `device_bash`-Bridge unter +`$HOME/mnt/ssh_jumphost`. + +**Einordnung**: Aufgabe #11 ist vom Nutzer nachtraeglich hinzugefuegt worden, +nicht Teil von `Umsetzungsauftrag_Sonnet5.md` und hatte bis zu dieser Session +keine Detail-Spec -- nur das Stichwort "SSH Copy-Paste + Terminal-Farben" +(siehe `FORTSETZUNG_Teil_C.md`/`_D.md`/`_F.md`). Das Scope wurde zu Beginn +dieser Session per Rueckfrage geklaert (zwei AskUserQuestion-Antworten): + +1. **Copy-Paste**: "kopieren mit markieren des textes und einfuegen mit + rechtsklick, wenn es umsetzbar ist sonst mit Rechtsklickkontextmenu." +2. **Terminal-Farben**: "Vom Benutzer waehlbar." + +Beides betrifft ausschliesslich SSH (`static/js/terminal.js`, +`SshTerminalSession`) -- RDP hat ueber Guacamole bereits eine eigene, +funktionierende Zwischenablage-Anbindung (`onclipboard` in `rdp.js`) und war +nie Teil der Aufgabe (Titel: "**SSH** Copy-Paste"). + +--- + +## 1) SSH Copy-Paste + +* **Kopieren**: `copyOnSelect: true` bei der `Terminal`-Konstruktion + (`_setupTerminal()`). xterm.js kopiert eine Markierung damit sofort in die + System-Zwischenablage -- kein Strg+C/Cmd+C mehr noetig. Strg+C OHNE + Markierung bleibt unveraendert SIGINT (xterm.js' Standardverhalten, von + `copyOnSelect` nicht beruehrt -- kein neues Verhalten, keine Ueberraschung + fuer bestehende Nutzung). +* **Einfuegen per Rechtsklick**, zweistufig, genau wie gewuenscht: + 1. `contextmenu`-Listener auf `this.terminalContainer` + (`_wireClipboard()`) ruft `event.preventDefault()` auf (unterdrueckt + das native Browser-Kontextmenue) und versucht direkt + `navigator.clipboard.readText()` (`_attemptDirectPaste()`). In den + meisten Chromium-Browsern reicht ein Rechtsklick als "transiente + Aktivierung" fuer diese Berechtigung -- Einfuegen passiert dann sofort, + ohne sichtbares Menue. + 2. Schlaegt das fehl (kein `navigator.clipboard`, Berechtigung verweigert + -- typischerweise Firefox/Safari ausserhalb einer frischen + Klick-Geste, oder unsicherer Kontext), erscheint ersatzweise ein + winziges Kontextmenue mit einem "Einfuegen"-Eintrag (`_showPasteMenu()`). + Dessen Klick ist selbst wieder eine frische Benutzergeste und hat + dadurch nochmal bessere Chancen auf die Berechtigung. Schlaegt auch das + fehl, zeigt der Status-Bereich der Sitzung einen erklaerenden Hinweis + ("... bitte Strg+Umschalt+V verwenden") statt eines stillen No-Ops. + 3. Das eigentliche Einfuegen laeuft ueber `this.term.paste(text)` (xterm.js + v5 API, bestaetigt durch `.paste=`-Vorkommen in `vendor/xterm.js`) -- + das ist KEIN separater Codepfad zum normalen Tippen: `paste()` loest + intern denselben `onData`-Callback aus wie eine Tastatureingabe, geht + also automatisch durch die bestehende WS-"input"-Sendefunktion. +* **CSP-Falle vermieden**: der erste Entwurf positionierte das + Ruecksicherungs-Kontextmenue ueber `menu.style.left/top` an der + Klickposition -- das haette der Server-CSP (`style-src 'self'`, kein + `unsafe-inline`) im echten Browser stillschweigend blockiert (dieselbe + Regel, die JS-gesetzte `element.style`-Aenderungen genauso trifft wie + `style="..."`-Attribute, siehe app.css-Kopfkommentar zu `.hidden`). + Ersetzt durch vier feste CSS-Klassen (`.term-paste-menu-{top,bottom}` x + `{left,right}`, app.css), die je nach Klick-Viertel von `.session-shell` + gesetzt werden -- keine Pixelgenauigkeit, aber vollstaendig CSP-konform + und konsistent mit der einzigen im ganzen Projekt verwendeten + Sichtbarkeits-/Positionierungstechnik (Klassen, nie `.style`). +* Aufraeumen: `_closePasteMenu()` entfernt das Menue plus seine + Dokument-Listener (Klick-daneben/Escape/Scroll) -- aufgerufen aus + `dispose()` UND aus `setActive(false)` (ein Umschalten auf eine andere + Sitzung soll kein Geister-Kontextmenue im Hintergrund hinterlassen). + +## 2) Terminal-Farben (vom Benutzer waehlbar) + +* Vier feste Farbschemata (`TERMINAL_THEMES`, Modul-Konstante): + Standard (dunkel, bisheriges Verhalten unveraendert als Vorgabe), + Dunkel/kontrastreich, Solarized Dark, Solarized Light. Bewusst eine kleine + feste Auswahl statt eines vollen Theme-Editors -- deckt "vom Benutzer + waehlbar" ab, ohne eine neue Einstellungsseite einzufuehren. +* ` 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 -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"; - 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))); } @@ -53,264 +93,683 @@ 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(); + // --- 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 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); - } + function storeThemeName(name) { + try { + window.localStorage.setItem(THEME_STORAGE_KEY, name); + } catch (_) { /* siehe oben -- Auswahl gilt dann nur fuer diese Instanz */ } } - 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"); + let _nextInstanceId = 1; - 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}`; - } - }); + 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._pasteMenuEl = null; - 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; + this.element = this._buildElement(); + this._cacheRefs(); + this._setupTerminal(); + this._wireToolbar(); + this._wireClipboard(); + this._wireFileTransfer(); } - 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); - } - }); + // --- Aufbau ------------------------------------------------------- - // Download - const downloadRemotePathInput = document.getElementById("ft-download-remote-path"); + _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) => ``) + .join(""); + const wrapper = document.createElement("div"); + wrapper.className = "session-instance"; + wrapper.innerHTML = ` +
+
+ + + + + Verbinde ... + +
+
+
+ + `; + return wrapper; } - 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"; + + _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 { - const data = await res.json(); - message = data.detail || message; - } catch (_) { /* Antwort war kein JSON */ } - throw new Error(message); + this.options.onStatusChange(text, !!isError); + } catch (_) { /* Aufrufer-Fehler duerfen die Sitzung nicht stoeren */ } } - 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); } - }); + + // --- 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); + } })(); diff --git a/static/js/workspace.js b/static/js/workspace.js new file mode 100644 index 0000000..4fb3f57 --- /dev/null +++ b/static/js/workspace.js @@ -0,0 +1,532 @@ +/* + * Arbeitsflaeche (Umsetzungsauftrag_Sonnet5.md Teil F.3): Seitenleiste + + * Hauptbereich. Nutzt dieselben Bausteine wie /terminal/{id} und /rdp/{id} + * (SshTerminalSession/RdpSession aus terminal.js/rdp.js, Stufe F1) -- keine + * zweite Implementierung (F.2). + * + * Stufe F4 (Umsetzungsauftrag_Sonnet5.md Teil F.3.2-F.3.4): mehrere + * gleichzeitige Sitzungen. Jede geoeffnete Sitzung bleibt als eigene + * SshTerminalSession/RdpSession-Instanz im DOM bestehen (this.element wird + * NICHT entfernt, wenn man wegschaltet) -- nur ueber die bestehende + * `.hidden`-Klasse (app.css, CSP-konformer Sichtbarkeits-Toggle wie an + * anderer Stelle im Projekt) ein-/ausgeblendet. Genau eine ist gleichzeitig + * sichtbar/aktiv (setActive(true)); alle anderen bleiben im Hintergrund + * voll verbunden (Betreiberentscheidung dieser Session, siehe + * FORTSETZUNG_Teil_F.md Abschnitt 0 Punkt 2 -- kein Pausenzustand). + * + * Bekannter Rest-Umfang dieser Stufe (siehe FORTSETZUNG_Teil_F.md): + * - Server-Sitzungs-ID-Zuordnung ist "best effort" (claimServerSessionId + * unten), weil terminal_ws.py/ws_tunnel.py dem Client seine eigene + * session_id an keiner Stelle mitteilen (siehe F3-Limitierung). Loest + * das Duplikat-/Verwechslungsrisiko fuer den Normalfall zuverlaessig, + * mit einem sehr kleinen, dokumentierten Restrisiko bei echten + * Wettlaeufen (siehe unten). + * - Keine Live-Aktualisierung der Seitenleiste fuer Sitzungen, die in + * ANDEREN Tabs geoeffnet/beendet werden, waehrend diese Seite laeuft -- + * nur bei Laden/Sitzungsende dieser Seite neu abgefragt (kein Polling). + * + * Stufe F5 (Umsetzungsauftrag_Sonnet5.md Teil F.3.8 + Feinschliff): + * - Obergrenzen sichtbar: GET /catalog/session-limits liefert die + * konfigurierten Werte (app/config.py) plus den aktuellen Stand: dieselbe + * Logik wie die serverseitige Durchsetzung in terminal_ws.py/ + * ws_tunnel.py (E.4), NICHT neu erfunden -- nur zum Anzeigen VOR dem + * Verbindungsversuch. "+ Neue Sitzung" und jeder "Verbinden"-Knopf im + * Katalog werden deaktiviert, sobald eine der beiden Grenzen erreicht + * ist. Die serverseitige Durchsetzung (WS-Code 4429) bleibt die + * verbindliche Kontrolle -- diese Anzeige ist rein informativ und kann + * durch die inhaerente Race zwischen Anzeige-Refresh und tatsaechlichem + * Verbindungsaufbau leicht veralten (siehe unten, wie bei + * claimServerSessionId: gleiche Klasse von Eventual-Consistency). + * - Sammelzustand-Kennzeichnung: eine Hintergrundsitzung mit Fehler oder + * Trennung wird auf ihrer Kachel UND in einer Sammel-Zeile ueber der + * Kachelliste markiert, damit man das Problem nicht erst durch + * Umschalten auf jede einzelne Sitzung entdeckt. + */ +(() => { + "use strict"; + + const tilesEl = document.getElementById("workspace-tiles"); + const catalogViewEl = document.getElementById("workspace-catalog-view"); + const sessionsAreaEl = document.getElementById("workspace-sessions-area"); + const newBtn = document.getElementById("workspace-new-btn"); + const whoamiEl = document.getElementById("whoami"); + const limitHintEl = document.getElementById("workspace-limit-hint"); + const attentionSummaryEl = document.getElementById("workspace-attention-summary"); + + let cachedHosts = []; + let remoteSessions = []; + const claimedSessionIds = new Set(); + const openSessions = []; // { uid, instance, hostId, hostname, protocol, startedAtMs, statusText, isError, elapsedSpan, tileEl, serverId } + let activeUid = null; // uid der gerade sichtbaren Sitzung, oder null (Katalog sichtbar) + let nextUid = 1; + let elapsedTimer = null; + let sessionLimits = null; // { max_per_user, current_user_count, user_limit_applies, at_user_limit, max_global, current_global_count, at_global_limit } + + async function getJson(url, opts) { + const res = await fetch(url, { credentials: "same-origin", ...(opts || {}) }); + if (res.status === 401) { + window.location.href = "/"; + throw new Error("nicht angemeldet"); + } + return res.json(); + } + + function findOpenSession(uid) { + return openSessions.find((s) => s.uid === uid) || null; + } + + // --- Server-Sitzungs-ID best effort zuordnen ------------------------- + + async function claimServerSessionId(record) { + for (let attempt = 0; attempt < 5; attempt++) { + try { + const rows = await getJson("/catalog/sessions?active_only=true"); + const candidates = rows + .filter((r) => r.host_id === record.hostId && !claimedSessionIds.has(r.id)) + .sort((a, b) => new Date(b.started_at) - new Date(a.started_at)); + if (candidates.length > 0) { + record.serverId = candidates[0].id; + claimedSessionIds.add(candidates[0].id); + return; + } + } catch (err) { + console.error("Sitzungs-ID konnte nicht abgefragt werden:", err); + } + await new Promise((resolve) => window.setTimeout(resolve, 250)); + } + console.warn( + `Server-Sitzungs-ID fuer ${record.hostname} konnte nicht ermittelt werden (best effort -- ` + + "siehe FORTSETZUNG_Teil_F.md, Stufe F4, bekannte Limitierung)." + ); + } + + // --- Obergrenzen (F5, F.3.8) ------------------------------------------- + // + // Nur eine Anzeige der bereits bestehenden serverseitigen Durchsetzung + // (E.4, terminal_ws.py/ws_tunnel.py) -- siehe Datei-Kopfkommentar. + + async function refreshSessionLimits() { + try { + sessionLimits = await getJson("/catalog/session-limits"); + } catch (err) { + console.error("Obergrenzen konnten nicht geladen werden:", err); + sessionLimits = null; + } + } + + function atAnyLimit() { + return !!sessionLimits && (sessionLimits.at_user_limit || sessionLimits.at_global_limit); + } + + function renderLimitHint() { + if (!limitHintEl) return; + if (!sessionLimits) { + limitHintEl.textContent = ""; + limitHintEl.classList.remove("workspace-limit-hint--at-limit"); + } else { + const parts = []; + if (sessionLimits.user_limit_applies) { + parts.push(`Eigene Sitzungen: ${sessionLimits.current_user_count}/${sessionLimits.max_per_user}`); + } + parts.push(`Server gesamt: ${sessionLimits.current_global_count}/${sessionLimits.max_global}`); + limitHintEl.textContent = parts.join(" · "); + limitHintEl.classList.toggle("workspace-limit-hint--at-limit", atAnyLimit()); + } + const atLimit = atAnyLimit(); + newBtn.disabled = atLimit; + newBtn.title = atLimit + ? "Obergrenze erreicht (F.3.8) -- erst eine bestehende Sitzung schliessen." + : ""; + } + + // --- Sidebar ---------------------------------------------------------- + + function statusClass(text, isError) { + if (isError) return "error"; + if (text === "Verbunden") return "connected"; + if (text === "Getrennt" || text.startsWith("Verbindung beendet")) return "disconnected"; + return "connecting"; + } + + // F5: eine Hintergrundsitzung mit Fehler oder Trennung braucht + // Aufmerksamkeit, ohne dass man erst umschaltet, um es zu sehen. + function hasAttention(record) { + const cls = statusClass(record.statusText, record.isError); + return cls === "error" || cls === "disconnected"; + } + + function formatElapsed(startedAtMs) { + const secs = Math.max(0, Math.floor((Date.now() - startedAtMs) / 1000)); + const m = Math.floor(secs / 60); + const s = secs % 60; + return `${m}:${String(s).padStart(2, "0")}`; + } + + function buildLocalTile(record) { + const isActive = record.uid === activeUid; + const tile = document.createElement("div"); + tile.className = "workspace-tile " + (isActive ? "workspace-tile-active" : "workspace-tile-background"); + tile.tabIndex = 0; + + const top = document.createElement("div"); + top.className = "workspace-tile-top"; + const dot = document.createElement("span"); + dot.className = "workspace-status-dot " + statusClass(record.statusText, record.isError); + top.appendChild(dot); + const name = document.createElement("span"); + name.className = "workspace-tile-hostname"; + name.textContent = record.hostname; + top.appendChild(name); + const proto = document.createElement("span"); + proto.className = "badge"; + proto.textContent = record.protocol.toUpperCase(); + top.appendChild(proto); + tile.appendChild(top); + + const meta = document.createElement("div"); + meta.className = "workspace-tile-meta"; + const elapsedSpan = document.createElement("span"); + elapsedSpan.className = "workspace-tile-elapsed"; + elapsedSpan.textContent = formatElapsed(record.startedAtMs); + meta.appendChild(elapsedSpan); + const statusSpan = document.createElement("span"); + let bgLabel = "im Hintergrund verbunden"; + if (!isActive && hasAttention(record)) { + tile.classList.add("workspace-tile-attention"); + bgLabel = statusClass(record.statusText, record.isError) === "error" + ? "im Hintergrund: Fehler" + : "im Hintergrund: getrennt"; + } + statusSpan.textContent = isActive ? record.statusText : bgLabel; + meta.appendChild(statusSpan); + tile.appendChild(meta); + + const closeBtn = document.createElement("button"); + closeBtn.type = "button"; + closeBtn.className = "btn-secondary btn-small workspace-tile-close-btn"; + closeBtn.textContent = "Schliessen"; + closeBtn.addEventListener("click", (ev) => { + ev.stopPropagation(); + closeLocalSession(record.uid); + }); + tile.appendChild(closeBtn); + + if (!isActive) { + tile.addEventListener("click", () => showSession(record.uid)); + tile.addEventListener("keydown", (ev) => { + if (ev.key === "Enter" || ev.key === " ") showSession(record.uid); + }); + } + + record.elapsedSpan = elapsedSpan; + return tile; + } + + function buildRemoteTile(session) { + const tile = document.createElement("div"); + tile.className = "workspace-tile workspace-tile-remote"; + + const top = document.createElement("div"); + top.className = "workspace-tile-top"; + const dot = document.createElement("span"); + dot.className = "workspace-status-dot connected"; + top.appendChild(dot); + const name = document.createElement("span"); + name.className = "workspace-tile-hostname"; + name.textContent = session.hostname; + top.appendChild(name); + const proto = document.createElement("span"); + proto.className = "badge"; + proto.textContent = session.protocol.toUpperCase(); + top.appendChild(proto); + tile.appendChild(top); + + const meta = document.createElement("div"); + meta.className = "workspace-tile-meta"; + meta.textContent = "laeuft in einem anderen Tab/Fenster"; + tile.appendChild(meta); + + const closeBtn = document.createElement("button"); + closeBtn.type = "button"; + closeBtn.className = "btn-secondary btn-small workspace-tile-close-btn"; + closeBtn.textContent = "Beenden"; + closeBtn.addEventListener("click", async () => { + closeBtn.disabled = true; + closeBtn.textContent = "Wird beendet ..."; + try { + const res = await fetch(`/catalog/sessions/${session.id}/terminate`, { + method: "POST", credentials: "same-origin", + }); + if (!res.ok && res.status !== 409) throw new Error(`HTTP ${res.status}`); + } catch (err) { + console.error("Sitzung konnte nicht beendet werden:", err); + } + await Promise.all([refreshRemoteSessions(), refreshSessionLimits()]); + renderSidebar(); + }); + tile.appendChild(closeBtn); + + return tile; + } + + function renderSidebar() { + renderLimitHint(); + + // F5: Sammelzustand -- Hintergrundsitzungen mit Fehler/Trennung ueber + // der Kachelliste zusammenfassen (die aktive Sitzung zaehlt nicht mit, + // deren Zustand sieht man ohnehin direkt). + if (attentionSummaryEl) { + const attentionCount = openSessions.filter((r) => r.uid !== activeUid && hasAttention(r)).length; + if (attentionCount > 0) { + attentionSummaryEl.textContent = attentionCount === 1 + ? "⚠ 1 Sitzung im Hintergrund mit Problem (Fehler/getrennt)." + : `⚠ ${attentionCount} Sitzungen im Hintergrund mit Problem (Fehler/getrennt).`; + attentionSummaryEl.classList.remove("hidden"); + } else { + attentionSummaryEl.textContent = ""; + attentionSummaryEl.classList.add("hidden"); + } + } + + tilesEl.innerHTML = ""; + for (const record of openSessions) { + tilesEl.appendChild(buildLocalTile(record)); + } + for (const s of remoteSessions) { + tilesEl.appendChild(buildRemoteTile(s)); + } + if (openSessions.length === 0 && remoteSessions.length === 0) { + const empty = document.createElement("div"); + empty.className = "hint workspace-empty-hint"; + empty.textContent = "Keine offenen Sitzungen."; + tilesEl.appendChild(empty); + } + + if (elapsedTimer) { + window.clearInterval(elapsedTimer); + elapsedTimer = null; + } + if (openSessions.length > 0) { + elapsedTimer = window.setInterval(() => { + for (const record of openSessions) { + if (record.elapsedSpan) record.elapsedSpan.textContent = formatElapsed(record.startedAtMs); + } + }, 1000); + } + } + + async function refreshRemoteSessions() { + try { + const rows = await getJson("/catalog/sessions?active_only=true"); + // Best-effort-Filter: alle bereits als lokal geoeffnet zugeordneten + // Server-Sitzungen (claimServerSessionId) duerfen NICHT zusaetzlich + // als "laeuft anderswo" auftauchen -- sonst erschiene jede eigene + // Sitzung dieser Seite doppelt. + remoteSessions = rows.filter((r) => !claimedSessionIds.has(r.id)); + } catch (err) { + console.error("Eigene Sitzungen konnten nicht geladen werden:", err); + remoteSessions = []; + } + } + + // --- Hauptbereich: Hostkatalog --------------------------------------- + + function hostCard(host) { + const div = document.createElement("div"); + div.className = "host-card"; + + const name = document.createElement("div"); + name.className = "hostname"; + name.textContent = host.hostname; + div.appendChild(name); + + const meta = document.createElement("div"); + meta.className = "meta"; + meta.textContent = `${host.protocol.toUpperCase()} · ${host.os_type} · ${host.address}`; + div.appendChild(meta); + + const actions = document.createElement("div"); + actions.className = "actions"; + const connectBtn = document.createElement("button"); + connectBtn.type = "button"; + connectBtn.textContent = "Verbinden"; + if (atAnyLimit()) { + // F5 (F.3.8): Obergrenze bereits VOR dem Verbindungsversuch sichtbar + // machen, statt den Nutzer erst beim WS-Handshake mit Code 4429 + // abzuweisen (serverseitige Durchsetzung bleibt unveraendert, siehe + // Datei-Kopfkommentar). + connectBtn.disabled = true; + connectBtn.title = "Obergrenze erreicht (F.3.8) -- erst eine bestehende Sitzung schliessen."; + } + connectBtn.addEventListener("click", () => startSession(host)); + actions.appendChild(connectBtn); + div.appendChild(actions); + + return div; + } + + function renderCatalog() { + catalogViewEl.innerHTML = ""; + const wrap = document.createElement("div"); + wrap.className = "container wide workspace-catalog"; + + const heading = document.createElement("h2"); + heading.textContent = "Host waehlen"; + wrap.appendChild(heading); + + if (cachedHosts.length === 0) { + const hint = document.createElement("p"); + hint.className = "hint"; + hint.textContent = "Keine Hosts zugewiesen. Bitte an einen Administrator wenden."; + wrap.appendChild(hint); + } else { + const list = document.createElement("div"); + list.className = "host-list"; + for (const host of cachedHosts) list.appendChild(hostCard(host)); + wrap.appendChild(list); + } + catalogViewEl.appendChild(wrap); + } + + // --- Umschalten zwischen Katalog und Sitzungen ------------------------- + + function showCatalog() { + activeUid = null; + for (const record of openSessions) { + record.instance.element.classList.add("hidden"); + record.instance.setActive(false); + } + sessionsAreaEl.classList.add("hidden"); + catalogViewEl.classList.remove("hidden"); + renderCatalog(); + renderSidebar(); + } + + function showSession(uid) { + const target = findOpenSession(uid); + if (!target) return; + activeUid = uid; + for (const record of openSessions) { + const visible = record.uid === uid; + record.instance.element.classList.toggle("hidden", !visible); + // F4 (F.3.2/F.3.3): setActive() steuert sowohl den Tastatur-/Paste- + // Filter (RdpSession) als auch den Refit beim Sichtbarwerden (beide + // Klassen) -- siehe terminal.js/rdp.js. + record.instance.setActive(visible); + } + catalogViewEl.classList.add("hidden"); + sessionsAreaEl.classList.remove("hidden"); + renderSidebar(); + } + + // --- Sitzung starten/beenden ------------------------------------------- + + function startSession(host) { + const uid = nextUid++; + const options = { + onStatusChange: (text, isError) => { + const record = findOpenSession(uid); + if (!record) return; + record.statusText = text; + record.isError = isError; + renderSidebar(); + }, + onExitRequested: () => { + // Der interne Exit-Knopf der Sitzung selbst wurde geklickt -- dessen + // dispose() lief bereits, hier nur noch den Arbeitsflaechen-Zustand + // nachziehen (kein zweiter dispose()-Aufruf). + handleSessionClosed(uid, /* alreadyDisposed */ true); + }, + }; + const instance = host.protocol === "rdp" ? new RdpSession(host.id, options) : new SshTerminalSession(host.id, options); + const record = { + uid, instance, hostId: host.id, hostname: host.hostname, protocol: host.protocol, + startedAtMs: Date.now(), statusText: "Verbinde ...", isError: false, serverId: null, + }; + openSessions.push(record); + instance.element.classList.add("hidden"); + sessionsAreaEl.appendChild(instance.element); + instance.connect(); + claimServerSessionId(record); + showSession(uid); + + // F5: Obergrenzen-Anzeige nachziehen -- der Server registriert die neue + // Sitzung (app/security/active_sessions.py::register) erst, nachdem der + // WS-Handshake durchgelaufen ist, daher dieselbe kleine Verzoegerung wie + // bei handleSessionClosed() unten statt eines sofortigen (noch + // veralteten) Refreshs. + window.setTimeout(() => { + refreshSessionLimits().then(() => renderSidebar()); + }, 300); + } + + function closeLocalSession(uid) { + const record = findOpenSession(uid); + if (!record) return; + record.instance.dispose(); // idempotent + handleSessionClosed(uid, /* alreadyDisposed */ true); + } + + function handleSessionClosed(uid, alreadyDisposed) { + const idx = openSessions.findIndex((s) => s.uid === uid); + if (idx === -1) return; + const [record] = openSessions.splice(idx, 1); + if (!alreadyDisposed) record.instance.dispose(); + if (record.serverId !== null) claimedSessionIds.delete(record.serverId); + + if (activeUid === uid) { + const next = openSessions[openSessions.length - 1]; + if (next) { + showSession(next.uid); + } else { + showCatalog(); + } + } else { + renderSidebar(); + } + + // Kleine Verzoegerung: der serverseitige finally-Block (WS-Handler) + // setzt ended_at erst, nachdem der Socket-Close tatsaechlich verarbeitet + // wurde -- ohne Verzoegerung koennte die soeben beendete Sitzung noch + // kurz als aktiv gelistet werden (unkritisch, naechster Refresh raeumt + // es auf, siehe FORTSETZUNG_Teil_F.md). + window.setTimeout(() => { + Promise.all([refreshRemoteSessions(), refreshSessionLimits()]).then(() => renderSidebar()); + }, 300); + } + + newBtn.addEventListener("click", () => showCatalog()); + + // --- Start ------------------------------------------------------------ + + document.getElementById("logout-btn").addEventListener("click", async () => { + await fetch("/auth/logout", { method: "POST", credentials: "same-origin" }); + window.location.href = "/"; + }); + + async function main() { + const me = await getJson("/auth/me"); + whoamiEl.textContent = `${me.username}${me.is_admin ? " (Admin)" : ""}`; + + cachedHosts = await getJson("/catalog/hosts"); + await refreshRemoteSessions(); + await refreshSessionLimits(); + renderCatalog(); + renderSidebar(); + + // ?host= (von dashboard.js "Verbinden" gesetzt): direkt verbinden + // statt erst den Katalog zu zeigen. + const params = new URLSearchParams(window.location.search); + const hostParam = params.get("host"); + if (hostParam) { + const host = cachedHosts.find((h) => String(h.id) === hostParam); + if (host) { + startSession(host); + return; + } + } + showCatalog(); + } + + main().catch((err) => console.error(err)); +})(); diff --git a/templates/admin.html b/templates/admin.html index 7b82698..6b42ea9 100644 --- a/templates/admin.html +++ b/templates/admin.html @@ -29,7 +29,6 @@ - @@ -63,7 +62,7 @@ IDBenutzernameAdminAktiv - TOTPMandantErstellt + TOTPErstellt @@ -104,10 +103,6 @@ -
- - -
@@ -117,7 +112,7 @@

Rollen fuer eine Gruppe (= Verbindungen mit der ganzen Gruppe teilen) werden im Tab "Rollen" vergeben.

- +
IDNameBeschreibungMandantMitglieder
IDNameBeschreibungMitglieder
@@ -170,16 +165,12 @@ -
- - -

Hostgruppen

- +
IDNameBeschreibungMandant
IDNameBeschreibung
@@ -251,7 +242,7 @@
- +
IDHostnameAdresseProtokollOSHostgruppeMandantStatus
IDHostnameAdresseProtokollOSHostgruppeStatus
@@ -361,10 +352,6 @@
-
- - -
@@ -389,7 +376,7 @@

Vorhandene SSH-Keys

- +
IDLabelBenutzerTypMandantPassphraseErstelltZuletzt ersetzt
IDLabelBenutzerTypPassphraseErstelltZuletzt ersetzt
@@ -463,10 +450,6 @@
-
- - -
@@ -477,7 +460,7 @@

Vorhandene RDP-Zugangsdaten

- +
IDLabelBenutzerDomaeneMandantZugewiesene ServerZuletzt rotiert
IDLabelBenutzerDomaeneZugewiesene ServerZuletzt rotiert
@@ -515,30 +498,11 @@ + +
+

Zugangsdaten fuer Gruppe freigeben (Achse B)

+

Bestimmt WOMIT sich eine Gruppe anmelden darf -- unabhaengig davon, WORAUF sie laut Rollen oben zugreifen darf. Eine Verbindung kommt nur zustande, wenn BEIDES zutrifft: die Rolle (ssh_connect/rdp_connect) auf der Hostgruppe UND eine hier freigegebene, am Host haengende Zugangsdaten-Ressource.

+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+ +
+
+ + + +
GruppeArtZugangsdatensatzAblauf
+
+