more admin stuff 2

This commit is contained in:
2026-08-20 17:10:19 +02:00
parent e8216b14e9
commit cd5957cbd2
19 changed files with 1120 additions and 78 deletions

View File

@ -0,0 +1,46 @@
"""
In-Memory-Registry der laufenden SSH/RDP-Sitzungen dieses Server-Prozesses.
Wird von app/ssh_proxy/terminal_ws.py und app/rdp_proxy/ws_tunnel.py beim
Sitzungsstart befuellt (Referenz auf den eigenen asyncio.Task) und beim
Sitzungsende wieder entfernt. Basis fuer die Superadmin-'Sessionview'
(app/admin/routes.py: GET /admin/sessions, POST /admin/sessions/{id}/terminate)
-- 'Beenden' funktioniert per asyncio.Task.cancel(), was den regulaeren
finally-Cleanup-Pfad der jeweiligen WS-Route ausloest (DB-Update, Audit-
Log-Eintrag, WebSocket schliessen), genau wie bei einem normalen Logout.
Bewusst rein prozesslokal (kein Redis/DB-Backing): bei einem einzelnen
uvicorn-Worker (Standard-Deployment dieses Projekts, siehe ansible/) sieht
und beendet jeder Superadmin-Request alle laufenden Sitzungen. Bei mehreren
Worker-Prozessen sind nur die Sitzungen DIESES Workers 'killable' -- die
Sessionview zeigt das ueber das 'killable'-Feld pro Sitzung an, statt einen
Beenden-Versuch fehlschlagen zu lassen."""
from __future__ import annotations
import asyncio
from dataclasses import dataclass
@dataclass
class ActiveSession:
session_id: int
task: asyncio.Task
_active: dict[int, ActiveSession] = {}
def register(session_id: int, task: asyncio.Task) -> None:
_active[session_id] = ActiveSession(session_id=session_id, task=task)
def unregister(session_id: int) -> None:
_active.pop(session_id, None)
def get(session_id: int) -> ActiveSession | None:
return _active.get(session_id)
def all_ids() -> set[int]:
return set(_active.keys())

View File

@ -0,0 +1,71 @@
"""
Ring-Buffer + Pub/Sub fuer das Live-'Verbindungslog' im Admin-Bereich.
Haengt sich als zusaetzlicher logging.Handler an den Root-Logger, haelt die
letzten LOG-Zeilen im Prozessspeicher (fuer den initialen Replay beim
Oeffnen des Tabs) und verteilt jede neue Zeile an alle aktuell verbundenen
WebSocket-Abonnenten (siehe app/admin/routes.py, WS /admin/ws/logs).
Bewusst KEINE Persistenz in der DB -- das ist ein reiner Live-Tail (wie
'journalctl -f'), kein durchsuchbares Archiv. Rein prozesslokal: bei mehreren
Worker-Prozessen sieht ein Abonnent nur die Logs des Worker-Prozesses, der
seine WebSocket-Verbindung bedient.
"""
from __future__ import annotations
import asyncio
import logging
from collections import deque
MAX_BUFFER_LINES = 1000
_FORMATTER = logging.Formatter("%(asctime)s %(levelname)s %(name)s: %(message)s")
_buffer: deque[str] = deque(maxlen=MAX_BUFFER_LINES)
_subscribers: set[asyncio.Queue[str]] = set()
_installed = False
class _BroadcastHandler(logging.Handler):
def emit(self, record: logging.LogRecord) -> None:
try:
line = _FORMATTER.format(record)
except Exception:
return
_buffer.append(line)
for queue in list(_subscribers):
try:
queue.put_nowait(line)
except asyncio.QueueFull:
# Langsamer/verschwundener Abonnent -- lieber eine Zeile
# verlieren als das Event-Loop-Logging blockieren.
pass
def install() -> None:
"""Idempotent -- an app.main::lifespan gebunden. Haengt den Handler an
den Root-Logger (sieht alles, was propagiert) und hebt den Level des
eigenen App-Namespace 'jumphost' auf DEBUG an, damit die Verbindungs-
Debug-Meldungen (SSH/RDP-Handshake etc.) ueberhaupt erzeugt werden, ohne
Drittanbieter-Logger (uvicorn, asyncssh) unnoetig auf DEBUG zu stellen."""
global _installed
if _installed:
return
handler = _BroadcastHandler()
handler.setLevel(logging.DEBUG)
logging.getLogger().addHandler(handler)
logging.getLogger("jumphost").setLevel(logging.DEBUG)
_installed = True
def subscribe() -> asyncio.Queue[str]:
queue: asyncio.Queue[str] = asyncio.Queue(maxsize=500)
_subscribers.add(queue)
return queue
def unsubscribe(queue: asyncio.Queue[str]) -> None:
_subscribers.discard(queue)
def recent_lines() -> list[str]:
return list(_buffer)