umbau 1.0

This commit is contained in:
2026-09-02 20:30:44 +02:00
parent afe6719f51
commit 5c95b21be7
77 changed files with 10733 additions and 1914 deletions

View File

@ -15,6 +15,7 @@ import logging
import asyncssh
import aiosqlite
from app.rbac import AmbiguousCredentialError, resolve_credential_for_user_on_host
from app.security.crypto import decrypt_secret
logger = logging.getLogger("jumphost.ssh_proxy")
@ -215,6 +216,10 @@ SSH_SETUP_ERRORS: tuple[type[Exception], ...] = (
HostKeyMismatchError,
HostKeyDiscoveryError,
PrivateKeyUnusableError,
# Teil D Schritt 4 (Achse B): mehr als ein fuer den Benutzer nutzbarer
# SSH-Key an einem Host -- eine Konfigurationsfrage, keine interne
# Ausnahme, gehoert deshalb in dieselbe Klasse wie die Eintraege oben.
AmbiguousCredentialError,
)
@ -229,6 +234,13 @@ def describe_connection_error(exc: Exception) -> str:
)
if isinstance(exc, HostKeyDiscoveryError):
return f"Das Ziel ist nicht erreichbar: {exc.reason}"
if isinstance(exc, AmbiguousCredentialError):
return (
f"Mehrdeutige Zugangsdaten fuer diesen Host: mehrere freigegebene SSH-Schluessel "
f"(IDs {exc.credential_ids}) treffen fuer Sie zu, keine automatische Auswahl "
"moeglich. Bitte im Adminbereich bereinigen (ueberzaehligen Schluessel entfernen "
"oder Gruppenfreigabe einschraenken)."
)
return str(exc)
@ -294,13 +306,22 @@ async def load_host(conn: aiosqlite.Connection, host_id: int) -> dict:
async def load_ssh_key_credential_for_host(
conn: aiosqlite.Connection, host_id: int
conn: aiosqlite.Connection, host_id: int, *, user_id: int
) -> tuple[asyncssh.SSHKey, str | None] | None:
"""Laedt den dem Host zugeordneten Schluessel, entschluesselt ihn und gibt
ihn zusammen mit dem am Schluessel hinterlegten Benutzernamen zurueck.
Gibt None zurueck (statt zu werfen), wenn kein Schluessel zugeordnet ist
-- der Aufrufer (connect_to_host) faellt dann auf ein SSH-Passwort
zurueck, falls eines hinterlegt ist (Migration 0011).
"""Laedt den fuer DIESEN Benutzer geltenden, dem Host zugeordneten
Schluessel, entschluesselt ihn und gibt ihn zusammen mit dem am
Schluessel hinterlegten Benutzernamen zurueck. Gibt None zurueck (statt
zu werfen), wenn kein Schluessel zutrifft -- der Aufrufer
(connect_to_host) faellt dann auf ein SSH-Passwort zurueck, falls eines
hinterlegt ist (Migration 0011).
Seit Teil D Schritt 4 (Achse B) laeuft die Auswahl NICHT mehr blind ueber
den Host allein: app.rbac.resolve_credential_for_user_on_host()
beruecksichtigt zusaetzlich, welcher der dem Host zugeordneten
Schluessel ueber group_ssh_key_grants mindestens einer Benutzergruppe
dieses Users freigegeben ist. Wirft AmbiguousCredentialError, wenn mehr
als ein Schluessel zutrifft (bewusst kein stilles LIMIT 1 mehr, siehe
dortige Docstring).
Der Benutzername gehoert seit Migration 0010 zu den Zugangsdaten
(ssh_keys.username) und nicht mehr zum Host: er ist Teil der Anmeldung,
@ -308,11 +329,14 @@ async def load_ssh_key_credential_for_host(
Passphrase hinterlegt (ssh_keys.passphrase_enc, Migration 0009), wird sie
ebenfalls entschluesselt und an asyncssh uebergeben.
"""
ssh_key_id = await resolve_credential_for_user_on_host(
conn, user_id=user_id, host_id=host_id, kind="ssh_key"
)
if ssh_key_id is None:
return None
cursor = await conn.execute(
"SELECT sk.private_key_enc, sk.passphrase_enc, sk.username FROM ssh_keys sk "
"JOIN host_ssh_key_map m ON m.ssh_key_id = sk.id "
"WHERE m.host_id = ? LIMIT 1",
(host_id,),
"SELECT private_key_enc, passphrase_enc, username FROM ssh_keys WHERE id = ?",
(ssh_key_id,),
)
row = await cursor.fetchone()
if row is None:
@ -332,14 +356,25 @@ async def load_ssh_key_credential_for_host(
async def load_ssh_password_credential_for_host(
conn: aiosqlite.Connection, host_id: int
conn: aiosqlite.Connection, host_id: int, *, user_id: int
) -> tuple[str, str] | None:
"""Laedt das (Passwort, Benutzername)-Paar fuer einen Host OHNE
zugeordneten SSH-Key (ssh_password_credentials, Migration 0011). Gibt
None zurueck, wenn kein SSH-Passwort hinterlegt ist."""
"""Laedt das (Passwort, Benutzername)-Paar, das fuer DIESEN Benutzer auf
einem Host OHNE (fuer ihn nutzbaren) SSH-Key gilt (ssh_password_credentials,
Migration 0011, seit Migration 0016 ueber host_ssh_password_credential_map
referenziert -- eigenstaendiges Objekt mit eigener ID, siehe dortiger
Kommentar). Gibt None zurueck, wenn kein SSH-Passwort zutrifft. Wie beim
SSH-Key laeuft die Auswahl seit Teil D Schritt 4 ueber
app.rbac.resolve_credential_for_user_on_host() (Achse B); strukturell
kann hier keine Mehrdeutigkeit auftreten (host_ssh_password_credential_map
hat PK auf host_id, also hoechstens ein Treffer)."""
credential_id = await resolve_credential_for_user_on_host(
conn, user_id=user_id, host_id=host_id, kind="ssh_password_credential"
)
if credential_id is None:
return None
cursor = await conn.execute(
"SELECT password_enc, username FROM ssh_password_credentials WHERE host_id = ?",
(host_id,),
"SELECT password_enc, username FROM ssh_password_credentials WHERE id = ?",
(credential_id,),
)
row = await cursor.fetchone()
if row is None:
@ -351,12 +386,15 @@ async def load_ssh_password_credential_for_host(
del password
async def load_private_key_for_host(conn: aiosqlite.Connection, host_id: int) -> asyncssh.SSHKey:
async def load_private_key_for_host(
conn: aiosqlite.Connection, host_id: int, *, user_id: int
) -> asyncssh.SSHKey:
"""Rueckwaertskompatible Fassung ohne Benutzername (siehe
load_ssh_key_credential_for_host). Wirft HostNotConfiguredError, wenn kein
Schluessel zugeordnet ist -- anders als connect_to_host beruecksichtigt
diese Fassung KEIN SSH-Passwort als Alternative."""
result = await load_ssh_key_credential_for_host(conn, host_id)
load_ssh_key_credential_for_host). Wirft HostNotConfiguredError, wenn
kein fuer diesen Benutzer nutzbarer Schluessel zutrifft -- anders als
connect_to_host beruecksichtigt diese Fassung KEIN SSH-Passwort als
Alternative."""
result = await load_ssh_key_credential_for_host(conn, host_id, user_id=user_id)
if result is None:
raise HostNotConfiguredError(f"Kein SSH-Schluessel fuer Host {host_id} hinterlegt")
key, _username = result
@ -484,7 +522,9 @@ def _public_key_body(openssh_line: str) -> tuple[str, str]:
return (parts[0], parts[1])
async def connect_to_host(conn: aiosqlite.Connection, host_id: int) -> asyncssh.SSHClientConnection:
async def connect_to_host(
conn: aiosqlite.Connection, host_id: int, *, user_id: int
) -> asyncssh.SSHClientConnection:
host = await load_host(conn, host_id)
if host["protocol"] != "ssh":
raise HostNotConfiguredError("Host ist kein SSH-Ziel")
@ -496,13 +536,23 @@ async def connect_to_host(conn: aiosqlite.Connection, host_id: int) -> asyncssh.
# SSH-Key hat immer Vorrang vor einem SSH-Passwort (Migration 0011,
# "Linux kann statt SSH-Key auch Passwort haben" -- explizit als
# Alternative gewuenscht, nicht als gleichrangige zweite Option: ist ein
# Schluessel zugeordnet, wird er benutzt, unabhaengig davon, ob zusaetzlich
# ein Passwort hinterlegt ist).
key_credential = await load_ssh_key_credential_for_host(conn, host_id)
password_credential = None if key_credential is not None else await load_ssh_password_credential_for_host(conn, host_id)
# fuer DIESEN Benutzer nutzbarer Schluessel zugeordnet, wird er benutzt,
# unabhaengig davon, ob zusaetzlich ein Passwort hinterlegt ist). Beide
# Aufloesungen sind seit Teil D Schritt 4 benutzerabhaengig (Achse B) --
# siehe load_ssh_key_credential_for_host()/load_ssh_password_credential_for_host().
key_credential = await load_ssh_key_credential_for_host(conn, host_id, user_id=user_id)
password_credential = (
None
if key_credential is not None
else await load_ssh_password_credential_for_host(conn, host_id, user_id=user_id)
)
if key_credential is None and password_credential is None:
raise HostNotConfiguredError(
f"Weder ein SSH-Schluessel noch ein SSH-Passwort fuer Host {host_id} hinterlegt"
f"Weder ein SSH-Schluessel noch ein SSH-Passwort fuer Host {host_id} hinterlegt "
"oder Ihrer Benutzergruppe freigegeben. Falls Sie ueber eine Gruppe "
"'ssh_connect' fuer diesen Host haben, aber trotzdem diese Meldung sehen: "
"die Zugangsdaten sind zwar zugeordnet, aber keiner Ihrer Gruppen ueber "
"Achse B freigegeben (Adminbereich -> Benutzergruppen -> Zugangsdaten)."
)
if key_credential is not None:

View File

@ -5,6 +5,7 @@ Groessenlimit, Sha256-Hashing und optionaler AV-Scan sind Pflicht (Konzept
"""
from __future__ import annotations
import asyncio
import hashlib
import logging
@ -13,6 +14,7 @@ from fastapi import APIRouter, Depends, HTTPException, Query, Request, UploadFil
from fastapi.responses import StreamingResponse
from app.auth.deps import CurrentUser, get_current_user
from app.config import settings
from app.db import get_db
from app.rbac import user_has_role_for_host
from app.security.audit import write_audit_event
@ -31,6 +33,20 @@ router = APIRouter(prefix="/ssh", tags=["file-transfer"])
MAX_UPLOAD_BYTES = 200 * 1024 * 1024 # 200 MiB, ueber Ansible-Variable konfigurierbar (siehe Konzept)
# E4 (Umsetzungsauftrag Teil E): Ein-/Auslesen in Bloecken statt in einem
# einzigen file.read(MAX_UPLOAD_BYTES + 1)/remote_file.read()-Aufruf --
# sowohl fuers Streaming zum/vom SFTP-Ziel als auch fuer inkrementelles
# Hashing (ein einzelner hashlib.sha256(<200 MiB>)-Aufruf haelt den
# Event-Loop zwar nur kurz, aber synchron und ohne jede Zwischen-await-
# Gelegenheit an -- inkrementelles Update() je Chunk verteilt das).
TRANSFER_CHUNK_BYTES = 1 * 1024 * 1024 # 1 MiB
# E4: Obergrenze gleichzeitig laufender Transfers -- Prozess-weiter
# In-Memory-Zustand wie login_rate_limiter/active_sessions (siehe E.0),
# bewusst NICHT pro Worker (es gibt ohnehin nur einen, --workers waere laut
# E.0 ohnehin keine Option).
_transfer_semaphore = asyncio.Semaphore(settings.max_concurrent_transfers)
def _client_ip(request: Request) -> str:
return request.client.host if request.client else "unknown"
@ -80,66 +96,103 @@ async def upload_file(
host=Depends(_require_file_transfer),
user: CurrentUser = Depends(get_current_user),
):
data = await file.read(MAX_UPLOAD_BYTES + 1)
if len(data) > MAX_UPLOAD_BYTES:
raise HTTPException(status.HTTP_413_CONTENT_TOO_LARGE, "Datei zu gross")
# E4 (Umsetzungsauftrag Teil E): in Bloecken statt in einem einzigen
# file.read(MAX_UPLOAD_BYTES + 1)-Aufruf lesen. Der AV-Scan (scan_bytes)
# braucht weiterhin den vollstaendigen Inhalt in einem Stueck -- das
# Limit wird deshalb WAEHREND des Einlesens durchgesetzt (frueher
# Abbruch, sobald es ueberschritten ist, statt still bis MAX+1 zu lesen),
# und das Hashing laeuft inkrementell mit, statt als ein einzelner
# blockierender hashlib.sha256(<gesamte Datei>)-Aufruf am Ende.
hasher = hashlib.sha256()
chunks: list[bytes] = []
total = 0
while True:
chunk = await file.read(TRANSFER_CHUNK_BYTES)
if not chunk:
break
total += len(chunk)
if total > MAX_UPLOAD_BYTES:
raise HTTPException(status.HTTP_413_CONTENT_TOO_LARGE, "Datei zu gross")
hasher.update(chunk)
chunks.append(chunk)
data = b"".join(chunks)
del chunks
sha256 = hasher.hexdigest()
async with _transfer_semaphore:
# E3 (Umsetzungsauftrag Teil E): scan_bytes() ruft synchron
# subprocess.run(..., timeout=30) auf -- bis zu 30 Sekunden, in denen
# der EINZIGE Event-Loop der Anwendung fuer ALLE Benutzer stillstand
# (siehe E.0). asyncio.to_thread() lagert den blockierenden Aufruf in
# einen Worker-Thread aus.
av_result = await asyncio.to_thread(scan_bytes, data)
if av_result.startswith("infected"):
conn = get_db()
await write_audit_event(
conn, event_type="file_transfer_blocked_malware", user_id=user.id,
client_ip=_client_ip(request),
details={"host_id": host_id, "filename": file.filename, "av_scan_result": av_result},
)
await conn.commit()
raise HTTPException(status.HTTP_400_BAD_REQUEST, f"Datei durch AV-Scan blockiert: {av_result}")
av_result = scan_bytes(data)
if av_result.startswith("infected"):
conn = get_db()
await write_audit_event(
conn, event_type="file_transfer_blocked_malware", user_id=user.id,
client_ip=_client_ip(request),
details={"host_id": host_id, "filename": file.filename, "av_scan_result": av_result},
)
await conn.commit()
raise HTTPException(status.HTTP_400_BAD_REQUEST, f"Datei durch AV-Scan blockiert: {av_result}")
sha256 = hashlib.sha256(data).hexdigest()
conn = get_db()
try:
ssh_conn = await connect_to_host(conn, host_id)
try:
async with ssh_conn.start_sftp_client() as sftp:
async with sftp.open(remote_path, "wb") as remote_file:
await remote_file.write(data)
finally:
ssh_conn.close()
except SSH_SETUP_ERRORS as exc:
# Alles, was den Verbindungsaufbau verhindert und in der Konfiguration
# begruendet ist (fehlender Benutzername/Schluessel, unbrauchbarer
# Schluessel, nicht gepinnter oder abweichender Host-Key, Ziel nicht
# 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")
ssh_conn = await connect_to_host(conn, host_id, user_id=user.id)
try:
async with ssh_conn.start_sftp_client() as sftp:
async with sftp.open(remote_path, "wb") as remote_file:
# In Bloecken schreiben statt eines einzelnen
# write(<gesamte Datei>): der AV-Scan (scan_bytes)
# braucht den vollstaendigen Inhalt vor dem Schreiben
# in einem Stueck, `data` haelt die Datei deshalb
# weiterhin komplett im Speicher (unveraendert
# gegenueber vorher) -- echtes speicherbegrenztes
# Streaming ist beim Upload durch das "erst scannen,
# dann schreiben"-Erfordernis inhaerent nicht moeglich,
# ohne den AV-Scan selbst auf Datei-/Stream-Basis
# umzustellen. Der chunk-weise write() vermeidet
# zumindest einen einzelnen sehr grossen asyncssh-
# Aufruf und haelt jeden einzelnen await kurz.
for offset in range(0, len(data), TRANSFER_CHUNK_BYTES):
await remote_file.write(data[offset:offset + TRANSFER_CHUNK_BYTES])
finally:
ssh_conn.close()
except SSH_SETUP_ERRORS as exc:
# Alles, was den Verbindungsaufbau verhindert und in der Konfiguration
# begruendet ist (fehlender Benutzername/Schluessel, unbrauchbarer
# Schluessel, nicht gepinnter oder abweichender Host-Key, Ziel nicht
# 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_result=av_result,
)
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_result=av_result,
)
return {"status": "ok", "sha256": sha256, "size": len(data), "av_scan_result": av_result}
@ -151,23 +204,44 @@ async def download_file(
host=Depends(_require_file_transfer),
user: CurrentUser = Depends(get_current_user),
):
# E4 (Umsetzungsauftrag Teil E): echtes Streaming statt
# remote_file.read() der kompletten Datei in einen einzigen `data`-Puffer
# gefolgt von StreamingResponse(yield data) (das war effektiv KEIN
# Streaming -- der gesamte Speicher-Peak war identisch zu vorher, nur in
# eine andere Form verpackt). Anders als beim Upload gibt es beim
# Download KEINEN AV-Scan-Zwang, der den vollstaendigen Inhalt vor dem
# Weiterreichen braucht -- echtes chunkweises Streaming direkt in die
# HTTP-Antwort ist hier also tatsaechlich moeglich und sinnvoll.
#
# Verbindungsaufbau + stat() laufen bewusst NOCH VOR der
# StreamingResponse (mit vollstaendiger Fehlerbehandlung wie bisher) --
# sobald die Antwort einmal zu streamen begonnen hat, kann der
# HTTP-Statuscode nicht mehr geaendert werden. sftp/remote_file bleiben
# ueber die gesamte Dauer des Streams offen und werden erst im
# finally-Block des Generators geschlossen.
conn = get_db()
await _transfer_semaphore.acquire()
try:
ssh_conn = await connect_to_host(conn, host_id)
ssh_conn = await connect_to_host(conn, host_id, user_id=user.id)
try:
async with ssh_conn.start_sftp_client() as sftp:
sftp = await ssh_conn.start_sftp_client()
try:
stat = await sftp.stat(remote_path)
if stat.size and stat.size > MAX_UPLOAD_BYTES:
raise HTTPException(status.HTTP_413_CONTENT_TOO_LARGE, "Datei zu gross")
async with sftp.open(remote_path, "rb") as remote_file:
data = await remote_file.read()
finally:
remote_file = await sftp.open(remote_path, "rb")
except Exception:
sftp.exit()
raise
except Exception:
ssh_conn.close()
raise
except SSH_SETUP_ERRORS as exc:
# Alles, was den Verbindungsaufbau verhindert und in der Konfiguration
# begruendet ist (fehlender Benutzername/Schluessel, unbrauchbarer
# Schluessel, nicht gepinnter oder abweichender Host-Key, Ziel nicht
# erreichbar): 400 mit Klartext statt eines unbehandelten 500.
_transfer_semaphore.release()
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:
@ -175,26 +249,66 @@ async def download_file(
# Bugfix: SFTP-Fehler nach erfolgreicher Anmeldung (z.B. Datei nicht
# gefunden, keine Leseberechtigung) liefen bisher unbehandelt bis zu
# Starlettes Klartext-500 durch.
_transfer_semaphore.release()
logger.warning("SFTP-Fehler bei Host %s: %s", host_id, exc)
raise HTTPException(status.HTTP_400_BAD_REQUEST, f"SFTP-Fehler: {exc}")
except HTTPException:
_transfer_semaphore.release()
raise
except Exception:
_transfer_semaphore.release()
logger.exception("Unerwarteter Fehler beim Datei-Download fuer Host %s", host_id)
raise HTTPException(status.HTTP_500_INTERNAL_SERVER_ERROR, "Unerwarteter Fehler beim Dateitransfer")
# Ab hier (kein except griff) ist der Stream erfolgreich eroeffnet --
# das Semaphor wird bewusst NICHT hier freigegeben, sondern erst im
# finally-Block von _stream() unten, sobald der komplette Download
# abgeschlossen (oder abgebrochen) ist.
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_result="not_applicable_download",
)
def _iter():
yield data
async def _stream():
hasher = hashlib.sha256()
total = 0
try:
while True:
chunk = await remote_file.read(TRANSFER_CHUNK_BYTES)
if not chunk:
break
total += len(chunk)
hasher.update(chunk)
yield chunk
finally:
try:
# SFTPClientFile wird andernorts in diesem Modul immer als
# `async with sftp.open(...) as remote_file:` verwendet --
# das bedeutet close() ist dort eine Koroutine (__aexit__
# ruft sie awaited auf). Hier manuell dasselbe nachgebildet,
# da der Dateihandle ueber die gesamte Stream-Dauer offen
# bleiben muss und daher nicht in einem `async with` um nur
# den Lesevorgang herum verwaltet werden kann.
await remote_file.close()
except Exception:
pass
sftp.exit()
ssh_conn.close()
_transfer_semaphore.release()
try:
await _log_transfer(
conn, user=user, host_id=host_id, client_ip=_client_ip(request), direction="download",
filename=filename, size=total, sha256=hasher.hexdigest(), av_result="not_applicable_download",
)
except Exception:
# Der Download selbst ist zu diesem Zeitpunkt beim Client
# bereits (teilweise) angekommen -- ein fehlgeschlagener
# Logeintrag darf den bereits laufenden Stream nicht mehr
# rueckwirkend als Fehler erscheinen lassen, muss aber sichtbar
# sein.
logger.exception(
"Download-Protokollierung fuer Host %s / %s fehlgeschlagen", host_id, remote_path
)
return StreamingResponse(
_iter(),
_stream(),
media_type="application/octet-stream",
headers={"Content-Disposition": f'attachment; filename="{filename}"'},
)

View File

@ -13,11 +13,13 @@ from __future__ import annotations
import asyncio
import base64
import logging
import time
import asyncssh
from fastapi import APIRouter, WebSocket, WebSocketDisconnect
from app.auth.deps import get_current_user_ws
from app.config import settings
from app.db import get_db
from app.rbac import user_has_role_for_host
from app.recordings.recorder import SessionRecorder
@ -35,8 +37,17 @@ from app.ssh_proxy.proxy import (
logger = logging.getLogger("jumphost.ssh_proxy.ws")
router = APIRouter()
MAX_SESSION_SECONDS = 8 * 3600
IDLE_TIMEOUT_SECONDS = 15 * 60
# E10/E11 (Umsetzungsauftrag Teil E): frueher feste Modul-Konstanten, jetzt
# konfigurierbar (siehe app/config.py). MAX_SESSION_SECONDS wurde vorher
# nirgends ausgewertet -- es gab de facto GAR KEINE absolute Obergrenze.
IDLE_TIMEOUT_SECONDS = settings.ssh_idle_timeout_s
MAX_SESSION_SECONDS = settings.ssh_max_session_seconds
MAX_SESSION_WARNING_S = settings.ssh_max_session_warning_s
# Wie oft die Haupt-Schleife hoechstens "blind" auf eine Benutzereingabe
# wartet, bevor sie den gemeinsamen Aktivitaets-/Laufzeitstand neu prueft.
# Niedrig genug, um Idle-Timeout und Sitzungsobergrenze zeitnah durchsetzen
# zu koennen, aber hoch genug, um nicht sinnlos oft zu pollen.
_POLL_INTERVAL_S = 20
async def _reject(websocket: WebSocket, code: int, reason: str, *, accepted: bool) -> None:
@ -57,7 +68,8 @@ async def _reject(websocket: WebSocket, code: int, reason: str, *, accepted: boo
async def _pump_ssh_to_ws(
process: asyncssh.SSHClientProcess, websocket: WebSocket, recorder: SessionRecorder, session_id: int
process: asyncssh.SSHClientProcess, websocket: WebSocket, recorder: SessionRecorder, session_id: int,
activity: dict,
):
try:
while True:
@ -66,6 +78,12 @@ async def _pump_ssh_to_ws(
break
if isinstance(data, str):
data = data.encode("utf-8", errors="replace")
# E10 (Umsetzungsauftrag Teil E): Ausgabe vom Ziel zaehlt genauso
# als Aktivitaet wie eine Benutzereingabe -- ein laufendes
# `tail -f`/langer Build haelt die Sitzung damit am Leben, auch
# wenn niemand tippt. `activity` wird mit der Haupt-Schleife
# unten geteilt (dieselbe Coroutine-Ausfuehrung, kein Lock noetig).
activity["t"] = time.monotonic()
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.
@ -99,6 +117,25 @@ async def ssh_terminal(websocket: WebSocket, host_id: int):
)
return
# E.4 (Umsetzungsauftrag Teil E): Obergrenzen je Benutzer und global,
# VOR jedem Ressourcenverbrauch geprueft (analog app/rdp_proxy/ws_tunnel.py).
if not user.is_admin and active_sessions.count_for_user(user.id) >= settings.max_sessions_per_user:
await _reject(
websocket, 4429,
f"Sie haben bereits {settings.max_sessions_per_user} Sitzungen offen "
"(Obergrenze je Benutzer erreicht).",
accepted=False,
)
return
if active_sessions.count_total() >= settings.max_sessions_global:
await _reject(
websocket, 4429,
"Der Server hat die maximale Anzahl gleichzeitiger Sitzungen erreicht, "
"bitte spaeter erneut versuchen.",
accepted=False,
)
return
await websocket.accept()
client_ip = websocket.client.host if websocket.client else "unknown"
@ -130,25 +167,91 @@ async def ssh_terminal(websocket: WebSocket, host_id: int):
"SSH-Sitzung %s gestartet: user=%s host=%s (%s:%s) client_ip=%s",
session_id, user.username, host["hostname"], host["address"], host["port"], client_ip,
)
active_sessions.register(session_id, asyncio.current_task())
active_sessions.register(session_id, asyncio.current_task(), user.id)
end_reason = "logout"
ssh_conn = None
process = None
pump_task = None
session_start = time.monotonic()
# E10: von _pump_ssh_to_ws() UND der Haupt-Schleife hier gemeinsam
# aktualisiert -- ein dict-Eintrag statt einer einfachen Variable, damit
# beide Coroutinen denselben veraenderlichen Zustand sehen (Closures
# koennen keine Nicht-lokalen einfachen Namen neu binden). Kein Lock
# noetig: reine Zuweisungen im selben Event-Loop-Thread.
activity = {"t": session_start}
warned_max_duration = False
try:
ssh_conn = await connect_to_host(conn, host_id)
ssh_conn = await connect_to_host(conn, host_id, user_id=user.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, session_id))
pump_task = asyncio.create_task(_pump_ssh_to_ws(process, websocket, recorder, session_id, activity))
while True:
try:
msg = await asyncio.wait_for(websocket.receive_json(), timeout=IDLE_TIMEOUT_SECONDS)
msg = await asyncio.wait_for(websocket.receive_json(), timeout=_POLL_INTERVAL_S)
except asyncio.TimeoutError:
msg = None
now = time.monotonic()
if msg is not None:
activity["t"] = now
# E10: Inaktivitaet wird ueber `activity` in BEIDEN Richtungen
# gemessen (Benutzereingabe hier, Zielausgabe in
# _pump_ssh_to_ws()) -- vorher zaehlte nur receive_json(), ein
# rein ausgabelastiges `tail -f` o.ae. wurde nach 15 Minuten ohne
# Tastendruck getrennt, obwohl die Sitzung erkennbar aktiv war.
if now - activity["t"] > IDLE_TIMEOUT_SECONDS:
end_reason = "idle_timeout"
try:
await websocket.send_json({
"type": "error",
"message": (
f"Sitzung wegen Inaktivitaet beendet "
f"(> {IDLE_TIMEOUT_SECONDS // 60} Minuten ohne Ein-/Ausgabe)."
),
})
except Exception:
logger.debug("Idle-Timeout-Meldung konnte nicht mehr gesendet werden", exc_info=True)
break
# E11: MAX_SESSION_SECONDS war vorher eine definierte, aber
# nirgends ausgewertete Konstante -- es gab de facto GAR KEINE
# absolute Obergrenze fuer eine SSH-Sitzung. Jetzt aktiv
# durchgesetzt, mit Vorwarnung statt eines ueberraschenden
# sofortigen Abbruchs.
running_for = now - session_start
if running_for > MAX_SESSION_SECONDS:
end_reason = "max_duration_exceeded"
try:
await websocket.send_json({
"type": "error",
"message": (
f"Sitzung nach Erreichen der maximalen Sitzungsdauer "
f"({MAX_SESSION_SECONDS // 3600} Stunden) beendet."
),
})
except Exception:
logger.debug("Sitzungsende-Meldung konnte nicht mehr gesendet werden", exc_info=True)
break
if not warned_max_duration and running_for > MAX_SESSION_SECONDS - MAX_SESSION_WARNING_S:
warned_max_duration = True
try:
await websocket.send_json({
"type": "warning",
"message": (
f"Diese Sitzung wird in ca. {MAX_SESSION_WARNING_S} Sekunden wegen der "
"maximalen Sitzungsdauer automatisch beendet."
),
})
except Exception:
logger.debug("Vorwarnung konnte nicht mehr gesendet werden", exc_info=True)
if msg is None:
continue
if msg.get("type") == "input":
raw = base64.b64decode(msg.get("data", ""))
recorder.record("input", base64.b64encode(raw).decode())
@ -228,7 +331,7 @@ async def ssh_terminal(websocket: WebSocket, host_id: int):
process.close()
if ssh_conn:
ssh_conn.close()
recorder.close()
await recorder.aclose()
logger.debug("SSH-Sitzung %s beendet: reason=%s", session_id, end_reason)
await conn.execute(
"UPDATE sessions SET ended_at = strftime('%Y-%m-%dT%H:%M:%fZ','now'), end_reason = ? "