84 lines
3.6 KiB
Python
84 lines
3.6 KiB
Python
"""
|
|
Startup-Reaper fuer verwaiste Sitzungen (Umsetzungsauftrag_Sonnet5.md, E7).
|
|
|
|
app/security/active_sessions.py ist bewusst rein prozesslokal -- bei einem
|
|
Absturz oder Neustart des uvicorn-Prozesses geht diese Registry verloren,
|
|
aber die zugehoerigen Zeilen in der Tabelle 'sessions' bleiben mit
|
|
ended_at IS NULL stehen, weil kein Task mehr existiert, der den regulaeren
|
|
finally-Cleanup-Pfad (Update + Audit-Event) durchlaeuft.
|
|
|
|
Ohne Korrektur wachsen diese 'Geistersitzungen' mit jedem Neustart an:
|
|
die Superadmin-Sessionview zeigt sie als aktiv, aber nicht beendbar
|
|
(killable: false), und jede kuenftige Zaehlung 'wie viele Sitzungen hat
|
|
dieser Benutzer offen' (E6, Teil F) rechnet mit ihnen.
|
|
|
|
reap_orphaned_sessions() wird einmalig beim Start aufgerufen (app/main.py::
|
|
lifespan, NACH init_db() -- die Migrationen muessen gelaufen sein) und
|
|
schliesst alle offenen Sitzungen mit end_reason='server_restart'. Es ist zu
|
|
diesem Zeitpunkt garantiert korrekt, JEDE offene Sitzung so zu behandeln:
|
|
der Prozess startet gerade erst, es kann also noch keine echte, laufende
|
|
Sitzung dieses neuen Prozesses geben.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import logging
|
|
|
|
import aiosqlite
|
|
|
|
from app.security.audit import write_audit_event
|
|
|
|
logger = logging.getLogger("jumphost.session_reaper")
|
|
|
|
|
|
async def reap_orphaned_sessions(conn: aiosqlite.Connection) -> int:
|
|
"""Schliesst alle beim Start noch offenen Sitzungen. Gibt die Anzahl zurueck."""
|
|
cursor = await conn.execute(
|
|
"SELECT id FROM sessions WHERE ended_at IS NULL ORDER BY id"
|
|
)
|
|
rows = await cursor.fetchall()
|
|
orphaned_ids = [row[0] for row in rows]
|
|
|
|
if not orphaned_ids:
|
|
logger.info("Keine verwaisten Sitzungen beim Start gefunden.")
|
|
return 0
|
|
|
|
await conn.execute(
|
|
"UPDATE sessions SET ended_at = strftime('%Y-%m-%dT%H:%M:%fZ','now'), "
|
|
"end_reason = 'server_restart' WHERE ended_at IS NULL"
|
|
)
|
|
# Bug-Fix (FORTSETZUNG_Teil_C.md Abschnitt 3 Punkt 3): explizit committen,
|
|
# BEVOR write_audit_event() unten aufgerufen wird. write_audit_event()
|
|
# oeffnet immer ein eigenes "BEGIN IMMEDIATE" (app/security/audit.py, E1)
|
|
# -- ohne diesen Commit bleibt das UPDATE oben (je nach isolation_level
|
|
# der uebergebenen Connection) als offene Transaktion stehen, und das
|
|
# BEGIN IMMEDIATE schlaegt mit "cannot start a transaction within a
|
|
# transaction" fehl. In der Produktion faellt das nicht auf, weil
|
|
# app/db.py::init_db() die einzige echte Connection bewusst mit
|
|
# isolation_level=None (Autocommit) oeffnet -- dort committet das UPDATE
|
|
# bereits von selbst. reap_orphaned_sessions() bekommt seine Connection
|
|
# aber als Parameter uebergeben und darf sich auf dieses Detail des
|
|
# Aufrufers nicht verlassen (siehe tests/test_session_reaper.py, das
|
|
# bewusst eine eigene :memory:-Connection MIT Standard-isolation_level
|
|
# verwendet und den Fehler dadurch reproduzierbar machte).
|
|
await conn.commit()
|
|
|
|
logger.warning(
|
|
"Startup-Reaper: %d verwaiste Sitzung(en) aus vorherigem Prozess geschlossen "
|
|
"(end_reason=server_restart): %s",
|
|
len(orphaned_ids),
|
|
orphaned_ids,
|
|
)
|
|
|
|
# Ein einziges Sammel-Audit-Ereignis statt eines je Sitzung (E7) -- die
|
|
# Anzahl und betroffenen IDs sind das, was fuer die Nachvollziehbarkeit
|
|
# gebraucht wird; ein Ereignis pro Zeile waere Rauschen im Log.
|
|
await write_audit_event(
|
|
conn,
|
|
event_type="session_reaper_server_restart",
|
|
user_id=None,
|
|
client_ip=None,
|
|
details={"count": len(orphaned_ids), "session_ids": orphaned_ids},
|
|
)
|
|
|
|
return len(orphaned_ids)
|