"""Dateitransfer zu SSH-Zielen per SFTP (Upload/Download ueber den Jumphost). Groessenlimit, Sha256-Hashing und optionaler AV-Scan sind Pflicht (Konzept 6.6). Jeder Transfer wird in file_transfers + audit_log protokolliert. """ from __future__ import annotations import hashlib import logging from fastapi import APIRouter, Depends, HTTPException, Query, Request, UploadFile, status from fastapi.responses import StreamingResponse from app.auth.deps import CurrentUser, get_current_user from app.db import get_db from app.rbac import user_has_role_for_host from app.security.audit import write_audit_event from app.security.av_scan import scan_bytes from app.ssh_proxy.proxy import ( SSH_SETUP_ERRORS, HostNotConfiguredError, PrivateKeyUnusableError, connect_to_host, describe_connection_error, load_host, ) logger = logging.getLogger("jumphost.ssh_proxy.sftp") router = APIRouter(prefix="/ssh", tags=["file-transfer"]) MAX_UPLOAD_BYTES = 200 * 1024 * 1024 # 200 MiB, ueber Ansible-Variable konfigurierbar (siehe Konzept) def _client_ip(request: Request) -> str: return request.client.host if request.client else "unknown" async def _require_file_transfer(host_id: int, request: Request, user: CurrentUser = Depends(get_current_user)): conn = get_db() if not user.is_admin and not await user_has_role_for_host( conn, user_id=user.id, host_id=host_id, role_name="file_transfer" ): raise HTTPException(status.HTTP_403_FORBIDDEN, "Keine Filetransfer-Berechtigung fuer diesen Host") host = await load_host(conn, host_id) if not host["file_transfer_enabled"]: raise HTTPException(status.HTTP_403_FORBIDDEN, "Dateitransfer ist fuer diesen Host deaktiviert") return host async def _log_transfer(conn, *, user: CurrentUser, host_id: int, client_ip: str, direction: str, filename: str, size: int, sha256: str, av_result: str) -> None: cursor = await conn.execute( "INSERT INTO sessions (user_id, host_id, protocol, client_ip, ended_at, end_reason) " "VALUES (?, ?, 'ssh', ?, strftime('%Y-%m-%dT%H:%M:%fZ','now'), 'file_transfer')", (user.id, host_id, client_ip), ) session_id = cursor.lastrowid await conn.execute( "INSERT INTO file_transfers (session_id, direction, filename, size_bytes, sha256, av_scan_result) " "VALUES (?, ?, ?, ?, ?, ?)", (session_id, direction, filename, size, sha256, av_result), ) await write_audit_event( conn, event_type="file_transfer", user_id=user.id, client_ip=client_ip, details={ "host_id": host_id, "direction": direction, "filename": filename, "size_bytes": size, "sha256": sha256, "av_scan_result": av_result, }, ) await conn.commit() @router.post("/{host_id}/files/upload") async def upload_file( host_id: int, request: Request, remote_path: str = Query(..., max_length=1024), file: UploadFile = ..., 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") 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)) await _log_transfer( conn, user=user, host_id=host_id, client_ip=_client_ip(request), direction="upload", filename=file.filename or remote_path, size=len(data), sha256=sha256, av_scan_result=av_result, ) return {"status": "ok", "sha256": sha256, "size": len(data), "av_scan_result": av_result} @router.get("/{host_id}/files/download") async def download_file( host_id: int, request: Request, remote_path: str = Query(..., max_length=1024), host=Depends(_require_file_transfer), user: CurrentUser = Depends(get_current_user), ): conn = get_db() try: ssh_conn = await connect_to_host(conn, host_id) try: async with ssh_conn.start_sftp_client() as sftp: 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: 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)) sha256 = hashlib.sha256(data).hexdigest() filename = remote_path.rsplit("/", 1)[-1] await _log_transfer( conn, user=user, host_id=host_id, client_ip=_client_ip(request), direction="download", filename=filename, size=len(data), sha256=sha256, av_scan_result="not_applicable_download", ) def _iter(): yield data return StreamingResponse( _iter(), media_type="application/octet-stream", headers={"Content-Disposition": f'attachment; filename="{filename}"'}, )