connectiopn fix round 2 3

This commit is contained in:
2026-08-21 16:02:23 +02:00
parent 8c9af78672
commit 763be3e7bf
12 changed files with 708 additions and 11 deletions

View File

@ -1921,13 +1921,20 @@ async def list_sessions(
result = []
for r in rows:
recording_path = r[11]
is_active = r[8] is None
killable = r[0] in active_ids
result.append({
"id": r[0], "user_id": r[1], "username": r[2], "host_id": r[3],
"hostname": r[4], "host_group_name": r[5], "protocol": r[6],
"started_at": r[7], "ended_at": r[8], "client_ip": r[9], "end_reason": r[10],
"is_active": r[8] is None,
"killable": r[0] in active_ids,
"is_active": is_active,
"killable": killable,
"has_recording": bool(recording_path) and Path(recording_path).exists(),
# Live-Mitschau (GET /ws/sessions/{id}/watch): wie 'killable' nur
# moeglich, wenn die Sitzung auf DIESEM Worker-Prozess laeuft --
# und bisher nur fuer SSH umgesetzt (RDP haette dafuer eine eigene
# Multiplexing-Loesung fuer den guacd-Binaer-Tunnel noetig).
"watchable": is_active and killable and r[6] == "ssh",
})
return result

View File

@ -14,17 +14,27 @@ uvicorn-Worker (Standard-Deployment dieses Projekts, siehe ansible/) sieht
und beendet jeder Superadmin-Request alle laufenden Sitzungen. Bei mehreren
Worker-Prozessen sind nur die Sitzungen DIESES Workers 'killable' -- die
Sessionview zeigt das ueber das 'killable'-Feld pro Sitzung an, statt einen
Beenden-Versuch fehlschlagen zu lassen."""
Beenden-Versuch fehlschlagen zu lassen.
Seit der Live-Mitschau ("Aktive Session live ansehen", GET /ws/sessions/
{id}/watch, app/ssh_proxy/terminal_ws.py) traegt jeder Registry-Eintrag
zusaetzlich eine Menge von Beobachter-Queues: app/ssh_proxy/terminal_ws.py::
_pump_ssh_to_ws() speist jeden vom Zielsystem gelesenen Byte-Chunk sowohl an
den eigentlichen Sitzungsinhaber als auch (per broadcast()) an alle gerade
zuschauenden Superadmins. Genau wie 'killable' gilt: nur auf demselben
Worker-Prozess moeglich -- ein Beobachter kann sich nur an eine Sitzung
haengen, die in DIESER Prozess-Registry steht."""
from __future__ import annotations
import asyncio
from dataclasses import dataclass
from dataclasses import dataclass, field
@dataclass
class ActiveSession:
session_id: int
task: asyncio.Task
watchers: set[asyncio.Queue] = field(default_factory=set)
_active: dict[int, ActiveSession] = {}
@ -44,3 +54,38 @@ def get(session_id: int) -> ActiveSession | None:
def all_ids() -> set[int]:
return set(_active.keys())
def add_watcher(session_id: int) -> asyncio.Queue | None:
"""Meldet einen Beobachter fuer eine laufende Sitzung an. Gibt None
zurueck, wenn die Sitzung nicht (mehr) auf diesem Prozess laeuft --
der Aufrufer (WS-Route) beendet die Beobachter-Verbindung dann sofort."""
entry = _active.get(session_id)
if entry is None:
return None
queue: asyncio.Queue = asyncio.Queue(maxsize=1000)
entry.watchers.add(queue)
return queue
def remove_watcher(session_id: int, queue: asyncio.Queue) -> None:
entry = _active.get(session_id)
if entry is not None:
entry.watchers.discard(queue)
def broadcast(session_id: int, data: bytes) -> None:
"""Verteilt einen Ausgabe-Chunk an alle aktuell zuschauenden Beobachter
dieser Sitzung. Bewusst best-effort wie log_stream.py: ein langsamer
oder haengender Beobachter darf weder die eigentliche Sitzung
verlangsamen noch blockieren -- volle Queues verwerfen den Chunk
stattdessen (put_nowait/QueueFull), statt den Sitzungsinhaber warten zu
lassen."""
entry = _active.get(session_id)
if entry is None or not entry.watchers:
return
for queue in list(entry.watchers):
try:
queue.put_nowait(data)
except asyncio.QueueFull:
pass

View File

@ -8,6 +8,7 @@ from __future__ import annotations
import hashlib
import logging
import asyncssh
from fastapi import APIRouter, Depends, HTTPException, Query, Request, UploadFile, status
from fastapi.responses import StreamingResponse
@ -111,10 +112,33 @@ async def upload_file(
# erreichbar): 400 mit Klartext statt eines unbehandelten 500.
logger.warning("Dateitransfer fuer Host %s nicht moeglich: %s", host_id, exc)
raise HTTPException(status.HTTP_400_BAD_REQUEST, describe_connection_error(exc))
except asyncssh.Error as exc:
# Bugfix: SSH_SETUP_ERRORS deckt nur Fehler VOR der Anmeldung ab
# (Pinning/Konfiguration). asyncssh.Error ist asyncssh's gemeinsame
# Basisklasse -- das schliesst sowohl einen echten Verbindungsfehler
# WAEHREND asyncssh.connect() ein (z.B. Verbindung abgelehnt/abgebrochen,
# von connect_to_host() bewusst unuebersetzt weitergereicht) als auch
# SFTP-Fehler NACH erfolgreicher Anmeldung (z.B. SFTPNoSuchFile, wenn
# das Zielverzeichnis nicht existiert, oder SFTPPermissionDenied). Beides
# war hier bisher NICHT gefangen und lief unbehandelt bis Starlette
# durch, das bei einer unbehandelten Exception (debug=False) eine
# KLARTEXT-500-Antwort "Internal Server Error" liefert statt JSON --
# daher der Frontend-Fehler "Unexpected token 'I', 'Internal S'... is
# not valid JSON" beim Upload.
logger.warning("SFTP-Fehler bei Host %s: %s", host_id, exc)
raise HTTPException(status.HTTP_400_BAD_REQUEST, f"SFTP-Fehler: {exc}")
except HTTPException:
raise
except Exception:
# Letztes Auffangnetz (gleiches Muster wie terminal_ws.py): niemals
# eine unbehandelte Ausnahme bis zu Starlettes Klartext-500 durchreichen
# -- das Frontend erwartet hier immer eine JSON-Antwort.
logger.exception("Unerwarteter Fehler beim Datei-Upload fuer Host %s", host_id)
raise HTTPException(status.HTTP_500_INTERNAL_SERVER_ERROR, "Unerwarteter Fehler beim Dateitransfer")
await _log_transfer(
conn, user=user, host_id=host_id, client_ip=_client_ip(request), direction="upload",
filename=file.filename or remote_path, size=len(data), sha256=sha256, av_scan_result=av_result,
filename=file.filename or remote_path, size=len(data), sha256=sha256, av_result=av_result,
)
return {"status": "ok", "sha256": sha256, "size": len(data), "av_scan_result": av_result}
@ -146,12 +170,24 @@ async def download_file(
# erreichbar): 400 mit Klartext statt eines unbehandelten 500.
logger.warning("Dateitransfer fuer Host %s nicht moeglich: %s", host_id, exc)
raise HTTPException(status.HTTP_400_BAD_REQUEST, describe_connection_error(exc))
except asyncssh.Error as exc:
# Siehe ausfuehrlicher Kommentar in upload_file() weiter oben -- gleicher
# Bugfix: SFTP-Fehler nach erfolgreicher Anmeldung (z.B. Datei nicht
# gefunden, keine Leseberechtigung) liefen bisher unbehandelt bis zu
# Starlettes Klartext-500 durch.
logger.warning("SFTP-Fehler bei Host %s: %s", host_id, exc)
raise HTTPException(status.HTTP_400_BAD_REQUEST, f"SFTP-Fehler: {exc}")
except HTTPException:
raise
except Exception:
logger.exception("Unerwarteter Fehler beim Datei-Download fuer Host %s", host_id)
raise HTTPException(status.HTTP_500_INTERNAL_SERVER_ERROR, "Unerwarteter Fehler beim Dateitransfer")
sha256 = hashlib.sha256(data).hexdigest()
filename = remote_path.rsplit("/", 1)[-1]
await _log_transfer(
conn, user=user, host_id=host_id, client_ip=_client_ip(request), direction="download",
filename=filename, size=len(data), sha256=sha256, av_scan_result="not_applicable_download",
filename=filename, size=len(data), sha256=sha256, av_result="not_applicable_download",
)
def _iter():

View File

@ -56,7 +56,9 @@ async def _reject(websocket: WebSocket, code: int, reason: str, *, accepted: boo
await websocket.close(code=code, reason=reason_bytes.decode("utf-8", errors="ignore"))
async def _pump_ssh_to_ws(process: asyncssh.SSHClientProcess, websocket: WebSocket, recorder: SessionRecorder):
async def _pump_ssh_to_ws(
process: asyncssh.SSHClientProcess, websocket: WebSocket, recorder: SessionRecorder, session_id: int
):
try:
while True:
data = await process.stdout.read(65536)
@ -65,6 +67,11 @@ async def _pump_ssh_to_ws(process: asyncssh.SSHClientProcess, websocket: WebSock
if isinstance(data, str):
data = data.encode("utf-8", errors="replace")
recorder.record("output", base64.b64encode(data).decode())
# Live-Mitschau (GET /ws/sessions/{id}/watch, siehe unten): jeder
# Chunk geht zusaetzlich an alle aktuell zuschauenden Superadmins.
# Best-effort/read-only -- ein Beobachter kann diese Sitzung nicht
# beeinflussen, und sein Fehlen/Trennen hat keinen Einfluss hierauf.
active_sessions.broadcast(session_id, data)
await websocket.send_json({"type": "output", "data": base64.b64encode(data).decode()})
except (asyncssh.Error, ConnectionResetError):
pass
@ -133,7 +140,7 @@ async def ssh_terminal(websocket: WebSocket, host_id: int):
ssh_conn = await connect_to_host(conn, host_id)
logger.debug("SSH-Sitzung %s: Verbindung zu %s hergestellt", session_id, host["hostname"])
process = await ssh_conn.create_process(term_type="xterm-256color")
pump_task = asyncio.create_task(_pump_ssh_to_ws(process, websocket, recorder))
pump_task = asyncio.create_task(_pump_ssh_to_ws(process, websocket, recorder, session_id))
while True:
try:
@ -237,3 +244,60 @@ async def ssh_terminal(websocket: WebSocket, host_id: int):
await websocket.close()
except Exception:
logger.debug("WebSocket war beim Schliessen bereits getrennt", exc_info=True)
@router.websocket("/ws/sessions/{session_id}/watch")
async def watch_ssh_session(websocket: WebSocket, session_id: int):
"""Live-Mitschau einer laufenden SSH-Sitzung fuer Superadmins ('Aktive
Session mitschauen', Sessionview im Adminbereich).
Rein lesend: der Beobachter bekommt exakt dieselben "output"-Frames wie
der Sitzungsinhaber selbst (siehe _pump_ssh_to_ws()/active_sessions.
broadcast()), kann aber keine Eingaben in die fremde Sitzung schicken --
dafuer gibt es hier bewusst keinen "input"-Pfad. Eigener Top-Level-Router
unter /ws/... (siehe app/admin/log_ws.py fuer die ausfuehrliche
Begruendung: der nginx-Reverse-Proxy setzt WebSocket-Upgrade-Header nur
fuer /ws/-Pfade). Nur auf demselben Server-Prozess moeglich wie
'Beenden' -- active_sessions.add_watcher() gibt None zurueck, wenn die
Sitzung hier nicht (mehr) laeuft."""
user = await get_current_user_ws(websocket)
if user is None or not user.is_admin:
await websocket.close(code=4403)
return
conn = get_db()
row = await (await conn.execute(
"SELECT protocol, ended_at FROM sessions WHERE id = ?", (session_id,)
)).fetchone()
if row is None or row[0] != "ssh" or row[1] is not None:
await websocket.close(code=4404)
return
queue = active_sessions.add_watcher(session_id)
if queue is None:
# Sitzung existiert, laeuft aber nicht (mehr) auf diesem Worker-
# Prozess -- analog zum 'killable'-Feld bei 'Beenden'.
await websocket.close(code=4409)
return
await websocket.accept()
client_ip = websocket.client.host if websocket.client else "unknown"
await write_audit_event(
conn, event_type="session_watch_started", user_id=user.id, client_ip=client_ip,
details={"session_id": session_id},
)
await conn.commit()
logger.debug("Superadmin %s beobachtet SSH-Sitzung %s live", user.username, session_id)
try:
while True:
data = await queue.get()
await websocket.send_json({"type": "output", "data": base64.b64encode(data).decode()})
except WebSocketDisconnect:
pass
finally:
active_sessions.remove_watcher(session_id, queue)
try:
await websocket.close()
except Exception:
logger.debug("Watch-WebSocket war beim Schliessen bereits getrennt", exc_info=True)

View File

@ -1364,6 +1364,11 @@
} else if (s.is_active) {
actions.appendChild(el("span", { className: "hint", textContent: "anderer Prozess" }));
}
if (s.watchable) {
actions.appendChild(
actionButton("Live ansehen", "btn-secondary", () => watchSession(s))
);
}
if (s.has_recording) {
actions.appendChild(
actionButton("Wiedergabe", "btn-secondary", () => showSessionRecording(s))
@ -1388,6 +1393,75 @@
);
}
// --- Live-Mitschau einer aktiven SSH-Sitzung (nur lesend) --------------
//
// Verbindet sich mit GET /ws/sessions/{id}/watch (app/ssh_proxy/
// terminal_ws.py) und schreibt jedes "output"-Frame in ein eigenes,
// schreibgeschuetztes xterm.js-Terminal -- exakt dieselben Frames, die
// auch der Sitzungsinhaber selbst bekommt (active_sessions.broadcast()).
// Es gibt hier bewusst KEINEN Eingabepfad: ein Beobachter kann die
// fremde Sitzung nicht beeinflussen.
let activeWatch = null;
function closeWatchOverlay() {
document.getElementById("watch-overlay").classList.add("hidden");
if (activeWatch) {
activeWatch.destroy();
activeWatch = null;
}
document.getElementById("watch-stage").replaceChildren();
}
document.getElementById("watch-close-btn").addEventListener("click", closeWatchOverlay);
function watchSession(session) {
closeWatchOverlay();
const overlay = document.getElementById("watch-overlay");
const title = document.getElementById("watch-title");
const statusEl = document.getElementById("watch-status");
const stage = document.getElementById("watch-stage");
overlay.classList.remove("hidden");
title.textContent = `Live-Ansicht -- Sitzung #${session.id} (${session.username} @ ${session.hostname})`;
statusEl.textContent = "verbinde ...";
const container = el("div", { className: "rec-terminal" });
stage.appendChild(container);
const term = new Terminal({
cursorBlink: false, disableStdin: true, convertEol: false,
fontFamily: "Menlo, Consolas, monospace", fontSize: 13,
theme: { background: "#000000" },
});
const fitAddon = new FitAddon.FitAddon();
term.loadAddon(fitAddon);
term.open(container);
fitAddon.fit();
const proto = window.location.protocol === "https:" ? "wss:" : "ws:";
const ws = new WebSocket(`${proto}//${window.location.host}/ws/sessions/${session.id}/watch`);
ws.addEventListener("open", () => { statusEl.textContent = "verbunden (nur lesend)"; });
ws.addEventListener("close", (ev) => {
statusEl.textContent = ev.reason
? `getrennt: ${ev.reason}`
: "getrennt (Sitzung vermutlich beendet)";
});
ws.addEventListener("error", () => { statusEl.textContent = "Verbindungsfehler"; });
ws.addEventListener("message", (ev) => {
const msg = JSON.parse(ev.data);
if (msg.type === "output") {
term.write(recB64decode(msg.data));
}
});
activeWatch = {
destroy() {
try { ws.close(); } catch (_) { /* bereits getrennt */ }
term.dispose();
},
};
}
// --- Wiedergabe (SSH: Terminal-Replay ueber xterm.js; RDP: volle
// grafische Wiedergabe ueber Guacamole.SessionRecording) --------------
//
@ -1400,8 +1474,28 @@
// die "output"-Eintraege deshalb exakt das Format, das die Bibliothek
// erwartet -- kein eigener Player noetig.
// Bugfix ("SSH-Wiedergabe funktioniert nicht"): NICHT als JS-String ueber
// decodeURIComponent(escape(atob(...))) dekodieren. Jeder aufgezeichnete
// "output"-Eintrag entspricht genau einem process.stdout.read()-Chunk auf
// dem Server (app/ssh_proxy/terminal_ws.py) -- ein Mehrbyte-UTF-8-Zeichen
// (Umlaute/Sonderzeichen in Banner, Prompt, htop, ls --color etc.) kann
// dabei GENAU an der Chunk-Grenze zerschnitten sein. decodeURIComponent()
// wirft dann "URI malformed" fuer diesen Eintrag -- und weil term.write()
// in scheduleNext()/seekTo() innerhalb eines try-losen setTimeout-Callbacks
// steht, bricht genau dort die gesamte Wiedergabe-Kette endgueltig ab
// (idx wird nie mehr erhoeht, scheduleNext() nie mehr aufgerufen): die
// Wiedergabe blieb an der ersten betroffenen Stelle fuer immer haengen,
// oft schon beim Login-Banner. 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 (siehe auch
// static/js/terminal.js::b64decodeBytes fuer dieselbe Klasse Bug in der
// Live-Ansicht).
function recB64decode(b64) {
return decodeURIComponent(escape(atob(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 formatMs(ms) {

View File

@ -54,6 +54,11 @@
const connect = document.createElement("a");
connect.href = host.protocol === "ssh" ? `/terminal/${host.id}` : `/rdp/${host.id}`;
connect.textContent = "Verbinden";
// Oeffnet die Sitzung in einem eigenen Tab statt im Dashboard-Tab zu
// navigieren -- das Dashboard bleibt dadurch erreichbar, waehrend die
// Sitzung laeuft, und der Exit-Button der Sitzung (session-toolbar in
// terminal.html/rdp.html) kann genau diesen Tab wieder schliessen.
connect.target = "_blank";
actions.appendChild(connect);
if (host.can_view_credentials) {

View File

@ -107,4 +107,13 @@
for (const keysym of keys) client.sendKeyEvent(1, keysym);
for (const keysym of keys.slice().reverse()) client.sendKeyEvent(0, keysym);
});
document.getElementById("exit-btn").addEventListener("click", () => {
try { client.disconnect(); } catch (_) { /* bereits getrennt */ }
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);
});
})();

View File

@ -206,8 +206,16 @@
`/ssh/${hostId}/files/upload?remote_path=${encodeURIComponent(remotePath)}`,
{ method: "POST", credentials: "same-origin", body: formData }
);
const data = await res.json();
if (!res.ok) throw new Error(data.detail || "Upload fehlgeschlagen");
// 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 = "";

View File

@ -752,6 +752,18 @@
</div>
</div>
<!-- ================= Aktive Session live ansehen (Overlay) ================= -->
<div class="ft-overlay hidden" id="watch-overlay">
<div class="ft-panel rec-panel">
<h2 id="watch-title">Live-Ansicht
<button type="button" class="btn-secondary btn-small" id="watch-close-btn">Schliessen</button>
</h2>
<p class="hint">Nur lesend -- Eingaben werden nicht an die Sitzung uebertragen.</p>
<p class="hint">Status: <span id="watch-status">verbinde ...</span></p>
<div id="watch-stage" class="rec-stage"></div>
</div>
</div>
<script src="/static/js/vendor/xterm.js"></script>
<script src="/static/js/vendor/xterm-addon-fit.js"></script>
<script src="/static/js/vendor/guacamole-common.js"></script>

View File

@ -13,6 +13,7 @@
<button id="ctrlaltdel-btn">Strg+Alt+Entf</button>
<span class="spacer"></span>
<span class="status" id="status">Verbinde ...</span>
<button id="exit-btn" class="btn-danger btn-small" title="Sitzung beenden und Tab schliessen">Exit</button>
</div>
<div id="rdp-display"></div>
</div>

View File

@ -14,6 +14,7 @@
<button id="filetransfer-btn">Dateitransfer</button>
<span class="spacer"></span>
<span class="status" id="status">Verbinde ...</span>
<button id="exit-btn" class="btn-danger btn-small" title="Sitzung beenden und Tab schliessen">Exit</button>
</div>
<div id="terminal"></div>
</div>

415
tests/test_phase15.py Normal file
View File

@ -0,0 +1,415 @@
"""
Tests fuer Phase 15 (2026-08-21) -- Dateitransfer-500er, SSH-Wiedergabe-
Haenger und Live-Mitschau aktiver Sitzungen.
Drei voneinander unabhaengige Bugs/Features, gemeldet in derselben Session:
1) Dateitransfer-Upload/-Download endete bei einem Fehler als nackter
Klartext-500 ("Internal Server Error") statt JSON -- das Frontend crashte
beim res.json() mit "Unexpected token 'I' ...". Zwei voneinander
UNABHAENGIGE Ursachen in app/ssh_proxy/sftp.py:
a) _log_transfer() wurde mit dem falschen Keyword-Argumentnamen
aufgerufen (av_scan_result= statt av_result=) -- das feuerte bei
JEDEM erfolgreichen Transfer, ausserhalb jedes try/except.
b) except SSH_SETUP_ERRORS deckte nur Fehler VOR der Anmeldung ab;
asyncssh.Error (echter Verbindungsfehler waehrend asyncssh.connect(),
oder ein SFTP-Fehler NACH der Anmeldung wie SFTPPermissionDenied)
lief unbehandelt durch.
Siehe project memory: dateitransfer_upload_500_phase15.md.
2) "SSH-Wiedergabe funktioniert nicht": static/js/admin.js dekodierte jeden
aufgezeichneten output-Chunk einzeln ueber
decodeURIComponent(escape(atob(...))) als JS-String. Ein Mehrbyte-UTF-8-
Zeichen (Umlaute etc.), das GENAU an einer process.stdout.read()-
Chunk-Grenze zerschnitten wurde, liess das an dieser Stelle mit
"URI malformed" abstuerzen -- innerhalb eines try-losen setTimeout, was
die gesamte Wiedergabe fuer immer haengen liess. Reiner JS-Bug (kein
Python-Aequivalent hier direkt testbar) -- separat mit einem Node-Skript
verifiziert (siehe Session-Notizen); dieser Testfall sichert stattdessen
die BACKEND-Seite ab, die diesen Chunk ueberhaupt erst erzeugt.
3) "Aktive Session kann man nicht mitschauen": neue read-only Live-Mitschau
fuer Superadmins, GET /ws/sessions/{id}/watch (app/ssh_proxy/
terminal_ws.py) + app/security/active_sessions.py (Beobachter-Queues pro
Sitzung, dieselbe best-effort/put_nowait-Philosophie wie log_stream.py).
"""
from __future__ import annotations
import base64
import asyncssh
import pytest
from fastapi import HTTPException, WebSocketDisconnect
from app.security import active_sessions
from app.ssh_proxy import sftp as sftp_module
from app.ssh_proxy import terminal_ws as terminal_ws_module
class _FakeUser:
def __init__(self, *, is_admin=True, id=1, username="admin"):
self.is_admin = is_admin
self.id = id
self.username = username
class _FakeConn:
"""Minimaler aiosqlite-Ersatz fuer diese Tests -- genug fuer die
INSERT/UPDATE/SELECT-Aufrufe aus sftp.py/terminal_ws.py, ohne echte
SQLite-Semantik nachzubilden (das leisten test_phase8/9/12/13 bereits
fuer die jeweils betroffenen Endpunkte)."""
def __init__(self, select_result=None):
self._select_result = select_result
async def execute(self, sql, params=()):
class _Cursor:
lastrowid = 1
async def fetchone(self_inner):
return self._select_result
return _Cursor()
async def commit(self):
pass
# --------------------------------------------------------------------------
# 1a) Regression: _log_transfer() muss mit dem PARAMETERNAMEN aufgerufen
# werden, den es tatsaechlich definiert (av_result), nicht mit einem
# Namen, der zufaellig plausibel klingt (av_scan_result). Ein erfolg-
# reicher Transfer darf NIE eine TypeError werfen.
# --------------------------------------------------------------------------
async def test_upload_erfolgreicher_transfer_wirft_keine_typeerror(monkeypatch):
fake_conn = _FakeConn()
monkeypatch.setattr(sftp_module, "get_db", lambda: fake_conn)
async def fake_write_audit_event(*a, **kw):
pass
monkeypatch.setattr(sftp_module, "write_audit_event", fake_write_audit_event)
class _OkRemoteFile:
async def __aenter__(self):
return self
async def __aexit__(self, *a):
return False
async def write(self, data):
self.written = data
class _OkSftp:
async def __aenter__(self):
return self
async def __aexit__(self, *a):
return False
def open(self, path, mode):
return _OkRemoteFile()
class _OkSshConn:
def start_sftp_client(self):
return _OkSftp()
def close(self):
pass
async def fake_connect_to_host(conn, host_id):
return _OkSshConn()
monkeypatch.setattr(sftp_module, "connect_to_host", fake_connect_to_host)
request = sftp_module.Request()
upload = sftp_module.UploadFile("test.txt", b"hallo welt")
result = await sftp_module.upload_file(
host_id=1, request=request, remote_path="/tmp/test.txt", file=upload,
host={"file_transfer_enabled": True}, user=_FakeUser(),
)
assert result["status"] == "ok"
assert result["size"] == len(b"hallo welt")
# --------------------------------------------------------------------------
# 1b) asyncssh.Error (Verbindungsfehler NACH SSH_SETUP_ERRORS-Pruefung, oder
# ein SFTP-Fehler nach erfolgreicher Anmeldung) muss als HTTPException
# ankommen -- NIE unbehandelt durchlaufen.
# --------------------------------------------------------------------------
async def test_upload_asyncssh_error_wird_zu_http_exception_400(monkeypatch):
fake_conn = _FakeConn()
monkeypatch.setattr(sftp_module, "get_db", lambda: fake_conn)
async def fake_connect_to_host(conn, host_id):
raise asyncssh.Error(reason="Connection refused")
monkeypatch.setattr(sftp_module, "connect_to_host", fake_connect_to_host)
request = sftp_module.Request()
upload = sftp_module.UploadFile("test.txt", b"hallo welt")
with pytest.raises(HTTPException) as excinfo:
await sftp_module.upload_file(
host_id=1, request=request, remote_path="/tmp/test.txt", file=upload,
host={"file_transfer_enabled": True}, user=_FakeUser(),
)
assert excinfo.value.status_code == 400
async def test_upload_sftp_permission_denied_wird_zu_http_exception_400(monkeypatch):
"""SFTP-Fehler NACH erfolgreicher Anmeldung (z.B. Zielverzeichnis nicht
vorhanden/keine Berechtigung) sind asyncssh.SFTPError -- eine Unterklasse
von asyncssh.Error, KEINE der SSH_SETUP_ERRORS."""
fake_conn = _FakeConn()
monkeypatch.setattr(sftp_module, "get_db", lambda: fake_conn)
class _DeniedSftp:
async def __aenter__(self):
return self
async def __aexit__(self, *a):
return False
def open(self, path, mode):
raise asyncssh.SFTPPermissionDenied(reason="Permission denied")
class _SshConn:
def start_sftp_client(self):
return _DeniedSftp()
def close(self):
pass
async def fake_connect_to_host(conn, host_id):
return _SshConn()
monkeypatch.setattr(sftp_module, "connect_to_host", fake_connect_to_host)
request = sftp_module.Request()
upload = sftp_module.UploadFile("test.txt", b"hallo welt")
with pytest.raises(HTTPException) as excinfo:
await sftp_module.upload_file(
host_id=1, request=request, remote_path="/root/blocked.txt", file=upload,
host={"file_transfer_enabled": True}, user=_FakeUser(),
)
assert excinfo.value.status_code == 400
async def test_upload_unerwarteter_fehler_wird_zu_http_exception_500_nicht_unbehandelt(monkeypatch):
"""Letztes Auffangnetz: selbst ein voellig unverwandter Bug (hier
simuliert per AttributeError) darf NIE unbehandelt bis Starlettes
Klartext-500 durchlaufen -- das Frontend braucht immer JSON."""
fake_conn = _FakeConn()
monkeypatch.setattr(sftp_module, "get_db", lambda: fake_conn)
async def fake_connect_to_host(conn, host_id):
raise AttributeError("simuliert einen unverwandten kuenftigen Bug")
monkeypatch.setattr(sftp_module, "connect_to_host", fake_connect_to_host)
request = sftp_module.Request()
upload = sftp_module.UploadFile("test.txt", b"hallo welt")
with pytest.raises(HTTPException) as excinfo:
await sftp_module.upload_file(
host_id=1, request=request, remote_path="/tmp/test.txt", file=upload,
host={"file_transfer_enabled": True}, user=_FakeUser(),
)
assert excinfo.value.status_code == 500
async def test_download_datei_zu_gross_bleibt_413_und_wird_nicht_zu_500(monkeypatch):
"""Regressionsschutz fuer den neuen Catch-all: die bereits bestehende
HTTPException(413) aus dem inneren try-Block darf NICHT vom neuen
'except Exception' verschluckt und zu einem 500 umgemuenzt werden."""
fake_conn = _FakeConn()
monkeypatch.setattr(sftp_module, "get_db", lambda: fake_conn)
class _Stat:
size = sftp_module.MAX_UPLOAD_BYTES + 1
class _HugeSftp:
async def __aenter__(self):
return self
async def __aexit__(self, *a):
return False
async def stat(self, path):
return _Stat()
class _SshConn:
def start_sftp_client(self):
return _HugeSftp()
def close(self):
pass
async def fake_connect_to_host(conn, host_id):
return _SshConn()
monkeypatch.setattr(sftp_module, "connect_to_host", fake_connect_to_host)
request = sftp_module.Request()
with pytest.raises(HTTPException) as excinfo:
await sftp_module.download_file(
host_id=1, request=request, remote_path="/tmp/huge.bin",
host={"file_transfer_enabled": True}, user=_FakeUser(),
)
assert excinfo.value.status_code == 413
# --------------------------------------------------------------------------
# 3) Live-Mitschau: app/security/active_sessions.py Beobachter-Queues.
# --------------------------------------------------------------------------
async def test_active_sessions_broadcast_erreicht_alle_beobachter(event_loop=None):
import asyncio
task = asyncio.current_task()
active_sessions.register(session_id=4242, task=task)
try:
q1 = active_sessions.add_watcher(4242)
q2 = active_sessions.add_watcher(4242)
assert q1 is not None and q2 is not None and q1 is not q2
active_sessions.broadcast(4242, b"chunk-1")
assert q1.get_nowait() == b"chunk-1"
assert q2.get_nowait() == b"chunk-1"
active_sessions.remove_watcher(4242, q1)
active_sessions.broadcast(4242, b"chunk-2")
assert q2.get_nowait() == b"chunk-2"
assert q1.empty()
finally:
active_sessions.unregister(4242)
async def test_active_sessions_add_watcher_auf_unbekannter_sitzung_gibt_none():
assert active_sessions.get(999999) is None
assert active_sessions.add_watcher(999999) is None
# --------------------------------------------------------------------------
# 3) Live-Mitschau: WS-Route -- Zugriffskontrolle + Frame-Weiterleitung.
# --------------------------------------------------------------------------
class _FakeWebSocket:
def __init__(self):
self.accepted = False
self.sent = []
self.closes = []
self.client = type("C", (), {"host": "10.0.0.5"})()
async def accept(self):
self.accepted = True
async def send_json(self, obj):
self.sent.append(obj)
async def close(self, code=1000, reason=""):
self.closes.append((code, reason))
async def test_watch_ssh_session_lehnt_nicht_admin_ab(monkeypatch):
async def as_normal_user(ws):
return _FakeUser(is_admin=False)
monkeypatch.setattr(terminal_ws_module, "get_current_user_ws", as_normal_user)
ws = _FakeWebSocket()
await terminal_ws_module.watch_ssh_session(ws, 1)
assert ws.closes == [(4403, "")]
assert ws.accepted is False
async def test_watch_ssh_session_lehnt_beendete_sitzung_ab(monkeypatch):
async def as_admin(ws):
return _FakeUser(is_admin=True)
monkeypatch.setattr(terminal_ws_module, "get_current_user_ws", as_admin)
monkeypatch.setattr(
terminal_ws_module, "get_db",
lambda: _FakeConn(select_result=("ssh", "2026-08-21T10:00:00Z")),
)
ws = _FakeWebSocket()
await terminal_ws_module.watch_ssh_session(ws, 1)
assert ws.closes == [(4404, "")]
assert ws.accepted is False
async def test_watch_ssh_session_lehnt_rdp_ab(monkeypatch):
async def as_admin(ws):
return _FakeUser(is_admin=True)
monkeypatch.setattr(terminal_ws_module, "get_current_user_ws", as_admin)
monkeypatch.setattr(
terminal_ws_module, "get_db", lambda: _FakeConn(select_result=("rdp", None))
)
ws = _FakeWebSocket()
await terminal_ws_module.watch_ssh_session(ws, 1)
assert ws.closes == [(4404, "")]
assert ws.accepted is False
async def test_watch_ssh_session_leitet_frames_weiter_und_raeumt_beobachter_auf(monkeypatch):
import asyncio
async def as_admin(ws):
return _FakeUser(is_admin=True)
monkeypatch.setattr(terminal_ws_module, "get_current_user_ws", as_admin)
monkeypatch.setattr(
terminal_ws_module, "get_db", lambda: _FakeConn(select_result=("ssh", None))
)
audit_calls = []
async def fake_write_audit_event(conn, **kw):
audit_calls.append(kw)
monkeypatch.setattr(terminal_ws_module, "write_audit_event", fake_write_audit_event)
active_sessions.register(session_id=1, task=asyncio.current_task())
try:
class _WsThenDisconnect(_FakeWebSocket):
def __init__(self):
super().__init__()
self._n = 0
async def send_json(self, obj):
await super().send_json(obj)
self._n += 1
if self._n >= 2:
raise WebSocketDisconnect()
ws = _WsThenDisconnect()
async def feeder():
await asyncio.sleep(0.01)
active_sessions.broadcast(1, b"frame-1")
await asyncio.sleep(0.01)
active_sessions.broadcast(1, b"frame-2")
feeder_task = asyncio.create_task(feeder())
await terminal_ws_module.watch_ssh_session(ws, 1)
await feeder_task
assert ws.accepted is True
assert [f["data"] for f in ws.sent] == [
base64.b64encode(b"frame-1").decode(),
base64.b64encode(b"frame-2").decode(),
]
# Beobachter muss nach dem Trennen entfernt sein (kein Leak).
assert len(active_sessions.get(1).watchers) == 0
assert len(audit_calls) == 1
assert audit_calls[0]["event_type"] == "session_watch_started"
finally:
active_sessions.unregister(1)