umbau 1.0
This commit is contained in:
@ -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:
|
||||
|
||||
@ -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}"'},
|
||||
)
|
||||
|
||||
@ -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 = ? "
|
||||
|
||||
Reference in New Issue
Block a user