umbau 1.0
This commit is contained in:
@ -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 = ? "
|
||||
|
||||
Reference in New Issue
Block a user