second commit
This commit is contained in:
0
app/ssh_proxy/__init__.py
Normal file
0
app/ssh_proxy/__init__.py
Normal file
130
app/ssh_proxy/proxy.py
Normal file
130
app/ssh_proxy/proxy.py
Normal file
@ -0,0 +1,130 @@
|
||||
"""
|
||||
Serverseitiger SSH-Verbindungsaufbau (asyncssh).
|
||||
|
||||
Zentrales Sicherheitsprinzip (Konzept 4.2/4.4/6.4): der private Schluessel
|
||||
wird pro Verbindung aus der DB geladen, entschluesselt, an asyncssh
|
||||
uebergeben und danach nicht weiter referenziert -- er verlaesst den
|
||||
Serverprozess nie und wird nicht geloggt. Strict Host Key Checking ist
|
||||
Pflicht: ohne gepinnten Fingerprint wird die Verbindung abgelehnt.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
import asyncssh
|
||||
import aiosqlite
|
||||
|
||||
from app.security.crypto import decrypt_secret
|
||||
|
||||
logger = logging.getLogger("jumphost.ssh_proxy")
|
||||
|
||||
|
||||
class HostNotConfiguredError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class HostKeyMismatchError(Exception):
|
||||
def __init__(self, expected: str | None, observed: str | None) -> None:
|
||||
self.expected = expected
|
||||
self.observed = observed
|
||||
super().__init__(f"Host-Key-Mismatch: erwartet={expected!r} beobachtet={observed!r}")
|
||||
|
||||
|
||||
class _PinnedHostKeyClient(asyncssh.SSHClient):
|
||||
"""Erzwingt Strict Host Key Checking gegen einen fest hinterlegten
|
||||
SHA-256-Fingerprint. Kein automatisches Trust-on-First-Use (TOFU)."""
|
||||
|
||||
def __init__(self, expected_fingerprint: str | None, *, discovery_mode: bool = False) -> None:
|
||||
self.expected_fingerprint = expected_fingerprint
|
||||
self.discovery_mode = discovery_mode
|
||||
self.observed_fingerprint: str | None = None
|
||||
|
||||
def validate_host_public_key(self, host, addr, port, key) -> bool: # noqa: D102
|
||||
self.observed_fingerprint = key.get_fingerprint("sha256")
|
||||
if self.discovery_mode:
|
||||
# Nur ueber den expliziten Admin-Discovery-Endpunkt erreichbar,
|
||||
# niemals im regulaeren Verbindungspfad (siehe admin/routes.py).
|
||||
return True
|
||||
if not self.expected_fingerprint:
|
||||
return False
|
||||
return self.observed_fingerprint == self.expected_fingerprint
|
||||
|
||||
|
||||
async def load_host(conn: aiosqlite.Connection, host_id: int) -> dict:
|
||||
cursor = await conn.execute(
|
||||
"SELECT id, hostname, address, port, os_type, protocol, ssh_host_key_fingerprint, "
|
||||
"ssh_username, file_transfer_enabled, host_group_id FROM hosts WHERE id = ? AND is_active = 1",
|
||||
(host_id,),
|
||||
)
|
||||
row = await cursor.fetchone()
|
||||
if row is None:
|
||||
raise HostNotConfiguredError(f"Host {host_id} nicht gefunden oder inaktiv")
|
||||
keys = (
|
||||
"id", "hostname", "address", "port", "os_type", "protocol",
|
||||
"ssh_host_key_fingerprint", "ssh_username", "file_transfer_enabled", "host_group_id",
|
||||
)
|
||||
return dict(zip(keys, row))
|
||||
|
||||
|
||||
async def load_private_key_for_host(conn: aiosqlite.Connection, host_id: int) -> asyncssh.SSHKey:
|
||||
cursor = await conn.execute(
|
||||
"SELECT sk.private_key_enc 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,),
|
||||
)
|
||||
row = await cursor.fetchone()
|
||||
if row is None:
|
||||
raise HostNotConfiguredError(f"Kein SSH-Schluessel fuer Host {host_id} hinterlegt")
|
||||
pem = decrypt_secret(row[0], associated_data=b"ssh_private_key")
|
||||
try:
|
||||
return asyncssh.import_private_key(pem)
|
||||
finally:
|
||||
# Bestpraxis: Referenz auf den Klartext-PEM-Bytes so schnell wie moeglich loslassen.
|
||||
del pem
|
||||
|
||||
|
||||
async def connect_to_host(conn: aiosqlite.Connection, host_id: int) -> asyncssh.SSHClientConnection:
|
||||
host = await load_host(conn, host_id)
|
||||
if host["protocol"] != "ssh":
|
||||
raise HostNotConfiguredError("Host ist kein SSH-Ziel")
|
||||
|
||||
private_key = await load_private_key_for_host(conn, host_id)
|
||||
client_factory = lambda: _PinnedHostKeyClient(host["ssh_host_key_fingerprint"])
|
||||
|
||||
try:
|
||||
connection = await asyncssh.connect(
|
||||
host["address"],
|
||||
port=host["port"],
|
||||
username=host["ssh_username"],
|
||||
client_keys=[private_key],
|
||||
known_hosts=None, # Validierung erfolgt ausschliesslich ueber validate_host_public_key
|
||||
client_factory=client_factory,
|
||||
connect_timeout=10,
|
||||
)
|
||||
except asyncssh.Error as exc:
|
||||
logger.warning("SSH-Verbindungsfehler zu Host %s: %s", host_id, exc)
|
||||
raise
|
||||
return connection
|
||||
|
||||
|
||||
async def discover_and_store_host_key(
|
||||
conn: aiosqlite.Connection, host_id: int, *, admin_user_id: int
|
||||
) -> str:
|
||||
"""Verbindet EINMALIG ohne Pinning, um den Host-Key-Fingerprint zu erfassen
|
||||
und in der DB zu hinterlegen. Nur ueber einen dedizierten, admin-only
|
||||
Endpunkt aufrufbar -- jeder Aufruf ist eine bewusste Vertrauensentscheidung
|
||||
und wird im Audit-Log als solche vermerkt (siehe admin/routes.py)."""
|
||||
host = await load_host(conn, host_id)
|
||||
client = _PinnedHostKeyClient(None, discovery_mode=True)
|
||||
connection = await asyncssh.connect(
|
||||
host["address"], port=host["port"], username=host["ssh_username"],
|
||||
known_hosts=None, client_factory=lambda: client, connect_timeout=10,
|
||||
)
|
||||
connection.close()
|
||||
fingerprint = client.observed_fingerprint
|
||||
await conn.execute(
|
||||
"UPDATE hosts SET ssh_host_key_fingerprint = ? WHERE id = ?", (fingerprint, host_id)
|
||||
)
|
||||
await conn.commit()
|
||||
return fingerprint
|
||||
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}"'},
|
||||
)
|
||||
144
app/ssh_proxy/terminal_ws.py
Normal file
144
app/ssh_proxy/terminal_ws.py
Normal file
@ -0,0 +1,144 @@
|
||||
"""
|
||||
Browser-Terminal <-> SSH-Ziel per WebSocket (xterm.js-kompatibel).
|
||||
|
||||
Framing: JSON-Textframes.
|
||||
Client -> Server: {"type":"input","data":"<base64>"} | {"type":"resize","cols":n,"rows":n}
|
||||
Server -> Client: {"type":"output","data":"<base64>"} | {"type":"error","message":"..."} | {"type":"closed"}
|
||||
|
||||
Jede Session wird aufgezeichnet (app.recordings.recorder) und im Audit-Log
|
||||
mit Start/Ende vermerkt (Konzept 4.2, 4.7, 6.5).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import base64
|
||||
import logging
|
||||
|
||||
import asyncssh
|
||||
from fastapi import APIRouter, WebSocket, WebSocketDisconnect
|
||||
|
||||
from app.auth.deps import get_current_user_ws
|
||||
from app.db import get_db
|
||||
from app.rbac import user_has_role_for_host
|
||||
from app.recordings.recorder import SessionRecorder
|
||||
from app.security.audit import write_audit_event
|
||||
from app.ssh_proxy.proxy import HostNotConfiguredError, connect_to_host, load_host
|
||||
|
||||
logger = logging.getLogger("jumphost.ssh_proxy.ws")
|
||||
router = APIRouter()
|
||||
|
||||
MAX_SESSION_SECONDS = 8 * 3600
|
||||
IDLE_TIMEOUT_SECONDS = 15 * 60
|
||||
|
||||
|
||||
async def _pump_ssh_to_ws(process: asyncssh.SSHClientProcess, websocket: WebSocket, recorder: SessionRecorder):
|
||||
try:
|
||||
while True:
|
||||
data = await process.stdout.read(65536)
|
||||
if not data:
|
||||
break
|
||||
if isinstance(data, str):
|
||||
data = data.encode("utf-8", errors="replace")
|
||||
recorder.record("output", base64.b64encode(data).decode())
|
||||
await websocket.send_json({"type": "output", "data": base64.b64encode(data).decode()})
|
||||
except (asyncssh.Error, ConnectionResetError):
|
||||
pass
|
||||
|
||||
|
||||
@router.websocket("/ws/ssh/{host_id}")
|
||||
async def ssh_terminal(websocket: WebSocket, host_id: int):
|
||||
user = await get_current_user_ws(websocket)
|
||||
if user is None:
|
||||
await websocket.close(code=4401)
|
||||
return
|
||||
|
||||
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="ssh_connect"
|
||||
):
|
||||
await websocket.close(code=4403)
|
||||
return
|
||||
|
||||
await websocket.accept()
|
||||
client_ip = websocket.client.host if websocket.client else "unknown"
|
||||
|
||||
try:
|
||||
host = await load_host(conn, host_id)
|
||||
except HostNotConfiguredError as exc:
|
||||
await websocket.send_json({"type": "error", "message": str(exc)})
|
||||
await websocket.close(code=4404)
|
||||
return
|
||||
|
||||
cursor = await conn.execute(
|
||||
"INSERT INTO sessions (user_id, host_id, protocol, client_ip) VALUES (?, ?, 'ssh', ?)",
|
||||
(user.id, host_id, client_ip),
|
||||
)
|
||||
session_id = cursor.lastrowid
|
||||
recorder = SessionRecorder(session_id)
|
||||
await conn.execute(
|
||||
"UPDATE sessions SET recording_path = ? WHERE id = ?", (str(recorder.path), session_id)
|
||||
)
|
||||
await write_audit_event(
|
||||
conn, event_type="ssh_session_start", user_id=user.id, client_ip=client_ip,
|
||||
details={"host_id": host_id, "hostname": host["hostname"], "session_id": session_id},
|
||||
)
|
||||
await conn.commit()
|
||||
|
||||
end_reason = "logout"
|
||||
ssh_conn = None
|
||||
process = None
|
||||
pump_task = None
|
||||
try:
|
||||
ssh_conn = await connect_to_host(conn, host_id)
|
||||
process = await ssh_conn.create_process(term_type="xterm-256color")
|
||||
pump_task = asyncio.create_task(_pump_ssh_to_ws(process, websocket, recorder))
|
||||
|
||||
while True:
|
||||
try:
|
||||
msg = await asyncio.wait_for(websocket.receive_json(), timeout=IDLE_TIMEOUT_SECONDS)
|
||||
except asyncio.TimeoutError:
|
||||
end_reason = "idle_timeout"
|
||||
break
|
||||
|
||||
if msg.get("type") == "input":
|
||||
raw = base64.b64decode(msg.get("data", ""))
|
||||
recorder.record("input", base64.b64encode(raw).decode())
|
||||
process.stdin.write(raw.decode("utf-8", errors="replace"))
|
||||
elif msg.get("type") == "resize":
|
||||
cols, rows = int(msg.get("cols", 80)), int(msg.get("rows", 24))
|
||||
process.change_terminal_size(cols, rows)
|
||||
except WebSocketDisconnect:
|
||||
end_reason = "logout"
|
||||
except asyncssh.Error as exc:
|
||||
logger.warning("SSH-Sessionfehler (session_id=%s): %s", session_id, exc)
|
||||
end_reason = "error"
|
||||
try:
|
||||
await websocket.send_json({"type": "error", "message": "Verbindung zum Zielsystem fehlgeschlagen"})
|
||||
except Exception:
|
||||
# Best-Effort-Fehlermeldung an einen ggf. bereits getrennten Client;
|
||||
# der eigentliche Fehler ist bereits oben geloggt (logger.warning).
|
||||
logger.debug("Fehlermeldung konnte nicht mehr an Client gesendet werden", exc_info=True)
|
||||
except HostNotConfiguredError:
|
||||
end_reason = "error"
|
||||
finally:
|
||||
if pump_task:
|
||||
pump_task.cancel()
|
||||
if process:
|
||||
process.close()
|
||||
if ssh_conn:
|
||||
ssh_conn.close()
|
||||
recorder.close()
|
||||
await conn.execute(
|
||||
"UPDATE sessions SET ended_at = strftime('%Y-%m-%dT%H:%M:%fZ','now'), end_reason = ? "
|
||||
"WHERE id = ?",
|
||||
(end_reason, session_id),
|
||||
)
|
||||
await write_audit_event(
|
||||
conn, event_type="ssh_session_end", user_id=user.id, client_ip=client_ip,
|
||||
details={"host_id": host_id, "session_id": session_id, "reason": end_reason},
|
||||
)
|
||||
await conn.commit()
|
||||
try:
|
||||
await websocket.close()
|
||||
except Exception:
|
||||
logger.debug("WebSocket war beim Schliessen bereits getrennt", exc_info=True)
|
||||
Reference in New Issue
Block a user