umbau 1.0

This commit is contained in:
2026-09-02 20:30:44 +02:00
parent afe6719f51
commit 5c95b21be7
77 changed files with 10733 additions and 1914 deletions

View File

@ -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 = ? "