second commit
This commit is contained in:
145
app/ssh_proxy/sftp.py
Normal file
145
app/ssh_proxy/sftp.py
Normal file
@ -0,0 +1,145 @@
|
||||
"""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
|
||||
|
||||
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 HostNotConfiguredError, connect_to_host, load_host
|
||||
|
||||
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 HostNotConfiguredError as exc:
|
||||
raise HTTPException(status.HTTP_400_BAD_REQUEST, str(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 HostNotConfiguredError as exc:
|
||||
raise HTTPException(status.HTTP_400_BAD_REQUEST, str(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}"'},
|
||||
)
|
||||
Reference in New Issue
Block a user