Files
ssh-jumphost/app/ssh_proxy/terminal_ws.py
2026-08-19 22:33:19 +02:00

145 lines
5.3 KiB
Python

"""
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)