connectiopn fix round 2 3
This commit is contained in:
@ -1921,13 +1921,20 @@ async def list_sessions(
|
|||||||
result = []
|
result = []
|
||||||
for r in rows:
|
for r in rows:
|
||||||
recording_path = r[11]
|
recording_path = r[11]
|
||||||
|
is_active = r[8] is None
|
||||||
|
killable = r[0] in active_ids
|
||||||
result.append({
|
result.append({
|
||||||
"id": r[0], "user_id": r[1], "username": r[2], "host_id": r[3],
|
"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],
|
"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],
|
"started_at": r[7], "ended_at": r[8], "client_ip": r[9], "end_reason": r[10],
|
||||||
"is_active": r[8] is None,
|
"is_active": is_active,
|
||||||
"killable": r[0] in active_ids,
|
"killable": killable,
|
||||||
"has_recording": bool(recording_path) and Path(recording_path).exists(),
|
"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
|
return result
|
||||||
|
|
||||||
|
|||||||
@ -14,17 +14,27 @@ uvicorn-Worker (Standard-Deployment dieses Projekts, siehe ansible/) sieht
|
|||||||
und beendet jeder Superadmin-Request alle laufenden Sitzungen. Bei mehreren
|
und beendet jeder Superadmin-Request alle laufenden Sitzungen. Bei mehreren
|
||||||
Worker-Prozessen sind nur die Sitzungen DIESES Workers 'killable' -- die
|
Worker-Prozessen sind nur die Sitzungen DIESES Workers 'killable' -- die
|
||||||
Sessionview zeigt das ueber das 'killable'-Feld pro Sitzung an, statt einen
|
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
|
from __future__ import annotations
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass, field
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class ActiveSession:
|
class ActiveSession:
|
||||||
session_id: int
|
session_id: int
|
||||||
task: asyncio.Task
|
task: asyncio.Task
|
||||||
|
watchers: set[asyncio.Queue] = field(default_factory=set)
|
||||||
|
|
||||||
|
|
||||||
_active: dict[int, ActiveSession] = {}
|
_active: dict[int, ActiveSession] = {}
|
||||||
@ -44,3 +54,38 @@ def get(session_id: int) -> ActiveSession | None:
|
|||||||
|
|
||||||
def all_ids() -> set[int]:
|
def all_ids() -> set[int]:
|
||||||
return set(_active.keys())
|
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
|
||||||
|
|||||||
@ -8,6 +8,7 @@ from __future__ import annotations
|
|||||||
import hashlib
|
import hashlib
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
|
import asyncssh
|
||||||
from fastapi import APIRouter, Depends, HTTPException, Query, Request, UploadFile, status
|
from fastapi import APIRouter, Depends, HTTPException, Query, Request, UploadFile, status
|
||||||
from fastapi.responses import StreamingResponse
|
from fastapi.responses import StreamingResponse
|
||||||
|
|
||||||
@ -111,10 +112,33 @@ async def upload_file(
|
|||||||
# erreichbar): 400 mit Klartext statt eines unbehandelten 500.
|
# erreichbar): 400 mit Klartext statt eines unbehandelten 500.
|
||||||
logger.warning("Dateitransfer fuer Host %s nicht moeglich: %s", host_id, exc)
|
logger.warning("Dateitransfer fuer Host %s nicht moeglich: %s", host_id, exc)
|
||||||
raise HTTPException(status.HTTP_400_BAD_REQUEST, describe_connection_error(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(
|
await _log_transfer(
|
||||||
conn, user=user, host_id=host_id, client_ip=_client_ip(request), direction="upload",
|
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}
|
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.
|
# erreichbar): 400 mit Klartext statt eines unbehandelten 500.
|
||||||
logger.warning("Dateitransfer fuer Host %s nicht moeglich: %s", host_id, exc)
|
logger.warning("Dateitransfer fuer Host %s nicht moeglich: %s", host_id, exc)
|
||||||
raise HTTPException(status.HTTP_400_BAD_REQUEST, describe_connection_error(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()
|
sha256 = hashlib.sha256(data).hexdigest()
|
||||||
filename = remote_path.rsplit("/", 1)[-1]
|
filename = remote_path.rsplit("/", 1)[-1]
|
||||||
await _log_transfer(
|
await _log_transfer(
|
||||||
conn, user=user, host_id=host_id, client_ip=_client_ip(request), direction="download",
|
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():
|
def _iter():
|
||||||
|
|||||||
@ -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"))
|
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:
|
try:
|
||||||
while True:
|
while True:
|
||||||
data = await process.stdout.read(65536)
|
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):
|
if isinstance(data, str):
|
||||||
data = data.encode("utf-8", errors="replace")
|
data = data.encode("utf-8", errors="replace")
|
||||||
recorder.record("output", base64.b64encode(data).decode())
|
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()})
|
await websocket.send_json({"type": "output", "data": base64.b64encode(data).decode()})
|
||||||
except (asyncssh.Error, ConnectionResetError):
|
except (asyncssh.Error, ConnectionResetError):
|
||||||
pass
|
pass
|
||||||
@ -133,7 +140,7 @@ async def ssh_terminal(websocket: WebSocket, host_id: int):
|
|||||||
ssh_conn = await connect_to_host(conn, host_id)
|
ssh_conn = await connect_to_host(conn, host_id)
|
||||||
logger.debug("SSH-Sitzung %s: Verbindung zu %s hergestellt", session_id, host["hostname"])
|
logger.debug("SSH-Sitzung %s: Verbindung zu %s hergestellt", session_id, host["hostname"])
|
||||||
process = await ssh_conn.create_process(term_type="xterm-256color")
|
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:
|
while True:
|
||||||
try:
|
try:
|
||||||
@ -237,3 +244,60 @@ async def ssh_terminal(websocket: WebSocket, host_id: int):
|
|||||||
await websocket.close()
|
await websocket.close()
|
||||||
except Exception:
|
except Exception:
|
||||||
logger.debug("WebSocket war beim Schliessen bereits getrennt", exc_info=True)
|
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)
|
||||||
|
|||||||
@ -1364,6 +1364,11 @@
|
|||||||
} else if (s.is_active) {
|
} else if (s.is_active) {
|
||||||
actions.appendChild(el("span", { className: "hint", textContent: "anderer Prozess" }));
|
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) {
|
if (s.has_recording) {
|
||||||
actions.appendChild(
|
actions.appendChild(
|
||||||
actionButton("Wiedergabe", "btn-secondary", () => showSessionRecording(s))
|
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
|
// --- Wiedergabe (SSH: Terminal-Replay ueber xterm.js; RDP: volle
|
||||||
// grafische Wiedergabe ueber Guacamole.SessionRecording) --------------
|
// grafische Wiedergabe ueber Guacamole.SessionRecording) --------------
|
||||||
//
|
//
|
||||||
@ -1400,8 +1474,28 @@
|
|||||||
// die "output"-Eintraege deshalb exakt das Format, das die Bibliothek
|
// die "output"-Eintraege deshalb exakt das Format, das die Bibliothek
|
||||||
// erwartet -- kein eigener Player noetig.
|
// 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) {
|
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) {
|
function formatMs(ms) {
|
||||||
|
|||||||
@ -54,6 +54,11 @@
|
|||||||
const connect = document.createElement("a");
|
const connect = document.createElement("a");
|
||||||
connect.href = host.protocol === "ssh" ? `/terminal/${host.id}` : `/rdp/${host.id}`;
|
connect.href = host.protocol === "ssh" ? `/terminal/${host.id}` : `/rdp/${host.id}`;
|
||||||
connect.textContent = "Verbinden";
|
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);
|
actions.appendChild(connect);
|
||||||
|
|
||||||
if (host.can_view_credentials) {
|
if (host.can_view_credentials) {
|
||||||
|
|||||||
@ -107,4 +107,13 @@
|
|||||||
for (const keysym of keys) client.sendKeyEvent(1, keysym);
|
for (const keysym of keys) client.sendKeyEvent(1, keysym);
|
||||||
for (const keysym of keys.slice().reverse()) client.sendKeyEvent(0, 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);
|
||||||
|
});
|
||||||
})();
|
})();
|
||||||
|
|||||||
@ -206,8 +206,16 @@
|
|||||||
`/ssh/${hostId}/files/upload?remote_path=${encodeURIComponent(remotePath)}`,
|
`/ssh/${hostId}/files/upload?remote_path=${encodeURIComponent(remotePath)}`,
|
||||||
{ method: "POST", credentials: "same-origin", body: formData }
|
{ method: "POST", credentials: "same-origin", body: formData }
|
||||||
);
|
);
|
||||||
const data = await res.json();
|
// Robust gegen Nicht-JSON-Antworten (z.B. ein unbehandelter serverseitiger
|
||||||
if (!res.ok) throw new Error(data.detail || "Upload fehlgeschlagen");
|
// 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})`;
|
ftStatus.textContent = `Upload abgeschlossen (AV: ${data.av_scan_result})`;
|
||||||
recordTransfer("upload", file.name, data.size, true, "");
|
recordTransfer("upload", file.name, data.size, true, "");
|
||||||
uploadFileInput.value = "";
|
uploadFileInput.value = "";
|
||||||
|
|||||||
@ -752,6 +752,18 @@
|
|||||||
</div>
|
</div>
|
||||||
</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.js"></script>
|
||||||
<script src="/static/js/vendor/xterm-addon-fit.js"></script>
|
<script src="/static/js/vendor/xterm-addon-fit.js"></script>
|
||||||
<script src="/static/js/vendor/guacamole-common.js"></script>
|
<script src="/static/js/vendor/guacamole-common.js"></script>
|
||||||
|
|||||||
@ -13,6 +13,7 @@
|
|||||||
<button id="ctrlaltdel-btn">Strg+Alt+Entf</button>
|
<button id="ctrlaltdel-btn">Strg+Alt+Entf</button>
|
||||||
<span class="spacer"></span>
|
<span class="spacer"></span>
|
||||||
<span class="status" id="status">Verbinde ...</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>
|
||||||
<div id="rdp-display"></div>
|
<div id="rdp-display"></div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -14,6 +14,7 @@
|
|||||||
<button id="filetransfer-btn">Dateitransfer</button>
|
<button id="filetransfer-btn">Dateitransfer</button>
|
||||||
<span class="spacer"></span>
|
<span class="spacer"></span>
|
||||||
<span class="status" id="status">Verbinde ...</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>
|
||||||
<div id="terminal"></div>
|
<div id="terminal"></div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
415
tests/test_phase15.py
Normal file
415
tests/test_phase15.py
Normal 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)
|
||||||
Reference in New Issue
Block a user