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

@ -16,8 +16,8 @@ ebenfalls Top-Level-Router unter /ws/ sind.
Reiner Live-Tail der Anwendungslogs dieses Server-Prozesses (siehe
app/security/log_stream.py) -- kein durchsuchbares Archiv, keine
Persistenz. Nur Super-Admin (require_global_admin-Semantik), da die Logs
ueber alle Mandanten hinweg technische Details preisgeben koennen.
Persistenz. Nur Admin (require_global_admin-Semantik), da die Logs
technische Details preisgeben koennen.
"""
from __future__ import annotations

File diff suppressed because it is too large Load Diff

View File

@ -2,7 +2,7 @@
from __future__ import annotations
import json
from dataclasses import dataclass, field
from dataclasses import dataclass
from datetime import datetime, timezone
from fastapi import Cookie, Depends, Header, HTTPException, Request, Response, WebSocket, status
@ -22,25 +22,21 @@ from app.security.sessions import (
class CurrentUser:
id: int
username: str
is_admin: bool # Super-Admin (siehe Migration 0006, Mandantenfaehigkeit):
# sieht/verwaltet ALLE Mandanten. Ein Token ist NIE is_admin=True, auch
# wenn der zugehoerige User selbst Super-Admin ist (siehe unten).
tenant_admin_ids: frozenset[int] = field(default_factory=frozenset)
# Nur bei Token-Auth gesetzt: das Token ist zusaetzlich auf GENAU diesen
# einen Mandanten eingeschraenkt (siehe app/db/migrations/0006_tenants.sql:
# api_tokens.tenant_id ist NOT NULL -- jedes Token gehoert einem Mandanten).
token_tenant_id: int | None = None
@property
def is_any_admin(self) -> bool:
"""Super-Admin ODER Mandanten-Admin (mind. eines Mandanten)."""
return self.is_admin or bool(self.tenant_admin_ids)
async def _load_tenant_admin_ids(conn, user_id: int) -> frozenset[int]:
cursor = await conn.execute("SELECT tenant_id FROM tenant_admins WHERE user_id = ?", (user_id,))
rows = await cursor.fetchall()
return frozenset(r[0] for r in rows)
is_admin: bool # Admin: sieht/verwaltet alle Ressourcen. Ein Token ist NIE
# is_admin=True, auch wenn der zugehoerige User selbst Admin ist (siehe unten).
is_token: bool = False
# Teil D Schritt 5 (S4-Loesung): unterscheidet einen echten Nicht-Admin-
# Session-User (is_admin=False, is_token=False -- kommt ueber eine
# Hostgruppen-Rolle durch require_admin_scope_or_host_role) von einem
# API-Token mit passendem Scope (is_admin=False, is_token=True --
# laut Docstring dort admin-aequivalent FUER SEINEN SCOPE, "kein Token
# traegt je eine Hostgruppen-Rolle"). Vor dieser Unterscheidung liessen
# sich beide Faelle in einer Dependency nicht auseinanderhalten -- siehe
# FORTSETZUNG_Teil_D.md Abschnitt "Bewusst NICHT in Schritt 1 erledigt:
# S4" fuer die damals bewusst aufgeschobene Begruendung. Wird in
# app/admin/routes.py (map_ssh_key_to_host/assign_rdp_credential_to_host)
# verwendet, um die Achse-B-Freigabepruefung NUR fuer echte Nicht-Admin-
# Session-User zu erzwingen, nicht fuer Tokens.
async def get_current_user(
@ -78,8 +74,7 @@ async def get_current_user(
path="/",
)
tenant_admin_ids = await _load_tenant_admin_ids(conn, row[0])
return CurrentUser(id=row[0], username=row[1], is_admin=bool(row[2]), tenant_admin_ids=tenant_admin_ids)
return CurrentUser(id=row[0], username=row[1], is_admin=bool(row[2]))
async def get_current_user_ws(websocket: WebSocket) -> CurrentUser | None:
@ -106,18 +101,20 @@ async def get_current_user_ws(websocket: WebSocket) -> CurrentUser | None:
async def require_global_admin(user: CurrentUser = Depends(get_current_user)) -> CurrentUser:
"""NUR Super-Admin (Session, kein Token). Fuer die maechtigsten Aktionen:
Mandanten anlegen/loeschen, Mandanten-Admins ernennen, API-Dokumentation."""
"""Nur Admin-Session (kein Token). Fuer die maechtigsten Aktionen:
Sitzungsverwaltung (/admin/sessions*), API-Dokumentation (/docs)."""
if not user.is_admin:
raise HTTPException(status.HTTP_403_FORBIDDEN, "Super-Admin-Rechte erforderlich")
raise HTTPException(status.HTTP_403_FORBIDDEN, "Admin-Rechte erforderlich")
return user
async def require_admin_session(user: CurrentUser = Depends(get_current_user)) -> CurrentUser:
"""Super-Admin ODER Mandanten-Admin, aber ausschliesslich per Session
(NIE per Token) -- fuer Aktionen, die bewusst keinen Token-Bypass erlauben
sollen (z.B. Token-Verwaltung selbst, Mandanten-Admin-Zuweisung lesen)."""
if not user.is_any_admin:
"""Admin, aber ausschliesslich per Session (NIE per Token) -- fuer
Aktionen, die bewusst keinen Token-Bypass erlauben sollen (z.B. die
Token-Verwaltung selbst). Das ist der Privilege-Escalation-Schutz der
Token-Verwaltung: ein geleaktes Token darf sich damit nicht selbst
weitere/staerkere Tokens ausstellen."""
if not user.is_admin:
raise HTTPException(status.HTTP_403_FORBIDDEN, "Admin-Rechte erforderlich")
return user
@ -163,12 +160,11 @@ def require_host_role(role_name: str):
# User, Rollen, ...), NICHT fuer SSH/RDP/SFTP-Sitzungen selbst -- die bleiben
# ausschliesslich an eine eingeloggte, TOTP-verifizierte Browser-Session
# gebunden. Ein Token ist nie maechtiger als sein explizit vergebener Scope,
# auch wenn der zugehoerige User Admin ist (kein automatisches is_admin), UND
# ist seit Migration 0006 zusaetzlich auf GENAU EINEN Mandanten eingeschraenkt
# (token_tenant_id). Die Tokenverwaltung selbst (/admin/tokens/*) laeuft
# ausschliesslich ueber require_admin_session (Session, Super- ODER
# Mandanten-Admin), niemals ueber ein Token -- ein geleaktes Token darf sich
# damit nicht selbst weitere/staerkere Tokens ausstellen.
# auch wenn der zugehoerige User Admin ist (kein automatisches is_admin). Die
# Tokenverwaltung selbst (/admin/tokens/*) laeuft ausschliesslich ueber
# require_admin_session (reine Session-Aktion), niemals ueber ein Token -- ein
# geleaktes Token darf sich damit nicht selbst weitere/staerkere Tokens
# ausstellen.
async def _validate_api_token(token: str, *, resource: str, action: str) -> CurrentUser | None:
@ -177,7 +173,7 @@ async def _validate_api_token(token: str, *, resource: str, action: str) -> Curr
conn = get_db()
token_hash = hash_token(token)
cursor = await conn.execute(
"SELECT at.id, at.user_id, at.scopes_json, at.expires_at, at.revoked_at, at.tenant_id, "
"SELECT at.id, at.user_id, at.scopes_json, at.expires_at, at.revoked_at, "
"u.username, u.is_active "
"FROM api_tokens at JOIN users u ON u.id = at.user_id "
"WHERE at.token_hash = ? AND u.deleted_at IS NULL",
@ -186,7 +182,7 @@ async def _validate_api_token(token: str, *, resource: str, action: str) -> Curr
row = await cursor.fetchone()
if row is None:
return None
token_id, user_id, scopes_json, expires_at, revoked_at, tenant_id, username, is_active = row
token_id, user_id, scopes_json, expires_at, revoked_at, username, is_active = row
if revoked_at is not None or not is_active:
return None
now_str = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%S.%fZ")
@ -201,7 +197,7 @@ async def _validate_api_token(token: str, *, resource: str, action: str) -> Curr
(token_id,),
)
await conn.commit()
return CurrentUser(id=user_id, username=username, is_admin=False, token_tenant_id=tenant_id)
return CurrentUser(id=user_id, username=username, is_admin=False, is_token=True)
def require_admin_scope_or_host_role(resource: str, action: str, role_names: tuple[str, ...]):
@ -230,7 +226,7 @@ def require_admin_scope_or_host_role(resource: str, action: str, role_names: tup
return principal
user = await get_current_user(response, jh_session)
if user.is_any_admin:
if user.is_admin:
return user
conn = get_db()
for role_name in role_names:
@ -243,12 +239,9 @@ def require_admin_scope_or_host_role(resource: str, action: str, role_names: tup
def require_admin_or_scope(resource: str, action: str):
"""Dependency-Factory fuer die Admin-API: erlaubt Zugriff entweder ueber
eine eingeloggte Admin-Session (Super- ODER Mandanten-Admin, Cookie) ODER
ueber ein API-Token mit passendem Scope (Header 'Authorization: Bearer
<token>'). 'write' schliesst 'read' auf derselben Ressource ein (siehe
token_has_scope). Mandanten-Scoping selbst erfolgt NICHT hier, sondern im
jeweiligen Endpunkt ueber app/tenancy.py::TenantScope, basierend auf
effective_tenant_ids() des zurueckgegebenen CurrentUser."""
eine eingeloggte Admin-Session (Cookie) ODER ueber ein API-Token mit
passendem Scope (Header 'Authorization: Bearer <token>'). 'write'
schliesst 'read' auf derselben Ressource ein (siehe token_has_scope)."""
async def _dep(
response: Response,
@ -266,25 +259,8 @@ def require_admin_or_scope(resource: str, action: str):
return principal
user = await get_current_user(response, jh_session)
if not user.is_any_admin:
if not user.is_admin:
raise HTTPException(status.HTTP_403_FORBIDDEN, "Admin-Rechte erforderlich")
return user
return _dep
def effective_tenant_ids(admin: CurrentUser) -> set[int] | None:
"""None bedeutet 'alle Mandanten' (Super-Admin-Session, nicht ueber ein
mandantengebundenes Token authentifiziert). Sonst ein Set mit genau den
Mandanten-IDs, auf die der Principal beschraenkt ist:
- Token-Auth: immer genau {token_tenant_id} (Tokens sind nie
Mandanten-UEBERGREIFEND einsetzbar, siehe Migration 0006).
- Mandanten-Admin-Session: die Menge ihrer tenant_admin_ids.
- Ein regulaerer (Nicht-Admin-)User wuerde require_admin_or_scope /
require_admin_session ohnehin nie passieren -- kommt hier praktisch
nicht an."""
if admin.token_tenant_id is not None:
return {admin.token_tenant_id}
if admin.is_admin:
return None
return set(admin.tenant_admin_ids)

View File

@ -272,6 +272,13 @@ async def logout(request: Request, response: Response, user: CurrentUser = Depen
conn, event_type="logout", user_id=user.id, client_ip=_client_ip(request), details={}
)
await conn.commit()
# Siehe change_password() unten fuer die ausfuehrliche Begruendung: die
# get_current_user()-Dependency hat bereits ein Refresh-Set-Cookie
# geschrieben, das hier entfernte werden muss, bevor delete_cookie()
# selbst noch ein Set-Cookie (mit Max-Age=0) anhaengt -- sonst zwei
# Set-Cookie-Kopfzeilen in einer Antwort.
if "set-cookie" in response.headers:
del response.headers["set-cookie"]
response.delete_cookie(SESSION_COOKIE_NAME, path="/")
return {"status": "ok"}
@ -289,6 +296,9 @@ async def logout_everywhere(
conn, event_type="logout_everywhere", user_id=user.id, client_ip=_client_ip(request), details={}
)
await conn.commit()
# Siehe change_password() unten fuer die ausfuehrliche Begruendung.
if "set-cookie" in response.headers:
del response.headers["set-cookie"]
response.delete_cookie(SESSION_COOKIE_NAME, path="/")
return {"status": "ok"}
@ -318,6 +328,27 @@ async def change_password(
)
await conn.commit()
# Bug-Fix (FORTSETZUNG_Teil_C.md Abschnitt 3 Punkt 6): get_current_user()
# (siehe app/auth/deps.py) hat als Dependency VOR dieser Routenfunktion
# bereits ein eigenes Set-Cookie in `response` geschrieben (gleitender
# Idle-Timeout-Refresh) -- MIT DER ALTEN session_version, weil die DB zu
# dem Zeitpunkt noch nicht aktualisiert war. Ohne diese Zeile wuerden
# ZWEI "Set-Cookie: jh_session=..."-Header in derselben Antwort stehen
# (die alte Refresh-Cookie UND die neue unten) -- welche davon ein Client
# am Ende tatsaechlich speichert, ist zwischen Browsern/HTTP-Bibliotheken
# nicht einheitlich definiert (in der Praxis eher "die letzte gewinnt",
# aber verlassen sollte man sich darauf nicht). Ein per-Request frischer
# Client akzeptiert die alte Sitzung danach zwar bereits korrekt nicht
# mehr (session_version-Pruefung in deps.py greift unabhaengig davon),
# aber die doppelte Kopfzeile bleibt unsauber und macht Tests, die den
# Cookie-Jar wiederverwenden, unnoetig fragil (siehe der dadurch lange
# falsch als "Sicherheitsluecke" vermutete Testfehlschlag). Die alte
# Set-Cookie-Kopfzeile wird deshalb hier explizit entfernt, bevor die
# neue, autoritative gesetzt wird -- `del response.headers["set-cookie"]`
# entfernt ALLE bisherigen Vorkommen dieses Headers (Starlette
# MutableHeaders.__delitem__), nicht nur das erste.
if "set-cookie" in response.headers:
del response.headers["set-cookie"]
token = create_session_token(user.id, new_version)
response.set_cookie(
SESSION_COOKIE_NAME, token, httponly=True, secure=True, samesite="strict", path="/"
@ -327,19 +358,8 @@ async def change_password(
@router.get("/me")
async def me(user: CurrentUser = Depends(get_current_user)):
tenant_admin_of = []
if user.tenant_admin_ids:
conn = get_db()
placeholders = ",".join("?" for _ in user.tenant_admin_ids)
cursor = await conn.execute(
f"SELECT id, name FROM tenants WHERE id IN ({placeholders}) ORDER BY name",
tuple(user.tenant_admin_ids),
)
tenant_admin_of = [{"id": r[0], "name": r[1]} for r in await cursor.fetchall()]
return {
"id": user.id,
"username": user.username,
"is_admin": user.is_admin,
"is_any_admin": user.is_any_admin,
"tenant_admin_of": tenant_admin_of,
}

View File

@ -1,65 +1,44 @@
"""Sicht fuer normale Nutzer: nur die Hosts/Aktionen, fuer die RBAC eine
Rolle in der jeweiligen Hostgruppe vergeben hat -- entweder direkt (Konzept
4.6) oder ueber eine Benutzergruppe, in der der User Mitglied ist (volle
Rollen-Vererbung, siehe app/rbac.py)."""
Rollen-Vererbung, siehe app/rbac.py).
S13 (Umsetzungsauftrag_Sonnet5.md Teil D.2): app/rbac.py ist die einzige
Wahrheitsquelle fuer die Vereinigung aus Direkt- und Gruppenvergabe. Dieses
Modul baut sie nicht mehr selbst per UNION/EXISTS nach, sondern ruft
user_host_group_ids_with_any_role() auf und filtert/verknuepft in Python.
F2 (Umsetzungsauftrag_Sonnet5.md Teil F.3.6): eigene Sitzungs-API. Bewusst
HIER und NICHT im Admin-Router -- GET /admin/sessions (app/admin/routes.py)
zeigt fremde Sitzungen, Benutzernamen und Aufzeichnungspfade und darf dafuer
nicht geoeffnet werden. Jede Abfrage/Aktion hier ist zwingend auf
user_id = <eigene angemeldete> beschraenkt."""
from __future__ import annotations
from fastapi import APIRouter, Depends
from fastapi import APIRouter, Depends, HTTPException, Request, status
from app.auth.deps import CurrentUser, get_current_user
from app.config import settings
from app.db import get_db
from app.rbac import user_host_group_ids_with_any_role
from app.security import active_sessions
from app.security.audit import write_audit_event
router = APIRouter(prefix="/catalog", tags=["catalog"])
def _client_ip(request: Request) -> str:
return request.client.host if request.client else "unknown"
@router.get("/hosts")
async def my_hosts(user: CurrentUser = Depends(get_current_user)):
conn = get_db()
if user.is_admin:
cursor = await conn.execute(
"SELECT h.id, h.hostname, h.address, h.protocol, h.os_type, h.host_group_id, "
"g.name, h.clipboard_enabled, h.file_transfer_enabled "
"FROM hosts h JOIN host_groups g ON g.id = h.host_group_id "
"WHERE h.is_active = 1 ORDER BY g.name, h.hostname"
)
rows = await cursor.fetchall()
hosts = [dict(zip(
("id", "hostname", "address", "protocol", "os_type", "host_group_id", "host_group_name",
"clipboard_enabled", "file_transfer_enabled"), r
)) for r in rows]
for h in hosts:
h["can_connect"] = True
h["can_file_transfer"] = True
h["can_view_credentials"] = True
return hosts
cursor = await conn.execute(
"""
SELECT h.id, h.hostname, h.address, h.protocol, h.os_type, h.host_group_id,
g.name, h.clipboard_enabled, h.file_transfer_enabled
FROM hosts h
JOIN host_groups g ON g.id = h.host_group_id
WHERE h.is_active = 1
AND (
EXISTS (
SELECT 1 FROM user_hostgroup_roles uhr
JOIN roles r ON r.id = uhr.role_id
WHERE uhr.user_id = ? AND uhr.host_group_id = h.host_group_id
AND r.name IN ('ssh_connect', 'rdp_connect')
AND (uhr.expires_at IS NULL OR uhr.expires_at > strftime('%Y-%m-%dT%H:%M:%fZ','now'))
)
OR EXISTS (
SELECT 1 FROM group_hostgroup_roles ghr
JOIN roles r ON r.id = ghr.role_id
JOIN user_group_members ugm ON ugm.user_group_id = ghr.user_group_id
WHERE ugm.user_id = ? AND ghr.host_group_id = h.host_group_id
AND r.name IN ('ssh_connect', 'rdp_connect')
AND (ghr.expires_at IS NULL OR ghr.expires_at > strftime('%Y-%m-%dT%H:%M:%fZ','now'))
)
)
ORDER BY g.name, h.hostname
""",
(user.id, user.id),
"SELECT h.id, h.hostname, h.address, h.protocol, h.os_type, h.host_group_id, "
"g.name, h.clipboard_enabled, h.file_transfer_enabled "
"FROM hosts h JOIN host_groups g ON g.id = h.host_group_id "
"WHERE h.is_active = 1 ORDER BY g.name, h.hostname"
)
rows = await cursor.fetchall()
hosts = [dict(zip(
@ -67,42 +46,145 @@ async def my_hosts(user: CurrentUser = Depends(get_current_user)):
"clipboard_enabled", "file_transfer_enabled"), r
)) for r in rows]
ft_cursor = await conn.execute(
"""
SELECT uhr.host_group_id FROM user_hostgroup_roles uhr
JOIN roles r ON r.id = uhr.role_id
WHERE uhr.user_id = ? AND r.name = 'file_transfer'
AND (uhr.expires_at IS NULL OR uhr.expires_at > strftime('%Y-%m-%dT%H:%M:%fZ','now'))
UNION
SELECT ghr.host_group_id FROM group_hostgroup_roles ghr
JOIN roles r ON r.id = ghr.role_id
JOIN user_group_members ugm ON ugm.user_group_id = ghr.user_group_id
WHERE ugm.user_id = ? AND r.name = 'file_transfer'
AND (ghr.expires_at IS NULL OR ghr.expires_at > strftime('%Y-%m-%dT%H:%M:%fZ','now'))
""",
(user.id, user.id),
)
ft_groups = {row[0] for row in await ft_cursor.fetchall()}
if user.is_admin:
for h in hosts:
h["can_connect"] = True
h["can_file_transfer"] = True
h["can_view_credentials"] = True
return hosts
cred_cursor = await conn.execute(
"""
SELECT uhr.host_group_id FROM user_hostgroup_roles uhr
JOIN roles r ON r.id = uhr.role_id
WHERE uhr.user_id = ? AND r.name IN ('credentials_view', 'credentials_manage')
AND (uhr.expires_at IS NULL OR uhr.expires_at > strftime('%Y-%m-%dT%H:%M:%fZ','now'))
UNION
SELECT ghr.host_group_id FROM group_hostgroup_roles ghr
JOIN roles r ON r.id = ghr.role_id
JOIN user_group_members ugm ON ugm.user_group_id = ghr.user_group_id
WHERE ugm.user_id = ? AND r.name IN ('credentials_view', 'credentials_manage')
AND (ghr.expires_at IS NULL OR ghr.expires_at > strftime('%Y-%m-%dT%H:%M:%fZ','now'))
""",
(user.id, user.id),
# Protokoll-Abgleich (S6, Umsetzungsauftrag_Sonnet5.md D.1/D.2): ssh_connect
# zaehlt nur fuer SSH-Hosts, rdp_connect nur fuer RDP-Hosts -- getrennte
# Gruppenmengen je Rolle, kein pauschales "irgendeine der beiden Rollen".
ssh_groups = await user_host_group_ids_with_any_role(conn, user_id=user.id, role_names=("ssh_connect",))
rdp_groups = await user_host_group_ids_with_any_role(conn, user_id=user.id, role_names=("rdp_connect",))
ft_groups = await user_host_group_ids_with_any_role(conn, user_id=user.id, role_names=("file_transfer",))
cred_groups = await user_host_group_ids_with_any_role(
conn, user_id=user.id, role_names=("credentials_view", "credentials_manage")
)
cred_groups = {row[0] for row in await cred_cursor.fetchall()}
visible = []
for h in hosts:
can_connect = (
(h["protocol"] == "ssh" and h["host_group_id"] in ssh_groups)
or (h["protocol"] == "rdp" and h["host_group_id"] in rdp_groups)
)
if not can_connect:
continue
h["can_connect"] = True
h["can_file_transfer"] = h["host_group_id"] in ft_groups and bool(h["file_transfer_enabled"])
h["can_view_credentials"] = h["host_group_id"] in cred_groups
return hosts
visible.append(h)
return visible
# --- Eigene Sitzungen (F2, Umsetzungsauftrag Teil F.3.6) --------------------
#
# Grundlage fuer die kuenftige Arbeitsflaeche /workspace (Stufe F3): eine
# Seitenleiste braucht eine Liste der eigenen offenen Sitzungen, um Kacheln
# zu zeichnen, und einen Weg, eine davon (z.B. ueber den Schliessen-Knopf
# einer Kachel) zu beenden. Referenzmuster: app/admin/routes.py::
# list_sessions/terminate_session -- hier aber zwingend auf den eigenen
# Benutzer beschraenkt und ohne die dort zusaetzlich gelieferten sensiblen
# Felder (Client-IP, Aufzeichnungspfad, fremde Benutzernamen).
@router.get("/sessions")
async def my_sessions(active_only: bool = True, user: CurrentUser = Depends(get_current_user)):
"""Eigene Sitzungen (Standard: nur die noch offenen). Nutzt den
zusammengesetzten Index idx_sessions_user_ended (Migration 0020) --
deckt per Index-Praefix-Regel sowohl diese Abfrage (user_id, ended_at)
als auch active_only=false (user_id allein) ab."""
conn = get_db()
where = "WHERE s.user_id = ? AND s.ended_at IS NULL" if active_only else "WHERE s.user_id = ?"
cursor = await conn.execute(
f"""
SELECT s.id, s.host_id, h.hostname, hg.name, s.protocol, s.started_at, s.ended_at, s.end_reason
FROM sessions s
JOIN hosts h ON h.id = s.host_id
JOIN host_groups hg ON hg.id = h.host_group_id
{where}
ORDER BY s.started_at DESC
""",
(user.id,),
)
rows = await cursor.fetchall()
active_ids = active_sessions.all_ids()
return [
{
"id": r[0], "host_id": r[1], "hostname": r[2], "host_group_name": r[3],
"protocol": r[4], "started_at": r[5], "ended_at": r[6], "end_reason": r[7],
"is_active": r[6] is None,
# Wie bei der Admin-Sessionview: nur auf DIESEM Worker-Prozess
# beendbar (app/security/active_sessions.py, Moduldoc).
"killable": r[0] in active_ids,
}
for r in rows
]
@router.post("/sessions/{session_id}/terminate")
async def terminate_my_session(session_id: int, request: Request, user: CurrentUser = Depends(get_current_user)):
"""Beendet eine EIGENE laufende Sitzung. Zwingend auf user_id = user.id
beschraenkt -- sowohl beim DB-Zeilen- als auch beim Registry-Zugriff
(F.5-Risikotabelle: 'Rechteumgehung ueber die neue Sitzungs-API' /
'Wiederverwendung des Admin-Endpunkts'). Eine fremde oder nicht
existierende session_id liefert in BEIDEN Faellen 404 -- die Existenz
einer fremden Sitzung wird nicht ueber einen abweichenden Statuscode
(z.B. 403 nur bei existierender, aber fremder ID) verraten."""
conn = get_db()
row = await (await conn.execute(
"SELECT ended_at, user_id, host_id FROM sessions WHERE id = ?", (session_id,)
)).fetchone()
if row is None or row[1] != user.id:
raise HTTPException(status.HTTP_404_NOT_FOUND, "Sitzung nicht gefunden")
if row[0] is not None:
raise HTTPException(status.HTTP_409_CONFLICT, "Sitzung ist bereits beendet")
entry = active_sessions.get(session_id)
if entry is None or entry.user_id != user.id:
raise HTTPException(
status.HTTP_409_CONFLICT,
"Sitzung laeuft nicht (mehr) auf diesem Server-Prozess und kann von hier aus nicht beendet werden",
)
entry.task.cancel()
await write_audit_event(
conn, event_type="session_terminated_by_owner", user_id=user.id, client_ip=_client_ip(request),
details={"session_id": session_id, "host_id": row[2]},
)
await conn.commit()
return {"status": "ok"}
# --- Sitzungs-Obergrenzen (F5, Umsetzungsauftrag Teil F.3.8) ----------------
#
# Die Durchsetzung selbst existiert bereits seit Teil E.4 und bleibt dort
# die alleinige, verbindliche Kontrolle (app/ssh_proxy/terminal_ws.py,
# app/rdp_proxy/ws_tunnel.py -- vor jedem Ressourcenverbrauch, WS-Code 4429
# bei Ueberschreitung). Dieser Endpunkt liefert nur die Zahlen dazu, damit
# die Arbeitsflaeche (static/js/workspace.js) den Zustand VORHER anzeigen
# und "+ Neue Sitzung" deaktivieren kann, statt den Nutzer erst beim
# WS-Verbindungsversuch abzuweisen (F.3.8 verlangt sichtbare Obergrenzen,
# nicht nur eine funktionierende Durchsetzung -- die gab es bereits).
#
# Absichtlich nur aggregierte Zahlen, keine Details zu FREMDEN Sitzungen
# (kein user_id/hostname anderer Nutzer) -- sonst waere dies ein Fall fuer
# den Admin-Router (siehe Moduldoc oben).
#
# Admins sind wie bei der Durchsetzung selbst von der Je-Nutzer-Grenze
# ausgenommen, nicht aber von der globalen Grenze -- exakt dieselbe Logik
# wie in terminal_ws.py/ws_tunnel.py. Das MUSS synchron bleiben: sonst zeigt
# die Oberflaeche "frei", obwohl der Server ablehnen wuerde, oder umgekehrt.
@router.get("/session-limits")
async def my_session_limits(user: CurrentUser = Depends(get_current_user)):
user_count = active_sessions.count_for_user(user.id)
global_count = active_sessions.count_total()
user_limit_applies = not user.is_admin
return {
"max_per_user": settings.max_sessions_per_user,
"current_user_count": user_count,
"user_limit_applies": user_limit_applies,
"at_user_limit": user_limit_applies and user_count >= settings.max_sessions_per_user,
"max_global": settings.max_sessions_global,
"current_global_count": global_count,
"at_global_limit": global_count >= settings.max_sessions_global,
}

View File

@ -67,12 +67,73 @@ class Settings:
listen_uds: str = os.environ.get("JUMPHOST_LISTEN_UDS", "/run/jumphost/app.sock")
guacd_host: str = os.environ.get("JUMPHOST_GUACD_HOST", "127.0.0.1")
guacd_port: int = int(os.environ.get("JUMPHOST_GUACD_PORT", "4822"))
# Befund A3 (Umsetzungsauftrag_Sonnet5.md Teil A.3): weder der
# TCP-Verbindungsaufbau zu guacd noch die beiden read_instruction()-
# Aufrufe im guac-Handshake (args/ready) hatten ein Timeout -- antwortet
# guacd nach dem TCP-Accept nicht mehr, haengt die Coroutine unbegrenzt
# (offene sessions-Zeile ohne ended_at, offene Aufzeichnungsdatei,
# belegtes WebSocket). Je Phase konfigurierbar statt eines Werts fuer
# beides, da ein TCP-Connect-Timeout und ein Protokoll-Handshake-Timeout
# unterschiedliche Fehlerbilder abdecken (guacd-Prozess tot vs. guacd
# haengt mitten im Protokoll).
guacd_connect_timeout_s: float = float(os.environ.get("JUMPHOST_GUACD_CONNECT_TIMEOUT", "10"))
guacd_handshake_timeout_s: float = float(os.environ.get("JUMPHOST_GUACD_HANDSHAKE_TIMEOUT", "10"))
session_idle_timeout_s: int = int(os.environ.get("JUMPHOST_SESSION_IDLE_TIMEOUT", "900"))
session_absolute_timeout_s: int = int(os.environ.get("JUMPHOST_SESSION_ABS_TIMEOUT", "28800"))
max_failed_logins: int = int(os.environ.get("JUMPHOST_MAX_FAILED_LOGINS", "5"))
lockout_base_seconds: int = int(os.environ.get("JUMPHOST_LOCKOUT_BASE_SECONDS", "30"))
# Sitzungsaufzeichnung (Umsetzungsauftrag Teil A/D2, Teil E/E2): Puffer-
# Zeitfenster, Groesse je Teildatei (Rotation) und Gesamtgroesse je
# Sitzung (harte Obergrenze -- Aufzeichnung wird danach mit einem
# Abschluss-Eintrag abgeschnitten, die Sitzung selbst laeuft normal
# weiter). Siehe app/recordings/recorder.py.
recording_flush_interval_s: float = float(os.environ.get("JUMPHOST_RECORDING_FLUSH_INTERVAL_S", "1.0"))
recording_max_part_bytes: int = int(os.environ.get("JUMPHOST_RECORDING_MAX_PART_BYTES", str(50 * 1024 * 1024)))
recording_max_total_bytes: int = int(os.environ.get("JUMPHOST_RECORDING_MAX_TOTAL_BYTES", str(500 * 1024 * 1024)))
# Dateitransfer (Umsetzungsauftrag Teil E, E4): Obergrenze gleichzeitig
# laufender Uploads+Downloads ueber alle Benutzer hinweg. Ohne Grenze
# koennten mehrere parallele 200-MiB-Transfers den Prozess in den
# OOM-Killer treiben (siehe app/ssh_proxy/sftp.py).
max_concurrent_transfers: int = int(os.environ.get("JUMPHOST_MAX_CONCURRENT_TRANSFERS", "8"))
# SSH-Terminal-Sitzungen (Umsetzungsauftrag Teil E):
# E10 -- Inaktivitaet wird in BEIDEN Richtungen gemessen (Eingaben UND
# Ausgaben vom Ziel, z.B. ein laufendes `tail -f`), nicht mehr nur an
# websocket.receive_json() (Benutzereingaben). Vorher wurde eine
# inhaltlich aktive, aber eingabelose Sitzung nach 15 Minuten getrennt.
ssh_idle_timeout_s: int = int(os.environ.get("JUMPHOST_SSH_IDLE_TIMEOUT", "900"))
# E11 -- vorher als MAX_SESSION_SECONDS-Konstante in
# app/ssh_proxy/terminal_ws.py definiert, aber nirgends verwendet: es gab
# de facto GAR KEINE absolute Obergrenze fuer eine SSH-Sitzung, anders
# als beim Anmelde-Cookie (session_absolute_timeout_s oben). Jetzt aktiv
# durchgesetzt, mit Vorwarnung (siehe ssh_max_session_warning_s).
ssh_max_session_seconds: int = int(os.environ.get("JUMPHOST_SSH_MAX_SESSION_SECONDS", str(8 * 3600)))
ssh_max_session_warning_s: int = int(os.environ.get("JUMPHOST_SSH_MAX_SESSION_WARNING_S", "60"))
# E8 (Umsetzungsauftrag Teil E): IP-basiertes Login-Rate-Limit. Bewusst
# NICHT automatisch an eine erkannte Benutzerzahl angepasst -- ob mehrere
# interne Benutzer hinter derselben NAT-Adresse sitzen (typischer
# Buero-Fall) ist eine Betriebsentscheidung, die zusammen mit dem
# nginx-limit_req (ansible/roles/nginx_proxy) und der fail2ban-Schwelle
# (ansible/roles/fail2ban) getroffen werden muss -- siehe deren
# jumphost_login_rate_limit_* group_vars. Der kontenbezogene Lockout
# (max_failed_logins oben) bleibt in jedem Fall die scharfe, IP-
# unabhaengige Bremse.
login_rate_limit_max_events: int = int(os.environ.get("JUMPHOST_LOGIN_RATE_LIMIT_MAX_EVENTS", "10"))
login_rate_limit_window_s: int = int(os.environ.get("JUMPHOST_LOGIN_RATE_LIMIT_WINDOW_S", "60"))
# E.4 (Umsetzungsauftrag Teil E): Obergrenzen fuer gleichzeitige SSH/RDP-
# Sitzungen. Vorher gab es WEDER einen Zaehler je Benutzer NOCH einen
# globalen Zaehler -- mit der Mehrsitzungs-Seitenleiste (Teil F) wird es
# zur Normalitaet, dass ein Benutzer mehrere Sitzungen gleichzeitig haelt,
# jede kostet mindestens einen zusaetzlichen Socket zum Ziel (RDP
# zusaetzlich die guacd-Verbindung) plus eine Aufzeichnungsdatei.
max_sessions_per_user: int = int(os.environ.get("JUMPHOST_MAX_SESSIONS_PER_USER", "6"))
max_sessions_global: int = int(os.environ.get("JUMPHOST_MAX_SESSIONS_GLOBAL", "100"))
def __post_init__(self) -> None:
self.db_path = self.data_dir / "jumphost.db"
self.recordings_dir = self.data_dir / "recordings"

View File

@ -30,6 +30,19 @@ async def init_db() -> None:
await _connection.execute("PRAGMA journal_mode = WAL;")
await _connection.execute("PRAGMA foreign_keys = ON;")
await _connection.execute("PRAGMA busy_timeout = 5000;")
# E5 (Umsetzungsauftrag Teil E): ohne explizites PRAGMA synchronous
# verwendet SQLite seinen kompilierten Standard FULL -- das fsynct bei
# JEDEM COMMIT, unabhaengig vom (an sich korrekten) WAL-Journal-Modus.
# aiosqlite bedient alle Anfragen ueber EINEN gemeinsamen Hintergrund-
# Thread (die einzige Verbindung der Anwendung, siehe E.0/E5-Befund) --
# ein fsync dort serialisiert sich vor JEDE andere wartende Anfrage
# jedes anderen Benutzers. NORMAL ist die von SQLite fuer WAL-Betrieb
# dokumentierte Empfehlung: fsynct nur noch an Checkpoint-Grenzen,
# bleibt dabei garantiert unbeschaedigt bei einem Anwendungsabsturz
# (der hier relevante Fall) -- nur bei einem Stromausfall/OS-Crash
# koennen die letzten, unmittelbar davor committeten Transaktionen
# verloren gehen (nicht: die DB korrumpieren).
await _connection.execute("PRAGMA synchronous = NORMAL;")
await _apply_migrations(_connection)
try:
@ -51,6 +64,22 @@ async def _apply_migrations(conn: aiosqlite.Connection) -> None:
logger.info("Wende Migration an: %s", migration_file.name)
sql = migration_file.read_text(encoding="utf-8")
await conn.executescript(sql)
# Fremdschluesselpruefung NACH jeder Migration: PRAGMA foreign_key_check
# liefert nur eine Ergebnismenge zurueck, die ein per executescript()
# ausgefuehrtes Skript NICHT auswertet (das PRAGMA innerhalb des
# Skripts selbst ist daher wirkungslos) -- deshalb wird es hier,
# ausserhalb von executescript(), explizit erneut ausgefuehrt und
# das Ergebnis geprueft. Verletzungen brechen den Start hart ab,
# statt eine inkonsistente Datenbank stillschweigend zu uebernehmen
# (siehe 0014_drop_tenants.sql-Kommentar).
violations = await (await conn.execute("PRAGMA foreign_key_check")).fetchall()
if violations:
raise RuntimeError(
f"Migration {migration_file.name} hat Fremdschluessel-Verletzungen "
f"hinterlassen, Start abgebrochen: {violations!r}"
)
await conn.execute(
"INSERT INTO schema_migrations (filename) VALUES (?)", (migration_file.name,)
)

View File

@ -0,0 +1,21 @@
-- Migration 0013: Befund C2 (Umsetzungsauftrag_Sonnet5.md Teil A.3).
--
-- Migration 0012 hat fuer RDP-Zugangsdatensaetze ohne Benutzername am alten
-- Host-Datensatz den Platzhaltertext '(kein Benutzername)' eingetragen,
-- statt die Spalte leer zu lassen. Die Leerpruefung in
-- app/rdp_proxy/guacd_client.py::build_rdp_params() greift nur bei einem
-- LEEREN String -- der Platzhalter ist nicht leer und wurde bislang als
-- echter RDP-Anmeldename an Windows gesendet. Das Ziel meldet daraufhin
-- "Anmeldung fehlgeschlagen" (bzw. guacd Status 769), was wie ein falsches
-- Passwort aussieht statt wie das eigentliche Problem: es fehlt schlicht
-- ein Benutzername.
--
-- Ruecksetzung auf einen leeren String macht die vorhandene, bereits
-- korrekte Leerpruefung wieder wirksam -- inkl. ihres Fallbacks auf
-- hosts.rdp_username (Altbestand) und ihrer sprechenden Fehlermeldung,
-- wenn auch das leer ist. Betroffen sind ausschliesslich Datensaetze mit
-- exakt diesem Migrations-Platzhalter; von Hand vergebene, echte
-- Benutzernamen bleiben unberuehrt.
UPDATE rdp_credentials
SET username = ''
WHERE username = '(kein Benutzername)';

View File

@ -0,0 +1,196 @@
-- Umsetzungsauftrag_Sonnet5.md Teil C, Phase 2, Schritt 12: Mandantenfaehigkeit
-- vollstaendig aus dem Schema entfernen.
--
-- Betreiberentscheidungen (Phase 0, per AskUserQuestion eingeholt):
-- * tenant_admins: Benutzer wurden bereits VOR dieser Migration per
-- scripts/promote_tenant_admins.py auf is_admin=1
-- befoerdert (Phase 1, mit eigenem Audit-Event je
-- Benutzer) -- diese Migration setzt das nur noch
-- schematisch um (Tabelle faellt weg).
-- * users.home_tenant_id: VOLLSTAENDIGER Rebuild (nicht als tote Spalte
-- belassen) -- daher ist 'users' hier mit dabei,
-- zusaetzlich zu den fuenf primaer betroffenen
-- Tabellen.
-- * api_tokens: bestehende Tokens bleiben gueltig (KEIN
-- Massenwiderruf) -- diese Migration entfernt nur
-- die tenant_id-Spalte, ruehrt token_hash/
-- revoked_at/expires_at nicht an.
--
-- ALTER TABLE ... DROP COLUMN scheidet fuer alle sechs Spalten aus (SQLite
-- verweigert das Droppen indizierter/fremdschluesselbehafteter Spalten) --
-- deshalb voller Tabellen-Rebuild (12-Schritte-Verfahren) fuer host_groups,
-- user_groups, ssh_keys, api_tokens, rdp_credentials und users.
--
-- Reihenfolge wichtig: erst die fuenf idx_*_tenant-Indizes droppen, dann
-- rebuilden, DANN tenant_admins/tenants droppen -- erst wenn keine
-- REFERENCES tenants(id)-Klausel mehr im Schema steht, verschwindet die
-- letzte Abhaengigkeit, die DROP TABLE tenants verhindern wuerde.
--
-- Kein Tabellen-Rename der ALTEN Tabelle (z.B. "host_groups RENAME TO
-- host_groups_old") -- das wuerde seit SQLite 3.25 (legacy_alter_table=OFF)
-- automatisch alle REFERENCES host_groups(...)-Klauseln in ANDEREN Tabellen
-- (z.B. hosts.host_group_id) auf den neuen Namen umschreiben, was hier
-- explizit unerwuenscht ist. Stattdessen: neue Tabelle unter Platzhalter-
-- namen anlegen, Daten kopieren, ALTE Tabelle direkt droppen (kein Rename),
-- NEUE Tabelle auf den Zielnamen umbenennen -- der Platzhaltername taucht in
-- keiner REFERENCES-Klausel auf, dieser Rename loest also keinen
-- Seiteneffekt aus.
--
-- Fremdschluesselgeprueft wird NICHT hier inline per PRAGMA foreign_key_check
-- (das PRAGMA liefert nur eine Ergebnismenge zurueck, die ein per
-- executescript() ausgefuehrtes Skript nicht auswertet) -- app/db.py::
-- _apply_migrations() fuehrt diese Pruefung nach JEDER Migration
-- programmatisch aus und bricht den Start mit RuntimeError ab, falls
-- Verletzungen gefunden werden.
--
-- Audit-Log: historische tenant_created/tenant_updated/tenant_deleted/
-- tenant_admin_granted/tenant_admin_revoked/tenant_admin_promoted_to_admin-
-- Eintraege werden NICHT angefasst (audit_log hat ohnehin keine
-- Tenant-Spalte) -- die Trigger no_audit_update/no_audit_delete
-- (0001_initial.sql) wuerden das ohnehin verweigern. Sie bleiben dauerhaft
-- im (manipulationssicheren) Log erhalten.
--
-- Verifiziert (Testharness gegen synthetischen Zwei-Mandanten-Datensatz,
-- ausserhalb dieses Repos, da hier keine echte Produktiv-DB verfuegbar
-- ist): Zeilenzahlen, Datenwerte, foreign_key_check, integrity_check,
-- Audit-Hash-Kette und die Phase-1-Beforderung ueberstehen die Migration
-- unveraendert.
PRAGMA foreign_keys = OFF;
DROP INDEX IF EXISTS idx_host_groups_tenant;
DROP INDEX IF EXISTS idx_user_groups_tenant;
DROP INDEX IF EXISTS idx_ssh_keys_tenant;
DROP INDEX IF EXISTS idx_api_tokens_tenant;
DROP INDEX IF EXISTS idx_rdp_credentials_tenant;
DROP INDEX IF EXISTS idx_users_home_tenant;
BEGIN IMMEDIATE;
-- --- host_groups ------------------------------------------------------------
CREATE TABLE host_groups_new (
id INTEGER PRIMARY KEY,
name TEXT UNIQUE NOT NULL,
description TEXT
);
INSERT INTO host_groups_new (id, name, description)
SELECT id, name, description FROM host_groups;
DROP TABLE host_groups;
ALTER TABLE host_groups_new RENAME TO host_groups;
-- --- user_groups --------------------------------------------------------
CREATE TABLE user_groups_new (
id INTEGER PRIMARY KEY,
name TEXT UNIQUE NOT NULL,
description TEXT,
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))
);
INSERT INTO user_groups_new (id, name, description, created_at)
SELECT id, name, description, created_at FROM user_groups;
DROP TABLE user_groups;
ALTER TABLE user_groups_new RENAME TO user_groups;
-- --- ssh_keys -------------------------------------------------------------
CREATE TABLE ssh_keys_new (
id INTEGER PRIMARY KEY,
label TEXT NOT NULL,
owner_user_id INTEGER REFERENCES users(id),
private_key_enc BLOB NOT NULL,
public_key TEXT NOT NULL,
key_type TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')),
rotated_at TEXT,
expires_at TEXT,
passphrase_enc BLOB,
username TEXT
);
INSERT INTO ssh_keys_new (
id, label, owner_user_id, private_key_enc, public_key, key_type,
created_at, rotated_at, expires_at, passphrase_enc, username
)
SELECT id, label, owner_user_id, private_key_enc, public_key, key_type,
created_at, rotated_at, expires_at, passphrase_enc, username
FROM ssh_keys;
DROP TABLE ssh_keys;
ALTER TABLE ssh_keys_new RENAME TO ssh_keys;
-- --- api_tokens -----------------------------------------------------------
CREATE TABLE api_tokens_new (
id INTEGER PRIMARY KEY,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
label TEXT NOT NULL,
token_hash TEXT NOT NULL UNIQUE,
token_prefix TEXT NOT NULL,
scopes_json TEXT NOT NULL,
created_by INTEGER REFERENCES users(id),
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')),
expires_at TEXT,
last_used_at TEXT,
revoked_at TEXT
);
INSERT INTO api_tokens_new (
id, user_id, label, token_hash, token_prefix, scopes_json,
created_by, created_at, expires_at, last_used_at, revoked_at
)
SELECT id, user_id, label, token_hash, token_prefix, scopes_json,
created_by, created_at, expires_at, last_used_at, revoked_at
FROM api_tokens;
DROP TABLE api_tokens;
ALTER TABLE api_tokens_new RENAME TO api_tokens;
CREATE INDEX IF NOT EXISTS idx_api_tokens_user ON api_tokens(user_id);
-- --- rdp_credentials --------------------------------------------------------
CREATE TABLE rdp_credentials_new (
id INTEGER PRIMARY KEY,
label TEXT NOT NULL,
username TEXT NOT NULL,
domain TEXT,
password_enc BLOB NOT NULL,
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')),
rotated_at TEXT
);
INSERT INTO rdp_credentials_new (id, label, username, domain, password_enc, created_at, rotated_at)
SELECT id, label, username, domain, password_enc, created_at, rotated_at
FROM rdp_credentials;
DROP TABLE rdp_credentials;
ALTER TABLE rdp_credentials_new RENAME TO rdp_credentials;
-- --- users (Betreiberentscheidung: voller Rebuild statt tote Spalte) -----
CREATE TABLE users_new (
id INTEGER PRIMARY KEY,
username TEXT UNIQUE NOT NULL,
password_hash TEXT NOT NULL,
totp_secret_enc BLOB,
totp_enrolled INTEGER NOT NULL DEFAULT 0,
is_active INTEGER NOT NULL DEFAULT 1,
is_admin INTEGER NOT NULL DEFAULT 0,
failed_logins INTEGER NOT NULL DEFAULT 0,
locked_until TEXT,
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')),
password_changed_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')),
must_change_password INTEGER NOT NULL DEFAULT 1,
session_version INTEGER NOT NULL DEFAULT 1,
deleted_at TEXT
);
INSERT INTO users_new (
id, username, password_hash, totp_secret_enc, totp_enrolled, is_active,
is_admin, failed_logins, locked_until, created_at, password_changed_at,
must_change_password, session_version, deleted_at
)
SELECT id, username, password_hash, totp_secret_enc, totp_enrolled, is_active,
is_admin, failed_logins, locked_until, created_at, password_changed_at,
must_change_password, session_version, deleted_at
FROM users;
DROP TABLE users;
ALTER TABLE users_new RENAME TO users;
-- --- tenant_admins/tenants zuletzt -- erst jetzt existiert keine
-- REFERENCES tenants(id)-Klausel mehr im Schema (alle sechs Tabellen oben
-- wurden bereits ohne tenant_id/home_tenant_id neu angelegt).
DROP INDEX IF EXISTS idx_tenant_admins_user;
DROP TABLE tenant_admins;
DROP TABLE tenants;
COMMIT;
PRAGMA foreign_keys = ON;

View File

@ -0,0 +1,34 @@
-- Teil D.6 Schritt 1 (Umsetzungsauftrag_Sonnet5.md): drei nie durchgesetzte
-- ("tote") Rollen entfernen, je nach Betreiberentscheidung. Der Betreiber
-- hat sich fuer alle drei fuer "Entfernen" statt "Ausimplementieren"
-- entschieden (Rueckfrage vom 2026-08-31):
--
-- * clipboard -- Clipboard bleibt ausschliesslich ueber das
-- host-globale hosts.clipboard_enabled
-- gesteuert (heutiges Verhalten, unveraendert).
-- * session_recording_view -- Sitzungs-Playback bleibt dauerhaft
-- ausschliesslich globalen Admins vorbehalten
-- (require_global_admin, unveraendert).
-- * admin_hostgroup -- keine delegierte Hostgruppen-Admin-Ebene;
-- es bleibt bei genau zwei Stufen (globaler
-- Admin vs. granulare Rollen ueber Gruppen).
--
-- Diese drei Rollen waren seit 0001_initial.sql vergebbar, aber kein
-- einziger Codepfad hat sie je geprueft (siehe D.1 im Umsetzungsauftrag) --
-- das Entfernen aendert daher am tatsaechlichen Verhalten NICHTS, schliesst
-- aber die falsche Sicherheitserwartung, dass eine Vergabe dieser Rollen
-- irgendeine Wirkung haette.
--
-- Reihenfolge wichtig: zuerst alle Vergaben dieser Rollen loeschen (FK
-- role_id -> roles(id) hat keine eigene ON DELETE-Klausel, siehe
-- 0001_initial.sql:88/0004_user_groups.sql:28), erst danach die
-- roles-Zeilen selbst.
DELETE FROM user_hostgroup_roles
WHERE role_id IN (SELECT id FROM roles WHERE name IN ('clipboard', 'session_recording_view', 'admin_hostgroup'));
DELETE FROM group_hostgroup_roles
WHERE role_id IN (SELECT id FROM roles WHERE name IN ('clipboard', 'session_recording_view', 'admin_hostgroup'));
DELETE FROM roles
WHERE name IN ('clipboard', 'session_recording_view', 'admin_hostgroup');

View File

@ -0,0 +1,49 @@
-- Migration 0016: SSH-Passwort-Zugangsdaten als eigenstaendiges Objekt mit
-- eigener ID -- Vorarbeit fuer Teil D.6 Schritt 3 (Umsetzungsauftrag_Sonnet5.md
-- D.3): Achse B ("Benutzergruppe x Zugangsdatensatz") braucht fuer alle drei
-- Credential-Arten (SSH-Key, RDP, SSH-Passwort) dieselbe Form -- eine
-- Freigabetabelle referenziert eine ID, keine host_id. SSH-Keys und (seit
-- Migration 0012) RDP-Zugangsdaten haben das bereits; SSH-Passwoerter noch
-- nicht (0011_ssh_password_credentials.sql: host_id war PRIMARY KEY, ein
-- Datensatz ausschliesslich 1:1 an genau einem Host).
--
-- Vorgehen exakt analog zu Migration 0012 (siehe dortiger Kommentar fuer die
-- ausfuehrliche Begruendung): alte 1:1-Tabelle bleibt als Datenquelle
-- erhalten, wird umbenannt; neues eigenstaendiges Objekt plus 1:1-
-- Zuordnungstabelle (bewusst weiterhin PRIMARY KEY auf host_id -- diese
-- Migration fuehrt NICHT die Mehrfachzuweisung-an-mehrere-Hosts-UI ein, die
-- RDP inzwischen hat; das waere ein eigener, spaeterer Schritt. Hier geht es
-- ausschliesslich darum, dass das Objekt eine stabile ID hat, auf die eine
-- Achse-B-Freigabetabelle verweisen kann).
--
-- Korrelation zwischen den beiden folgenden INSERTs laeuft wie bei 0012
-- ueber password_enc statt ueber eine temporaere ID-Spalte: jede
-- Verschluesselung verwendet einen frischen Zufalls-Nonce (app/security/
-- crypto.py::encrypt_secret), zwei Zeilen der Alttabelle koennen also nie
-- denselben password_enc-Wert haben.
ALTER TABLE ssh_password_credentials RENAME TO ssh_password_credentials_legacy;
CREATE TABLE IF NOT EXISTS ssh_password_credentials (
id INTEGER PRIMARY KEY,
label TEXT NOT NULL,
username TEXT NOT NULL,
password_enc BLOB NOT NULL,
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')),
rotated_at TEXT
);
CREATE TABLE IF NOT EXISTS host_ssh_password_credential_map (
host_id INTEGER PRIMARY KEY REFERENCES hosts(id) ON DELETE CASCADE,
ssh_password_credential_id INTEGER NOT NULL REFERENCES ssh_password_credentials(id)
);
INSERT INTO ssh_password_credentials (label, username, password_enc, created_at)
SELECT 'Migriert: ' || h.hostname, spcl.username, spcl.password_enc, spcl.updated_at
FROM ssh_password_credentials_legacy spcl
JOIN hosts h ON h.id = spcl.host_id;
INSERT INTO host_ssh_password_credential_map (host_id, ssh_password_credential_id)
SELECT spcl.host_id, spc.id
FROM ssh_password_credentials_legacy spcl
JOIN ssh_password_credentials spc ON spc.password_enc = spcl.password_enc;

View File

@ -0,0 +1,64 @@
-- Migration 0017: persoenliche Gruppen als rechteerhaltende Migration weg
-- von Direktvergaben (user_hostgroup_roles) -- Teil D.6 Schritt 3, D.4
-- (Umsetzungsauftrag_Sonnet5.md). Empfohlener Ansatz dort: "automatisch
-- erzeugte persoenliche Gruppe je betroffenem Benutzer, danach manuelle
-- Konsolidierung" -- niemand verliert oder gewinnt beim Umstieg etwas.
--
-- user_hostgroup_roles bleibt nach dieser Migration bestehen (Projekt-
-- konvention: Altes bleibt lesbar erhalten statt geloescht, siehe 0010/0012)
-- und wird auch weiterhin von app/rbac.py ausgewertet -- das Leeren/
-- Umbenennen dieser Tabelle ist D.6 Schritt 5 (Schreibpfade abschalten),
-- NICHT dieser Schritt. Diese Migration fuegt nur die Gruppen-Spiegelung
-- HINZU, sie nimmt niemandem etwas weg.
-- D.7-Risiko "Rechteausweitung ueber persoenliche Gruppen": Kennzeichen
-- "persoenlich" (fuer UI/Reports in Schritt 6) plus Trigger-Sperre "max. 1
-- Mitglied" weiter unten. ADD COLUMN mit konstantem Default ist in SQLite
-- ohne Tabellen-Rebuild moeglich.
ALTER TABLE user_groups ADD COLUMN is_personal INTEGER NOT NULL DEFAULT 0;
CREATE TRIGGER IF NOT EXISTS enforce_personal_group_single_member
BEFORE INSERT ON user_group_members
FOR EACH ROW
WHEN (SELECT is_personal FROM user_groups WHERE id = NEW.user_group_id) = 1
AND (SELECT COUNT(*) FROM user_group_members WHERE user_group_id = NEW.user_group_id) >= 1
BEGIN
SELECT RAISE(ABORT, 'Persoenliche Gruppe darf nur ein Mitglied haben');
END;
-- Eine persoenliche Gruppe je Benutzer mit mindestens einer Direktvergabe.
-- Name enthaelt bewusst die User-ID (nicht nur den Benutzernamen), um jede
-- Kollision mit einer bereits existierenden, gleichnamigen Gruppe
-- auszuschliessen (user_groups.name ist UNIQUE NOT NULL) -- ein Fehlschlag
-- hier wuerde die gesamte Migration abbrechen. Explizite Transaktion fuer
-- Atomaritaet (alle drei INSERTs oder keiner) -- anders als 0014 wird hier
-- nichts umgebaut/gedroppt, PRAGMA foreign_keys = OFF ist daher nicht noetig
-- (keine der drei INSERT-Anweisungen kann eine gueltige Fremdschluessel-
-- Referenz verletzen).
BEGIN IMMEDIATE;
INSERT INTO user_groups (name, description, is_personal)
SELECT 'Persoenlich: ' || u.username || ' (#' || u.id || ')',
'Automatisch erzeugt bei der Migration auf ausschliesslich '
|| 'gruppenbasierte Berechtigungen (Teil D.6 Schritt 3, Migration '
|| '0017) -- Spiegel der vormaligen Direktvergaben dieses Benutzers '
|| 'aus user_hostgroup_roles. Kann spaeter bewusst in eine echte '
|| 'Team-Gruppe ueberfuehrt werden (D.6 Schritt 6).',
1
FROM users u
WHERE u.id IN (SELECT DISTINCT user_id FROM user_hostgroup_roles)
AND u.deleted_at IS NULL;
INSERT INTO user_group_members (user_group_id, user_id, added_by, added_at)
SELECT ug.id, u.id, NULL, strftime('%Y-%m-%dT%H:%M:%fZ','now')
FROM user_groups ug
JOIN users u ON ug.name = 'Persoenlich: ' || u.username || ' (#' || u.id || ')'
WHERE ug.is_personal = 1;
INSERT INTO group_hostgroup_roles (user_group_id, host_group_id, role_id, granted_by, granted_at, expires_at)
SELECT ug.id, uhr.host_group_id, uhr.role_id, uhr.granted_by, uhr.granted_at, uhr.expires_at
FROM user_hostgroup_roles uhr
JOIN users u ON u.id = uhr.user_id
JOIN user_groups ug ON ug.name = 'Persoenlich: ' || u.username || ' (#' || u.id || ')' AND ug.is_personal = 1;
COMMIT;

View File

@ -0,0 +1,91 @@
-- Migration 0018: Achse B (Umsetzungsauftrag_Sonnet5.md Teil D.3) --
-- "Benutzergruppe x Zugangsdatensatz". Beantwortet erstmals, WOMIT sich eine
-- Gruppe anmeldet: eine Verbindung soll kuenftig nur zustande kommen, wenn
-- ein Zugangsdatensatz existiert, der SOWOHL dem Host zugeordnet ALS AUCH
-- einer Gruppe des Benutzers freigegeben ist (Achse A -- ssh_connect/
-- rdp_connect je Hostgruppe -- bleibt unveraendert und bestimmt weiterhin,
-- OB ueberhaupt verbunden werden darf).
--
-- Drei schmale Freigabetabellen, eine je Credential-Art, Aufbau analog zu
-- group_hostgroup_roles (zusammengesetzter PK, FK auf user_groups mit ON
-- DELETE CASCADE, FK auf das jeweilige Credential-Objekt, granted_by/
-- granted_at/expires_at). Alle drei Credential-Arten haben seit Migration
-- 0012 (RDP) bzw. 0016 (SSH-Passwort, diese Session) eine eigene stabile ID
-- -- SSH-Keys hatten sie schon immer (0001_initial.sql).
--
-- WICHTIG: diese Migration schafft nur das Schema UND befuellt es
-- rechteneutral (siehe unten) -- app/rbac.py/die Proxies pruefen diese
-- Tabellen noch NICHT (das ist D.6 Schritt 4, "Lesepfade umstellen", noch
-- nicht umgesetzt). Bis dahin bleibt das heutige Verhalten (jeder Nutzer mit
-- ssh_connect/rdp_connect auf der Hostgruppe nutzt automatisch das am Host
-- haengende Credential) unveraendert in Kraft -- diese Migration allein
-- aendert also NICHTS am Laufzeitverhalten.
CREATE TABLE IF NOT EXISTS group_ssh_key_grants (
user_group_id INTEGER NOT NULL REFERENCES user_groups(id) ON DELETE CASCADE,
ssh_key_id INTEGER NOT NULL REFERENCES ssh_keys(id),
granted_by INTEGER REFERENCES users(id),
granted_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')),
expires_at TEXT,
PRIMARY KEY (user_group_id, ssh_key_id)
);
CREATE INDEX IF NOT EXISTS idx_gskg_group ON group_ssh_key_grants(user_group_id);
CREATE INDEX IF NOT EXISTS idx_gskg_key ON group_ssh_key_grants(ssh_key_id);
CREATE TABLE IF NOT EXISTS group_rdp_credential_grants (
user_group_id INTEGER NOT NULL REFERENCES user_groups(id) ON DELETE CASCADE,
rdp_credential_id INTEGER NOT NULL REFERENCES rdp_credentials(id),
granted_by INTEGER REFERENCES users(id),
granted_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')),
expires_at TEXT,
PRIMARY KEY (user_group_id, rdp_credential_id)
);
CREATE INDEX IF NOT EXISTS idx_grcg_group ON group_rdp_credential_grants(user_group_id);
CREATE INDEX IF NOT EXISTS idx_grcg_cred ON group_rdp_credential_grants(rdp_credential_id);
CREATE TABLE IF NOT EXISTS group_ssh_password_credential_grants (
user_group_id INTEGER NOT NULL REFERENCES user_groups(id) ON DELETE CASCADE,
ssh_password_credential_id INTEGER NOT NULL REFERENCES ssh_password_credentials(id),
granted_by INTEGER REFERENCES users(id),
granted_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')),
expires_at TEXT,
PRIMARY KEY (user_group_id, ssh_password_credential_id)
);
CREATE INDEX IF NOT EXISTS idx_gspcg_group ON group_ssh_password_credential_grants(user_group_id);
CREATE INDEX IF NOT EXISTS idx_gspcg_cred ON group_ssh_password_credential_grants(ssh_password_credential_id);
-- Rechteneutrale Vorbefuellung (D.4 Schritt 5): fuer jeden Host mit
-- zugeordnetem Zugangsdatensatz wird jede Gruppe freigeschaltet, die auf der
-- Hostgruppe dieses Hosts die passende Connect-Rolle haelt -- reproduziert
-- exakt das heutige (Achse-A-only) Verhalten, bei dem JEDER Nutzer mit
-- ssh_connect/rdp_connect automatisch das am Host haengende Credential
-- benutzen darf. INSERT OR IGNORE, weil dieselbe (Gruppe, Credential)-
-- Kombination ueber mehrere Hosts hinweg mehrfach auftreten kann (ein
-- SSH-Key kann an mehreren Hosts derselben Hostgruppe haengen).
--
-- Setzt group_hostgroup_roles NACH der Personengruppen-Spiegelung aus
-- Migration 0017 voraus (Reihenfolge 0017 vor 0018 ist deshalb zwingend) --
-- sonst wuerden Nutzer, die ihre Verbindungsrechte bisher nur direkt (nicht
-- ueber eine Gruppe) hatten, hier uebergangen.
INSERT OR IGNORE INTO group_ssh_key_grants (user_group_id, ssh_key_id, granted_by, granted_at)
SELECT DISTINCT ghr.user_group_id, hskm.ssh_key_id, NULL, strftime('%Y-%m-%dT%H:%M:%fZ','now')
FROM host_ssh_key_map hskm
JOIN hosts h ON h.id = hskm.host_id
JOIN group_hostgroup_roles ghr ON ghr.host_group_id = h.host_group_id
JOIN roles r ON r.id = ghr.role_id AND r.name = 'ssh_connect';
INSERT OR IGNORE INTO group_rdp_credential_grants (user_group_id, rdp_credential_id, granted_by, granted_at)
SELECT DISTINCT ghr.user_group_id, hrcm.rdp_credential_id, NULL, strftime('%Y-%m-%dT%H:%M:%fZ','now')
FROM host_rdp_credential_map hrcm
JOIN hosts h ON h.id = hrcm.host_id
JOIN group_hostgroup_roles ghr ON ghr.host_group_id = h.host_group_id
JOIN roles r ON r.id = ghr.role_id AND r.name = 'rdp_connect';
INSERT OR IGNORE INTO group_ssh_password_credential_grants
(user_group_id, ssh_password_credential_id, granted_by, granted_at)
SELECT DISTINCT ghr.user_group_id, hspcm.ssh_password_credential_id, NULL, strftime('%Y-%m-%dT%H:%M:%fZ','now')
FROM host_ssh_password_credential_map hspcm
JOIN hosts h ON h.id = hspcm.host_id
JOIN group_hostgroup_roles ghr ON ghr.host_group_id = h.host_group_id
JOIN roles r ON r.id = ghr.role_id AND r.name = 'ssh_connect';

View File

@ -0,0 +1,26 @@
-- Migration 0019: Direktvergabe von Hostgruppen-Rollen retiriert (Teil D.6
-- Schritt 5, Umsetzungsauftrag_Sonnet5.md D.4 Punkt 4: "user_hostgroup_roles
-- leeren bzw. umbenennen").
--
-- Ab diesem Codestand ist `user_hostgroup_roles` von KEINEM Lese- oder
-- Schreibpfad der Anwendung mehr aus erreichbar:
-- * Lesepfade: app/rbac.py liest die Tabelle bereits seit Migration 0017 +
-- Schritt 4 (Codeaenderung, keine eigene Migration) nicht mehr -- jede
-- vormals direkte Vergabe wurde durch Migration 0017 1:1 in eine
-- persoenliche Gruppe gespiegelt (group_hostgroup_roles).
-- * Schreibpfade: POST /admin/roles/grant und /admin/roles/revoke
-- (app/admin/routes.py) antworten seit derselben Codeaenderung mit HTTP
-- 410 statt in die Tabelle zu schreiben.
--
-- Projektkonvention (siehe 0010:9-14, 0012:12-17, 0016 oben): eine Tabelle
-- wird beim Rueckbau nicht gedroppt, sondern umbenannt und als historische
-- Datenquelle aufbewahrt -- fuer eine spaetere Session, die z.B. pruefen
-- will, wann/von wem eine bestimmte Rolle urspruenglich direkt vergeben
-- wurde, bevor Migration 0017 sie gespiegelt hat. Der zugehoerige Index
-- (0001_initial.sql:94) bleibt ueber die Umbenennung hinweg automatisch an
-- die Tabelle gebunden -- SQLite aktualisiert die interne Referenz.
--
-- Keine FK verweist auf user_hostgroup_roles (sie ist ein reines Blatt im
-- Schema), daher genuegt eine einfache Umbenennung ohne Neuaufbau-Tanz.
ALTER TABLE user_hostgroup_roles RENAME TO user_hostgroup_roles_legacy;

View File

@ -0,0 +1,22 @@
-- Migration 0020: Index fuer die eigene-Sitzungen-Abfrage (Teil F.3.6,
-- Umsetzungsauftrag_Sonnet5.md): GET /catalog/sessions (app/catalog/
-- routes.py) filtert immer nach user_id UND (im Standardfall
-- active_only=true) zusaetzlich nach ended_at IS NULL.
--
-- Bisher existierte nur idx_sessions_user (user_id allein,
-- app/db/migrations/0001_initial.sql:107) -- fuer eine wachsende
-- sessions-Tabelle waere "meine noch offenen Sitzungen" damit ein
-- Index-Scan ueber ALLE Sitzungen dieses Benutzers mit anschliessendem
-- Filter auf ended_at, statt direkt auf die (typischerweise sehr kleine)
-- Teilmenge der offenen Sitzungen zuzugreifen.
--
-- Ein zusammengesetzter Index (user_id, ended_at) deckt per
-- SQLite-Index-Praefix-Regel sowohl "alle meine Sitzungen" (user_id
-- allein) als auch "meine offenen Sitzungen" (user_id + ended_at) ab und
-- macht idx_sessions_user damit redundant -- bewusst NICHT geloescht
-- (Projektkonvention: Indizes/Tabellen werden beim Rueckbau nicht
-- gedroppt, siehe 0019_retire_direct_role_grants.sql; ausserdem nutzen
-- andere Abfragen im Adminbereich, z.B. GET /admin/sessions, weiterhin
-- ausschliesslich user_id ueber JOINs, nicht ueber diesen Index).
CREATE INDEX IF NOT EXISTS idx_sessions_user_ended ON sessions(user_id, ended_at);

View File

@ -4,10 +4,12 @@ Auslieferung zusammen (siehe Konzept 6.6 Web-Anwendungs-Hardening).
"""
from __future__ import annotations
import asyncio
import logging
from contextlib import asynccontextmanager
from fastapi import Depends, FastAPI, Request
from fastapi import Depends, FastAPI, Query, Request
from fastapi.responses import JSONResponse
from fastapi.openapi.utils import get_openapi
from fastapi.responses import HTMLResponse
from fastapi.staticfiles import StaticFiles
@ -18,9 +20,11 @@ from app.admin.routes import router as admin_router
from app.auth.deps import CurrentUser, require_global_admin
from app.auth.routes import router as auth_router
from app.catalog.routes import router as catalog_router
from app.db import close_db, init_db
from app.config import settings
from app.db import close_db, get_db, init_db
from app.rdp_proxy.ws_tunnel import router as rdp_ws_router
from app.security import log_stream
from app.security.session_reaper import reap_orphaned_sessions
from app.ssh_proxy.proxy import bcrypt_kdf_available
from app.ssh_proxy.sftp import router as sftp_router
from app.ssh_proxy.terminal_ws import router as ssh_ws_router
@ -56,6 +60,13 @@ async def lifespan(app: FastAPI):
log_stream.install()
_check_optional_dependencies()
await init_db()
# E7 (Umsetzungsauftrag_Sonnet5.md Teil E.1): Sitzungen, die beim letzten
# Absturz/Neustart offen waren, haben ended_at IS NULL fuer immer -- es
# gibt keinen Task mehr, der sie regulaer schliessen wuerde. Muss NACH
# init_db() laufen (Migrationen/Tabellen muessen existieren) und VOR dem
# ersten Request, damit die Sessionview und kuenftige
# "wie viele Sitzungen offen"-Zaehlungen (Teil F) nie mit Geistern rechnen.
await reap_orphaned_sessions(get_db())
yield
await close_db()
@ -88,7 +99,7 @@ async def security_headers_middleware(request: Request, call_next):
"default-src 'self'; "
"script-src 'self'; "
"style-src 'self'; "
"img-src 'self' data:; "
"img-src 'self' data: blob:; "
"connect-src 'self' ws: wss:; "
"frame-ancestors 'none'; "
"base-uri 'self'; "
@ -114,6 +125,15 @@ async def dashboard(request: Request):
return templates.TemplateResponse(request, "dashboard.html", {})
@app.get("/workspace", response_class=HTMLResponse)
async def workspace_page(request: Request):
# F3 (Umsetzungsauftrag_Sonnet5.md Teil F.3): dauerhafte Arbeitsflaeche
# mit Seitenleiste -- wie /dashboard rein statisches Markup, Auth
# clientseitig ueber GET /auth/me, Sitzungs-/Katalogdaten ueber die
# bestehenden /catalog/*-Endpunkte (app/catalog/routes.py).
return templates.TemplateResponse(request, "workspace.html", {})
@app.get("/admin", response_class=HTMLResponse)
async def admin_page(request: Request):
# Wie /dashboard: Seite selbst ist statisches Markup ohne Secrets, die
@ -134,8 +154,55 @@ async def rdp_page(request: Request, host_id: int):
@app.get("/healthz")
async def healthz():
return {"status": "ok"}
async def healthz(deep: bool = Query(default=False)):
"""Flache Variante (Standard): Prozess laeuft, sonst nichts -- schnell
genug fuer haeufige Load-Balancer-/Monitoring-Abfragen.
Tiefe Variante (`?deep=true`, Befund B.2 in Umsetzungsauftrag_Sonnet5.md
Teil B): prueft zusaetzlich die DB-Verbindung (billiges `SELECT 1` auf
der einen gemeinsamen Verbindung, siehe app/db.py) und guacd per
TCP-Verbindungsversuch mit kurzem Timeout. Vorher lieferte /healthz
IMMER "ok", auch wenn guacd tot war -- das fiel erst auf, wenn ein
Benutzer eine RDP-Sitzung versuchte. Bewusst NICHT als Standardverhalten,
damit ein ausgefallenes guacd nicht jede flache Liveness-Pruefung (die
ueblicherweise sehr haeufig laeuft) mit in den Fehlerzustand reisst.
"""
if not deep:
return {"status": "ok"}
checks: dict[str, str] = {}
healthy = True
try:
conn = get_db()
await asyncio.wait_for(conn.execute("SELECT 1"), timeout=2.0)
checks["db"] = "ok"
except Exception as exc:
checks["db"] = f"fehler: {exc.__class__.__name__}"
healthy = False
try:
reader, writer = await asyncio.wait_for(
asyncio.open_connection(settings.guacd_host, settings.guacd_port), timeout=2.0
)
writer.close()
try:
await writer.wait_closed()
except Exception:
pass
checks["guacd"] = "ok"
except Exception as exc:
checks["guacd"] = (
f"nicht erreichbar unter {settings.guacd_host}:{settings.guacd_port} "
f"({exc.__class__.__name__})"
)
healthy = False
status_code = 200 if healthy else 503
return JSONResponse(
status_code=status_code,
content={"status": "ok" if healthy else "fehler", "checks": checks},
)
# --- API-Dokumentation --------------------------------------------------------

View File

@ -16,8 +16,7 @@ USERNAME_RE = re.compile(r"^[a-zA-Z0-9._-]{3,64}$")
HOSTNAME_LABEL_RE = re.compile(r"^[a-zA-Z0-9][a-zA-Z0-9._-]{0,127}$")
ROLE_NAME = Literal[
"ssh_connect", "rdp_connect", "file_transfer", "clipboard",
"session_recording_view", "admin_hostgroup",
"ssh_connect", "rdp_connect", "file_transfer",
# Hinzugefuegt fuer RBAC-Erweiterung 'Credentials ins RBAC-Modell' (Migration
# 0008_credentials_roles.sql): erlaubt Nicht-Admins mit dieser Rolle auf
# einer Hostgruppe gezielten Zugriff auf Zugangsdaten (SSH-Keys, RDP-
@ -25,6 +24,13 @@ ROLE_NAME = Literal[
# require_admin_scope_or_host_role und admin/routes.py (rdp-credentials,
# ssh-keys-Zuordnung, GET /admin/hosts/{id}/credentials).
"credentials_view", "credentials_manage",
# 'clipboard', 'session_recording_view' und 'admin_hostgroup' wurden mit
# Migration 0015_remove_dead_roles.sql entfernt (Teil D.6 Schritt 1,
# Umsetzungsauftrag_Sonnet5.md) -- sie waren seit 0001_initial.sql
# vergebbar, aber kein Codepfad hat sie je geprueft. Betreiberentscheidung
# (2026-08-31): Clipboard bleibt host-global (hosts.clipboard_enabled),
# Sitzungs-Playback bleibt global-admin-only, keine delegierte
# Hostgruppen-Admin-Ebene.
]
@ -58,11 +64,6 @@ class UserCreateRequest(BaseModel):
username: str = Field(min_length=3, max_length=64)
initial_password: str = Field(min_length=12, max_length=256)
is_admin: bool = False
# Nur relevant, wenn der anlegende Principal Mandanten-Admin ist: wird
# serverseitig ohnehin auf den/die erlaubten Mandanten erzwungen (siehe
# create_user() in admin/routes.py) -- ein Mandanten-Admin kann hierueber
# KEINEN User einem fremden Mandanten zuordnen. Fuer Super-Admins optional.
home_tenant_id: int | None = None
@field_validator("username")
@classmethod
@ -78,27 +79,9 @@ class UserUpdateRequest(BaseModel):
new_password: str | None = Field(default=None, min_length=12, max_length=256)
class TenantCreateRequest(BaseModel):
name: str = Field(min_length=1, max_length=128)
description: str | None = Field(default=None, max_length=1024)
class TenantUpdateRequest(BaseModel):
name: str | None = Field(default=None, min_length=1, max_length=128)
description: str | None = Field(default=None, max_length=1024)
is_active: bool | None = None
class TenantAdminAssignRequest(BaseModel):
user_id: int
class HostGroupCreateRequest(BaseModel):
name: str = Field(min_length=1, max_length=128)
description: str | None = Field(default=None, max_length=1024)
# Nur fuer Super-Admins relevant (Mandanten-Admins werden serverseitig
# ohnehin auf ihren eigenen Mandanten festgelegt, siehe admin/routes.py).
tenant_id: int | None = None
class HostGroupUpdateRequest(BaseModel):
@ -179,7 +162,6 @@ class RoleRevokeRequest(BaseModel):
class UserGroupCreateRequest(BaseModel):
name: str = Field(min_length=1, max_length=128)
description: str | None = Field(default=None, max_length=1024)
tenant_id: int | None = None
class UserGroupUpdateRequest(BaseModel):
@ -204,14 +186,29 @@ class GroupRoleRevokeRequest(BaseModel):
role_name: ROLE_NAME
class GroupCredentialGrantRequest(BaseModel):
"""Achse B (Teil D Schritt 3+5, Umsetzungsauftrag_Sonnet5.md): Freigabe
EINES Zugangsdatensatzes (SSH-Key, RDP-Zugangsdaten oder SSH-Passwort --
die Art steht im Endpunktpfad, nicht im Body, siehe app/admin/routes.py)
an eine Benutzergruppe. Struktur bewusst identisch zu
GroupRoleGrantRequest (Achse A), nur mit `credential_id` statt
`host_group_id`/`role_names` -- unterschiedliche Achse, gleiches
Vergabemuster (Gruppe + Ziel + optionales Ablaufdatum)."""
user_group_id: int
credential_id: int
expires_at: str | None = None
class GroupCredentialRevokeRequest(BaseModel):
user_group_id: int
credential_id: int
class ApiTokenCreateRequest(BaseModel):
label: str = Field(min_length=1, max_length=128)
user_id: int
scopes: list[str] = Field(min_length=1, max_length=32)
expires_at: str | None = None
# Nur fuer Super-Admins relevant -- ein Mandanten-Admin wird serverseitig
# ohnehin auf seinen eigenen Mandanten festgelegt.
tenant_id: int | None = None
class SshKeyCreateRequest(BaseModel):
@ -219,7 +216,6 @@ class SshKeyCreateRequest(BaseModel):
private_key_pem: str = Field(min_length=1, max_length=32_768)
public_key: str = Field(min_length=1, max_length=8192)
key_type: Literal["ed25519", "rsa-3072", "rsa-4096", "ca-cert"]
tenant_id: int | None = None
# Anmeldename am Zielsystem. Gehoert zum Schluessel, nicht zum Host
# (Migration 0010): ein Schluessel ist immer "der Zugang von Benutzer X".
# Fuer mehrere Konten wird je ein Schluesseleintrag angelegt.
@ -264,7 +260,6 @@ class RdpCredentialCreateRequest(BaseModel):
username: str = Field(min_length=1, max_length=128)
domain: str | None = Field(default=None, max_length=128)
password: str = Field(min_length=1, max_length=512)
tenant_id: int | None = None
class RdpCredentialUpdateRequest(BaseModel):

View File

@ -1,27 +1,87 @@
"""RBAC-Durchsetzung: Rolle × Hostgruppe (siehe Konzept 4.6).
"""RBAC-Durchsetzung: Rolle × Hostgruppe (siehe Konzept 4.6) sowie, seit
Teil D Schritt 4, Achse B (Benutzergruppe × Zugangsdatensatz).
Eine Rolle gilt fuer einen User entweder, wenn sie ihm DIREKT vergeben wurde
(user_hostgroup_roles), ODER wenn sie einer Benutzergruppe (user_groups)
vergeben wurde, in der der User Mitglied ist (group_hostgroup_roles) --
volle Rollen-Vererbung: jedes Gruppenmitglied erhaelt automatisch alle der
Gruppe gewaehrten Rollen, ohne individuellen Eintrag."""
Eine Rolle gilt fuer einen User NUR NOCH ueber seine Mitgliedschaft in einer
Benutzergruppe (user_groups/user_group_members), der die Rolle auf der
Hostgruppe vergeben wurde (group_hostgroup_roles). Der frueher zusaetzlich
vorhandene Direktvergabe-Zweig ueber user_hostgroup_roles ist mit Teil D
Schritt 4 entfallen (Umsetzungsauftrag_Sonnet5.md, D.3: "user_has_role()
schrumpft auf einen Zweig"). Das ist erst SICHER, seit Teil D Schritt 3
(Migration 0017_personal_groups.sql) jeden Benutzer mit vormals direkten
Vergaben in eine automatisch erzeugte persoenliche Gruppe mit identischen
Vergaben gespiegelt hat -- siehe FORTSETZUNG_Teil_D.md Abschnitt 1a. Die
Tabelle user_hostgroup_roles selbst existiert als Altlast weiter (Migration
0015 hat nur die drei toten Rollen daraus entfernt) und wird erst in
Schritt 5 geleert/umbenannt; sie wird ab hier von keiner Leselogik mehr
konsultiert.
Achse B (group_ssh_key_grants, group_rdp_credential_grants,
group_ssh_password_credential_grants, angelegt in Migration
0018_credential_group_grants.sql) beantwortet eine zweite, unabhaengige
Frage: nicht "darf dieser Benutzer sich mit Host X verbinden" (Achse A,
oben), sondern "WOMIT darf er sich anmelden". user_can_use_credential() und
resolve_credential_for_user_on_host() sind die dafuer vorgesehenen zwei
neuen Funktionen (D.3: "eine Pruefung 'darf dieser Benutzer diesen
Zugangsdatensatz nutzen' und eine Auflösung 'welcher Satz gilt fuer diesen
Benutzer auf diesem Host'")."""
from __future__ import annotations
from typing import Literal
import aiosqlite
CredentialKind = Literal["ssh_key", "rdp_credential", "ssh_password_credential"]
# Achse-B-Freigabetabelle je Credential-Art (Migration 0018) --
# (Tabellenname, Spaltenname der Credential-ID).
_GRANT_TABLE: dict[CredentialKind, tuple[str, str]] = {
"ssh_key": ("group_ssh_key_grants", "ssh_key_id"),
"rdp_credential": ("group_rdp_credential_grants", "rdp_credential_id"),
"ssh_password_credential": (
"group_ssh_password_credential_grants",
"ssh_password_credential_id",
),
}
# Host<->Credential-Zuordnungstabelle je Credential-Art (unveraendert seit
# vor Teil D) -- (Tabellenname, Spaltenname der Credential-ID). ssh_key ist
# n:m (mehrere Schluessel je Host moeglich); die beiden anderen sind laut
# Schema 1:1 (PK auf host_id), Mehrdeutigkeit kann dort strukturell nicht
# auftreten.
_MAP_TABLE: dict[CredentialKind, tuple[str, str]] = {
"ssh_key": ("host_ssh_key_map", "ssh_key_id"),
"rdp_credential": ("host_rdp_credential_map", "rdp_credential_id"),
"ssh_password_credential": (
"host_ssh_password_credential_map",
"ssh_password_credential_id",
),
}
class AmbiguousCredentialError(Exception):
"""Mehr als ein fuer den Benutzer nutzbarer Zugangsdatensatz derselben Art
an einem Host. Bewusst ein harter Fehler statt einer stillen Auswahl
(Umsetzungsauftrag_Sonnet5.md D.3: "Bei mehreren Treffern lieber ein
klarer Fehler ... als das heutige LIMIT 1 ohne ORDER BY"). Kann nur bei
kind="ssh_key" auftreten (n:m-Zuordnung); bei den beiden anderen Arten
ist die Host-Zuordnung strukturell 1:1."""
def __init__(self, *, host_id: int, kind: CredentialKind, credential_ids: list[int]):
self.host_id = host_id
self.kind = kind
self.credential_ids = credential_ids
super().__init__(
f"Mehrdeutige Zugangsdaten (kind={kind}) fuer Host {host_id}: "
f"mehrere fuer den Benutzer nutzbare Eintraege {credential_ids} -- "
"keine automatische Auswahl."
)
async def user_has_role(
conn: aiosqlite.Connection, *, user_id: int, host_group_id: int, role_name: str
) -> bool:
cursor = await conn.execute(
"""
SELECT 1 FROM user_hostgroup_roles uhr
JOIN roles r ON r.id = uhr.role_id
WHERE uhr.user_id = ?
AND uhr.host_group_id = ?
AND r.name = ?
AND (uhr.expires_at IS NULL OR uhr.expires_at > strftime('%Y-%m-%dT%H:%M:%fZ','now'))
UNION
SELECT 1 FROM group_hostgroup_roles ghr
JOIN roles r ON r.id = ghr.role_id
JOIN user_group_members ugm ON ugm.user_group_id = ghr.user_group_id
@ -31,7 +91,7 @@ async def user_has_role(
AND (ghr.expires_at IS NULL OR ghr.expires_at > strftime('%Y-%m-%dT%H:%M:%fZ','now'))
LIMIT 1
""",
(user_id, host_group_id, role_name, user_id, host_group_id, role_name),
(user_id, host_group_id, role_name),
)
row = await cursor.fetchone()
return row is not None
@ -45,3 +105,86 @@ async def user_has_role_for_host(
if row is None:
return False
return await user_has_role(conn, user_id=user_id, host_group_id=row[0], role_name=role_name)
async def user_host_group_ids_with_any_role(
conn: aiosqlite.Connection, *, user_id: int, role_names: tuple[str, ...]
) -> set[int]:
"""Wie user_has_role(), aber fuer eine oder mehrere Rollen auf einmal und
ueber ALLE Hostgruppen hinweg statt eine einzelne host_group_id zu
pruefen -- fuer Uebersichtsabfragen (z.B. den Katalog, app/catalog/
routes.py), die sonst pro Hostgruppe/Host eine Einzelabfrage bräuchten.
S13 (Umsetzungsauftrag_Sonnet5.md Teil D.2): app/rbac.py ist die einzige
Stelle, die RBAC ausserhalb hier nachbaut -- Aufrufer bauen sie nicht
mehr selbst nach."""
if not role_names:
return set()
placeholders = ",".join("?" for _ in role_names)
cursor = await conn.execute(
f"""
SELECT ghr.host_group_id FROM group_hostgroup_roles ghr
JOIN roles r ON r.id = ghr.role_id
JOIN user_group_members ugm ON ugm.user_group_id = ghr.user_group_id
WHERE ugm.user_id = ? AND r.name IN ({placeholders})
AND (ghr.expires_at IS NULL OR ghr.expires_at > strftime('%Y-%m-%dT%H:%M:%fZ','now'))
""",
(user_id, *role_names),
)
rows = await cursor.fetchall()
return {row[0] for row in rows}
async def user_can_use_credential(
conn: aiosqlite.Connection, *, user_id: int, kind: CredentialKind, credential_id: int
) -> bool:
"""Achse B: darf dieser Benutzer diesen konkreten Zugangsdatensatz
benutzen -- ueber die Mitgliedschaft in mindestens einer Benutzergruppe,
der der Datensatz freigegeben wurde (group_*_grants, Migration 0018)."""
table, col = _GRANT_TABLE[kind]
cursor = await conn.execute(
f"""
SELECT 1 FROM {table} g
JOIN user_group_members ugm ON ugm.user_group_id = g.user_group_id
WHERE ugm.user_id = ? AND g.{col} = ?
AND (g.expires_at IS NULL OR g.expires_at > strftime('%Y-%m-%dT%H:%M:%fZ','now'))
LIMIT 1
""",
(user_id, credential_id),
)
row = await cursor.fetchone()
return row is not None
async def resolve_credential_for_user_on_host(
conn: aiosqlite.Connection, *, user_id: int, host_id: int, kind: CredentialKind
) -> int | None:
"""Achse B: welcher Zugangsdatensatz der angegebenen Art gilt fuer diesen
Benutzer auf diesem Host -- ersetzt die frueher benutzerblinden
`LIMIT 1`-Abfragen (app/ssh_proxy/proxy.py, app/rdp_proxy/ws_tunnel.py).
Liefert die Credential-ID, die (a) dem Host ueber die jeweilige
host_*_map-Tabelle zugeordnet UND (b) mindestens einer Benutzergruppe
dieses Users ueber Achse B freigegeben ist. None, wenn keine passt (kann
zwei Ursachen haben: kein Credential dieser Art am Host ODER eines ist
zugeordnet, aber keiner Gruppe des Benutzers freigegeben -- Aufrufer
formulieren die Fehlermeldung entsprechend). Wirft
AmbiguousCredentialError, wenn mehr als eine Credential-ID passt."""
map_table, map_col = _MAP_TABLE[kind]
grant_table, grant_col = _GRANT_TABLE[kind]
cursor = await conn.execute(
f"""
SELECT DISTINCT m.{map_col} FROM {map_table} m
JOIN {grant_table} g ON g.{grant_col} = m.{map_col}
JOIN user_group_members ugm ON ugm.user_group_id = g.user_group_id
WHERE m.host_id = ? AND ugm.user_id = ?
AND (g.expires_at IS NULL OR g.expires_at > strftime('%Y-%m-%dT%H:%M:%fZ','now'))
""",
(host_id, user_id),
)
rows = await cursor.fetchall()
ids = [row[0] for row in rows]
if not ids:
return None
if len(ids) > 1:
raise AmbiguousCredentialError(host_id=host_id, kind=kind, credential_ids=ids)
return ids[0]

View File

@ -18,12 +18,206 @@ from __future__ import annotations
import asyncio
import codecs
import logging
import re
from pathlib import Path
from app.config import settings
logger = logging.getLogger("jumphost.rdp_proxy.guacd")
def rdp_drive_path(host_id: int, session_id: int) -> Path:
"""Pfad des pro Sitzung umgeleiteten RDP-Laufwerks (Befund E6,
Umsetzungsauftrag Teil E).
Vorher war der Pfad NUR je Host angelegt (`.../rdp-drives/{host_id}`,
fuer alle Sitzungen auf diesem Host identisch) -- zwei gleichzeitige
Benutzer auf demselben Windows-Ziel teilten sich damit ein Verzeichnis:
A sah/ueberschrieb/las die Dateien von B. Je-Sitzung-Pfade schliessen das
aus; Aufraeumen beim Sitzungsende siehe app/rdp_proxy/ws_tunnel.py
(`_cleanup_rdp_drive_path`).
"""
return settings.data_dir / "rdp-drives" / str(host_id) / str(session_id)
# Der von "connect"/"select" bis zur eigentlichen Bilduebertragung genutzte
# StreamReader-Puffer muss groesser als der asyncio-Default (64 KiB) sein --
# einzelne Guacamole-Instruktionen (z.B. ein base64-kodierter Bild-Blob) sind
# regelmaessig groesser. Ohne diesen erhoehten Wert wirft readuntil()/
# readexactly() ein LimitOverrunError, sobald mehr als 64 KiB unverarbeitet
# im Puffer stehen (Umsetzungsauftrag_Sonnet5.md Befund A1).
GUACD_STREAM_LIMIT = 4 * 1024 * 1024
# Guacamole-Protokoll-Versions-Token, das guacd >= 1.1.0 als ERSTEN Eintrag
# der "args"-Instruktion mitschickt (Befund C1). Es sieht wie ein normaler
# Parametername aus ("VERSION_1_5_0"), ist aber keiner -- es signalisiert die
# hoechste vom Server unterstuetzte Protokollversion und muss vom Client als
# ERSTER Wert der "connect"-Instruktion bestaetigt werden, sonst faellt guacd
# auf Protokoll 1.0.0 zurueck (verliert dabei timezone/required/msg).
_VERSION_TOKEN_RE = re.compile(r"VERSION_\d+_\d+_\d+")
# Guacamole-Protokoll-Statuscodes (dezimal) -> deutsche Klartexte (Teil B.2).
# Quelle: Guacamole.Status.Code in guacamole-common-js (Status.js), Werte
# dort hexadezimal definiert. Nicht gelistete Codes fallen auf einen
# generischen Text zurueck, der den Rohcode weiterhin sichtbar macht -- ein
# unbekannter Code ist damit trotzdem diagnostizierbar, nicht stumm.
GUAC_STATUS_MESSAGES: dict[int, str] = {
0: "Erfolgreich",
256: "Nicht unterstuetzte Operation",
512: "Serverfehler (guacd/FreeRDP intern)",
513: "guacd ist ausgelastet, bitte spaeter erneut versuchen",
514: "Das Zielsystem antwortet nicht (Zeitueberschreitung)",
515: "Das Zielsystem meldet einen Fehler",
516: "Angeforderte Ressource nicht gefunden",
517: "Ressourcenkonflikt (Ressource bereits in Verwendung)",
518: "Ressource ist bereits geschlossen",
519: "Ziel nicht erreichbar",
520: "Ziel derzeit nicht verfuegbar",
521: "Sitzung durch eine andere Sitzung auf dem Ziel beendet",
522: "Sitzung wegen Inaktivitaet auf dem Ziel beendet",
523: "Sitzung auf dem Ziel zwangsweise beendet",
768: "Ungueltige Verbindungsparameter",
769: "Anmeldung fehlgeschlagen (Benutzername/Passwort pruefen)",
771: "Zugriff verweigert (auch nach erfolgreicher Anmeldung nicht erlaubt)",
776: "Zeitueberschreitung beim Verbindungsaufbau (Zertifikat/TLS-Handshake pruefen)",
781: "Zu viele Daten gesendet",
783: "Nicht unterstuetzter Datentyp gesendet",
797: "Zu viele gleichzeitige Ressourcen angefordert",
}
def guac_status_text(code: int) -> str:
"""Deutscher Klartext zu einem Guacamole-Statuscode, mit Rohcode als Fallback."""
text = GUAC_STATUS_MESSAGES.get(code)
if text:
return f"{text} (Code {code})"
return f"guacd meldet unbekannten Status {code}"
class GuacamoleProtocolError(Exception):
pass
"""Fehler auf Ebene des Guacamole-Instruktionsprotokolls (kaputte
Laengenangabe, unerwartetes Trennzeichen, unerwartete Instruktion an
einer Stelle, an der eine andere erwartet wurde)."""
class GuacdUnreachableError(Exception):
"""guacd ist unter guacd_host:guacd_port nicht erreichbar oder antwortet
nicht innerhalb der konfigurierten Zeit (Befund B.2, Teil B)."""
def __init__(self, guacd_host: str, guacd_port: int, cause: BaseException) -> None:
self.guacd_host = guacd_host
self.guacd_port = guacd_port
self.cause_type = cause.__class__.__name__
if isinstance(cause, (asyncio.TimeoutError, TimeoutError)):
detail = "keine Antwort innerhalb der konfigurierten Zeit (Timeout)"
elif isinstance(cause, ConnectionRefusedError):
detail = "Verbindung abgelehnt -- vermutlich laeuft/lauscht guacd nicht"
else:
detail = str(cause) or cause.__class__.__name__
super().__init__(f"guacd unter {guacd_host}:{guacd_port} nicht erreichbar: {detail}")
class GuacdDisconnectedError(GuacamoleProtocolError):
"""guacd hat die Verbindung mitten im Protokoll unerwartet beendet (EOF).
Haeufigste Ursache: ein Absturz des FreeRDP-Plugins in guacd."""
class GuacdStatusError(GuacamoleProtocolError):
"""guacd hat eine 'error'-Instruktion gesendet (Protokoll-Referenz:
Argumente sind [message, status] in dieser Reihenfolge)."""
def __init__(self, status_code: int, guac_message: str) -> None:
self.status_code = status_code
self.guac_message = guac_message
super().__init__(guac_status_text(status_code))
async def _read_length_prefix(reader: asyncio.StreamReader) -> int:
"""Liest die Zeichen-Laengenangabe vor dem '.' einer Instruktion.
Nutzt StreamReader.readuntil() (gepuffert, C-implementiert) statt der
fruehren Byte-fuer-Byte-Schleife -- fuer eine Laengenangabe von hoechstens
einigen Ziffern ist der Unterschied klein, aber jede Vereinfachung hier
zahlt auf denselben Umbau wie _read_chars() ein (Befund A1).
"""
try:
raw = await reader.readuntil(b".")
except asyncio.IncompleteReadError as exc:
raise GuacdDisconnectedError(
"guacd hat die Verbindung unerwartet beendet (EOF beim Lesen einer Laengenangabe)"
) from exc
except asyncio.LimitOverrunError as exc:
raise GuacamoleProtocolError(
"Laengenangabe ueberschreitet den Lesepuffer -- vermutlich kaputtes Protokoll"
) from exc
length_bytes = raw[:-1] # Trennzeichen "." abschneiden
try:
return int(length_bytes)
except ValueError as exc:
raise GuacamoleProtocolError(f"Ungueltige Laengenangabe: {length_bytes!r}") from exc
async def _read_chars(reader: asyncio.StreamReader, count: int) -> str:
"""Liest genau `count` ZEICHEN (nicht Bytes) UTF-8-kodiert vom Stream.
Befund A1 (Umsetzungsauftrag_Sonnet5.md Teil A.3): die vorherige Fassung
rief readexactly(1) einmal PRO ZEICHEN auf -- bei einem base64-kodierten
Bild-Blob (der weit ueberwiegende Anteil des RDP-Bildstroms) waren das
ohne weiteres hunderttausende einzelne awaits fuer eine einzige
Instruktion, mit entsprechendem Event-Loop-Overhead (Phase 16: Faktor
644x gegenueber gepuffertem Lesen).
Ausnutzung der Guacamole-Invariante "Laenge zaehlt Zeichen, nicht Bytes":
jedes UTF-8-Zeichen belegt MINDESTENS ein Byte, `count` Bytes liefern
also NIE mehr als `count` Zeichen. `count` Bytes auf einmal zu lesen ist
damit immer eine sichere (nie zu grosse) Untergrenze fuer die noch
fehlende Byteanzahl:
- Reiner ASCII-Inhalt (praktisch der gesamte Bildstrom, da Base64 nur
ASCII-Zeichen kennt): EIN readexactly() statt `count` einzelner --
der Normalfall, fuer den dieser Umbau gemacht ist.
- Mehrbyte-Zeichen (z.B. ein Umlaut in einem Passwort/Hostnamen/einer
Zwischenablage-Uebertragung): der inkrementelle Decoder puffert dann
intern unvollstaendige Byte-Reste, `chars` waechst langsamer als
`count` Bytes gelesen wurden, die Schleife liest gezielt genau den
fehlenden Rest nach -- korrekt und weiterhin ohne Byte-fuer-Byte-Awaits.
Ein readexactly() kann dabei nie ueber das Ende des aktuellen Elements
hinaus in den nachfolgenden Trenner/das naechste Element lesen: aus
`remaining` Bytes koennen hoechstens `remaining` Zeichen entstehen, die
Schleife fordert also nie mehr Bytes an, als fuer die noch fehlenden
Zeichen mindestens noetig sind.
"""
decoder = codecs.getincrementaldecoder("utf-8")()
chars: list[str] = []
remaining = count
while remaining > 0:
try:
chunk = await reader.readexactly(remaining)
except asyncio.IncompleteReadError as exc:
raise GuacdDisconnectedError(
"guacd hat die Verbindung unerwartet beendet (EOF mitten in einer Instruktion)"
) from exc
decoded = decoder.decode(chunk)
if decoded:
chars.append(decoded)
remaining = count - sum(len(c) for c in chars)
return "".join(chars)
async def read_instruction(reader: asyncio.StreamReader) -> list[str]:
elements: list[str] = []
while True:
length = await _read_length_prefix(reader)
content = await _read_chars(reader, length)
elements.append(content)
try:
sep = await reader.readexactly(1)
except asyncio.IncompleteReadError as exc:
raise GuacdDisconnectedError(
"guacd hat die Verbindung unerwartet beendet (EOF nach einem Element, vor dem Trennzeichen)"
) from exc
if sep == b";":
return elements
if sep != b",":
raise GuacamoleProtocolError(f"Unerwartetes Trennzeichen: {sep!r}")
def encode_instruction(*elements: str) -> str:
@ -41,72 +235,71 @@ def encode_instruction(*elements: str) -> str:
return ",".join(f"{len(element)}.{element}" for element in elements) + ";"
async def _read_until(reader: asyncio.StreamReader, delimiter: bytes) -> bytes:
buf = bytearray()
while True:
b = await reader.readexactly(1)
if b == delimiter:
return bytes(buf)
buf += b
async def _read_chars(reader: asyncio.StreamReader, count: int) -> str:
"""Liest genau `count` ZEICHEN (nicht Bytes) UTF-8-kodiert vom Stream.
Gegenstueck zur Laengenzaehlung in encode_instruction(): ein
`readexactly(count)` haette bei Mehrbyte-Zeichen zu wenig Bytes gelesen
und den Parser mitten in einem Zeichen stehen lassen."""
decoder = codecs.getincrementaldecoder("utf-8")()
chars: list[str] = []
while len(chars) < count:
chunk = decoder.decode(await reader.readexactly(1))
if chunk:
chars.extend(chunk)
return "".join(chars)
async def read_instruction(reader: asyncio.StreamReader) -> list[str]:
elements: list[str] = []
while True:
length_bytes = await _read_until(reader, b".")
try:
length = int(length_bytes)
except ValueError as exc:
raise GuacamoleProtocolError(f"Ungueltige Laengenangabe: {length_bytes!r}") from exc
content = await _read_chars(reader, length)
elements.append(content)
sep = await reader.readexactly(1)
if sep == b";":
return elements
if sep != b",":
raise GuacamoleProtocolError(f"Unerwartetes Trennzeichen: {sep!r}")
def parse_instruction_text(text: str) -> list[str]:
"""Parst genau EINE Instruktion aus einem bereits vollstaendig vorliegenden
String (z.B. eine einzelne WebSocket-Textnachricht vom Browser)."""
"""Parst genau EINE Instruktion vom Anfang eines Strings (Kompatibilitaets-
Wrapper um parse_instructions_text() fuer Aufrufer, die garantiert nur
eine einzelne Instruktion erwarten)."""
return parse_instructions_text(text)[0]
def parse_instructions_text(text: str) -> list[list[str]]:
"""Parst ALLE Instruktionen aus einem bereits vollstaendig vorliegenden
String (z.B. eine einzelne WebSocket-Textnachricht vom Browser).
Befund D5 (Umsetzungsauftrag_Sonnet5.md Teil A.3): guacamole-common-js
kann mehrere Instruktionen in EINER WebSocket-Textnachricht buendeln
(z.B. beim Leeren einer Sendewarteschlange). Die vorherige Fassung
parste nur die ERSTE Instruktion, leitete aber die KOMPLETTE Nachricht
unveraendert weiter -- der Zwischenablage-Filter in _ws_to_guacd() prueft
daher nur instr[0]; eine 'clipboard'-Instruktion an zweiter oder
spaeterer Stelle in derselben Nachricht wurde ungefiltert durchgereicht
(Policy-Umgehung). Aufrufer muessen jede zurueckgegebene Instruktion
einzeln pruefen/filtern und die ueberlebenden Instruktionen mit
encode_instruction() neu zusammensetzen, statt den Originaltext erneut
zu versenden.
"""
instructions: list[list[str]] = []
elements: list[str] = []
i = 0
n = len(text)
if n == 0:
raise GuacamoleProtocolError("Leere Instruktionsnachricht")
while i < n:
dot = text.index(".", i)
length = int(text[i:dot])
try:
dot = text.index(".", i)
except ValueError as exc:
raise GuacamoleProtocolError(f"Fehlender '.' in Laengenangabe ab Position {i} in {text!r}") from exc
try:
length = int(text[i:dot])
except ValueError as exc:
raise GuacamoleProtocolError(f"Ungueltige Laengenangabe in {text!r} an Position {i}") from exc
start = dot + 1
end = start + length
if end > n:
raise GuacamoleProtocolError(f"Laengenangabe {length} reicht ueber das Nachrichtenende hinaus in {text!r}")
elements.append(text[start:end])
sep = text[end] if end < n else ""
i = end + 1
if sep == ";":
break
if sep != ",":
instructions.append(elements)
elements = []
elif sep != ",":
raise GuacamoleProtocolError(f"Unerwartetes Trennzeichen in {text!r} an Position {end}")
return elements
if elements:
# Nachricht endete ohne abschliessendes ';' -- unvollstaendige
# Instruktion, kein gueltiger Zustand fuer eine einzelne, komplett
# vorliegende WS-Textnachricht.
raise GuacamoleProtocolError(f"Unvollstaendige Instruktion (fehlendes ';') in {text!r}")
return instructions
class GuacdTunnel:
def __init__(self, reader: asyncio.StreamReader, writer: asyncio.StreamWriter, connection_id: str) -> None:
def __init__(self, reader: asyncio.StreamReader, writer: asyncio.StreamWriter, connection_id: str = "") -> None:
self.reader = reader
self.writer = writer
# Wird von der Lese-Pumpe (app/rdp_proxy/ws_tunnel.py::_guacd_to_ws)
# beim Empfang der 'ready'-Instruktion nachtraeglich gesetzt -- siehe
# open_tunnel(), das NICHT mehr selbst auf 'ready' wartet (Befund A2).
self.connection_id = connection_id
async def close(self) -> None:
@ -130,44 +323,102 @@ async def open_tunnel(
screen_width: int = 1024,
screen_height: int = 768,
dpi: int = 96,
connect_timeout: float | None = 10.0,
handshake_timeout: float | None = 10.0,
) -> GuacdTunnel:
reader, writer = await asyncio.open_connection(guacd_host, guacd_port)
"""Baut den guacd-Tunnel auf und sendet 'select'/Groessen-/'connect'-
Instruktionen. Wartet bewusst NICHT auf 'ready' (Befund A2, Teil A.3):
writer.write(encode_instruction("select", protocol).encode("utf-8"))
await writer.drain()
guacd startet nach 'connect' im Hintergrund den eigentlichen RDP-/NLA-
Handshake zum Windows-Ziel; der kann je nach Ziel/Netz mehrere Sekunden
dauern. Wuerde hier synchron auf 'ready' gewartet, empfaengt der Browser
in dieser ganzen Zeit KEINE Daten -- guacamole-common-js bricht nach
receiveTimeout (Standard 15s, vendor/guacamole-common.js) mit
"Server timeout" ab, noch bevor der Windows-Anmeldevorgang ueberhaupt
abgeschlossen ist. Aufrufer starten die Lese-Pumpe direkt im Anschluss
an diese Funktion (siehe app/rdp_proxy/ws_tunnel.py::rdp_tunnel); 'ready'
(und ein etwaiges 'error') werden dort wie jede andere Instruktion
empfangen und behandelt (_guacd_to_ws) -- ab dem Senden von 'connect'
ist also durchgehend jemand am Lesen, unabhaengig davon, wie lange guacd
fuer den eigentlichen RDP-Handshake braucht.
args_instr = await read_instruction(reader)
if args_instr[0] != "args":
raise GuacamoleProtocolError(f"Erwartete 'args', erhalten: {args_instr[0]}")
arg_names = args_instr[1:]
guacd_host/guacd_port stecken bei jedem Fehler in der Exception-Nachricht
(Befund B.2): die fruehere Fassung liess einen nackten OSError
durchreichen, ohne wohin die Anwendung ueberhaupt verbinden wollte.
"""
try:
reader, writer = await asyncio.wait_for(
asyncio.open_connection(guacd_host, guacd_port, limit=GUACD_STREAM_LIMIT),
timeout=connect_timeout,
)
except (OSError, asyncio.TimeoutError) as exc:
raise GuacdUnreachableError(guacd_host, guacd_port, exc) from exc
handshake = (
encode_instruction("size", str(screen_width), str(screen_height), str(dpi))
+ encode_instruction("audio")
+ encode_instruction("video")
+ encode_instruction("image", "image/png", "image/jpeg")
)
writer.write(handshake.encode("utf-8"))
await writer.drain()
try:
writer.write(encode_instruction("select", protocol).encode("utf-8"))
await writer.drain()
values = [params.get(name, "") for name in arg_names]
writer.write(encode_instruction("connect", *values).encode("utf-8"))
await writer.drain()
try:
args_instr = await asyncio.wait_for(read_instruction(reader), timeout=handshake_timeout)
except asyncio.TimeoutError as exc:
raise GuacdUnreachableError(guacd_host, guacd_port, exc) from exc
if not args_instr or args_instr[0] != "args":
raise GuacamoleProtocolError(
f"Erwartete 'args', erhalten: {args_instr[0] if args_instr else '(leer)'}"
)
ready_instr = await read_instruction(reader)
if ready_instr[0] != "ready":
raise GuacamoleProtocolError(f"Verbindungsaufbau fehlgeschlagen: {ready_instr}")
connection_id = ready_instr[1] if len(ready_instr) > 1 else ""
# C1: Versions-Token erkennen und aus den echten Parameternamen
# herausloesen (siehe _VERSION_TOKEN_RE oben).
raw_arg_names = args_instr[1:]
version_token: str | None = None
if raw_arg_names and _VERSION_TOKEN_RE.fullmatch(raw_arg_names[0]):
version_token = raw_arg_names[0]
arg_names = raw_arg_names[1:]
else:
arg_names = raw_arg_names
return GuacdTunnel(reader, writer, connection_id)
handshake = (
encode_instruction("size", str(screen_width), str(screen_height), str(dpi))
+ encode_instruction("audio")
+ encode_instruction("video")
+ encode_instruction("image", "image/png", "image/jpeg")
)
writer.write(handshake.encode("utf-8"))
await writer.drain()
# C3: width/height/dpi zusaetzlich als benannte connect-Parameter
# mitgeben, sofern guacd sie in seiner args-Liste anbietet -- FreeRDP
# nutzt sie fuer die initiale Aufloesungs-/DPI-Aushandlung mit dem
# Windows-Ziel, unabhaengig von der separaten 'size'-Instruktion oben.
effective_params = dict(params)
effective_params.setdefault("width", str(screen_width))
effective_params.setdefault("height", str(screen_height))
effective_params.setdefault("dpi", str(dpi))
values = [effective_params.get(name, "") for name in arg_names]
if version_token is not None:
# Der Client bestaetigt exakt das von guacd angebotene
# Versions-Token, statt (wie zuvor) implizit einen Leerstring zu
# senden und damit auf Protokoll 1.0.0 herabgestuft zu werden.
values = [version_token, *values]
writer.write(encode_instruction("connect", *values).encode("utf-8"))
await writer.drain()
except BaseException:
writer.close()
raise
return GuacdTunnel(reader, writer)
def build_rdp_params(
host: dict,
password: str,
*,
session_id: int,
username: str | None = None,
domain: str | None = None,
client_name: str | None = None,
timezone: str | None = None,
) -> dict[str, str]:
"""Baut die Parameter-Map fuer die connect-Instruktion aus dem Host-Datensatz.
@ -181,6 +432,16 @@ def build_rdp_params(
Benutzername gehoert zur Anmeldung, nicht zum Zielsystem. Fehlen sie, wird
auf hosts.rdp_username/rdp_domain zurueckgegriffen (Altbestand).
`client_name` (Befund C3) identifiziert die Sitzung auf dem Windows-Ziel
selbst (z.B. in Taskmanager/`qwinsta`) -- ohne ihn zeigt Windows nur einen
generischen FreeRDP-Bezeichner, was die Zuordnung "welche Sitzung gehoert
zu welchem Jumphost-Benutzer" beim Ziel-Admin erschwert.
`timezone` (Befund C3) wird, sofern vom Client uebermittelt (siehe
app/rdp_proxy/ws_tunnel.py, Query-Parameter), an FreeRDP durchgereicht --
ohne diesen Parameter uebernimmt Windows die Zeitzone des JUMPHOST-
Servers statt der des Benutzers.
WICHTIG: `host` muss aus app.ssh_proxy.proxy.load_host() stammen. Diese
Funktion liest rdp_require_nla/clipboard_enabled/rdp_ignore_cert -- genau
diese Spalten hat load_host() frueher NICHT selektiert, wodurch hier still
@ -200,7 +461,20 @@ def build_rdp_params(
# Benutzername/Domaene kommen seit Migration 0010 aus den Zugangsdaten
# (rdp_credentials). Der Wert am Host wird nur noch als Fallback fuer
# Datensaetze gelesen, die vor der Migration angelegt wurden.
effective_username = (username or "").strip() or (host.get("rdp_username") or "").strip()
#
# Befund C2: Migration 0012 hat fehlende Benutzernamen frueher mit dem
# Platzhaltertext '(kein Benutzername)' statt eines leeren Strings
# befuellt (inzwischen per Migration 0013 rueckwirkend korrigiert). Der
# Platzhalter wird hier zusaetzlich defensiv wie ein leerer String
# behandelt, falls er (z.B. durch manuelle Eingabe im Adminbereich)
# erneut auftaucht -- sonst ginge er unbemerkt als echter RDP-Anmelde-
# name an Windows und erzeugt dort ein irrefuehrendes "Anmeldung
# fehlgeschlagen" statt der eigentlichen Ursache.
_PLACEHOLDER_USERNAME = "(kein Benutzername)"
raw_username = (username or "").strip()
if raw_username == _PLACEHOLDER_USERNAME:
raw_username = ""
effective_username = raw_username or (host.get("rdp_username") or "").strip()
if not effective_username:
raise GuacamoleProtocolError(
"Fuer diesen Host ist kein RDP-Benutzername hinterlegt. Der Benutzername "
@ -210,7 +484,7 @@ def build_rdp_params(
effective_domain = (domain or "").strip() or (host.get("rdp_domain") or "").strip()
clipboard_enabled = bool(host.get("clipboard_enabled"))
return {
params = {
"hostname": host["address"],
"port": str(host["port"]),
"username": effective_username,
@ -220,9 +494,13 @@ def build_rdp_params(
"ignore-cert": "true" if host.get("rdp_ignore_cert", True) else "false",
"disable-audio": "true",
"enable-drive": "true" if host.get("file_transfer_enabled") else "false",
"drive-path": f"/var/lib/jumphost/rdp-drives/{host['id']}",
"drive-path": str(rdp_drive_path(host["id"], session_id)),
"create-drive-path": "true",
"disable-copy": "false" if clipboard_enabled else "true",
"disable-paste": "false" if clipboard_enabled else "true",
"resize-method": "display-update",
"client-name": (client_name or "jumphost").strip()[:64],
}
if timezone:
params["timezone"] = timezone
return params

View File

@ -11,6 +11,8 @@ from __future__ import annotations
import asyncio
import logging
import re
import shutil
import uuid as uuid_mod
from fastapi import APIRouter, Query, WebSocket, WebSocketDisconnect
@ -18,17 +20,22 @@ from fastapi import APIRouter, Query, 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.rbac import AmbiguousCredentialError, resolve_credential_for_user_on_host, user_has_role_for_host
from app.recordings.recorder import SessionRecorder
from app.security import active_sessions
from app.security.audit import write_audit_event
from app.security.crypto import decrypt_secret
from app.rdp_proxy.guacd_client import (
GuacamoleProtocolError,
GuacdDisconnectedError,
GuacdStatusError,
GuacdUnreachableError,
build_rdp_params,
encode_instruction,
guac_status_text,
open_tunnel,
parse_instruction_text,
parse_instructions_text,
rdp_drive_path,
read_instruction,
)
from app.ssh_proxy.proxy import HostNotConfiguredError, load_host
@ -48,6 +55,28 @@ INTERNAL_DATA_OPCODE = ""
# zustande und es erreichte kein einziger Frame die Anwendung.
GUACAMOLE_SUBPROTOCOL = "guacamole"
# Grobe Plausibilitaetspruefung fuer eine vom Client mitgeschickte IANA-
# Zeitzone (z.B. "Europe/Vienna") -- kein Anspruch auf Vollstaendigkeit gegen
# die tz-Datenbank, nur ein Schutz gegen offensichtlich falsche Werte, bevor
# sie als RDP-connect-Parameter an guacd/FreeRDP weitergereicht werden.
_TIMEZONE_RE = re.compile(r"^[A-Za-z0-9_+\-]+(/[A-Za-z0-9_+\-]+)*$")
async def _cleanup_rdp_drive_path(host_id: int, session_id: int) -> None:
"""Entfernt das pro Sitzung umgeleitete RDP-Laufwerk beim Sitzungsende
(Befund E6, Umsetzungsauftrag Teil E) -- sonst sammeln sich pro Sitzung
Verzeichnisse unbegrenzt an. shutil.rmtree ist blockierendes Datei-I/O
(siehe E.0) und laeuft deshalb in einem Thread statt direkt im
Event-Loop."""
path = rdp_drive_path(host_id, session_id)
try:
await asyncio.to_thread(shutil.rmtree, path, True)
except OSError:
logger.warning(
"Konnte RDP-Laufwerksverzeichnis fuer Sitzung %s (Host %s) nicht entfernen: %s",
session_id, host_id, path, exc_info=True,
)
async def _accept(websocket: WebSocket) -> None:
"""Nimmt die Verbindung an und bestaetigt dabei das Subprotokoll, sofern
@ -59,62 +88,141 @@ async def _accept(websocket: WebSocket) -> None:
await websocket.accept()
async def _reject(websocket: WebSocket, code: int, reason: str, *, accepted: bool) -> None:
async def _send_error_instruction(websocket: WebSocket, message: str, status_code: int) -> None:
"""Sendet eine vollstaendige Guacamole-'error'-Instruktion ueber den noch
offenen Tunnel, BEVOR die Verbindung geschlossen wird (Befund B.2,
vormals ws_tunnel.py:296-308): die WebSocket-Close-Reason ist nach RFC
6455 auf 123 Byte begrenzt und war bisher der EINZIGE Uebertragungsweg
fuer den Fehlertext. Eine 'error'-Instruktion im normalen Tunnel-
Datenstrom hat kein derartiges Laengenlimit und wird von
guacamole-common-js (vendor/guacamole-common.js, Guacamole.Tunnel-
Instruktions-Handler) automatisch verarbeitet -- die Close-Reason bleibt
als Fallback fuer den Fall, dass dieser Sendeversuch selbst scheitert
(z.B. weil der Client bereits getrennt hat).
"""
try:
await websocket.send_text(encode_instruction("error", message, str(status_code)))
except Exception:
logger.debug("Konnte 'error'-Instruktion nicht mehr senden (Tunnel bereits zu)", exc_info=True)
async def _reject(
websocket: WebSocket,
code: int,
reason: str,
*,
accepted: bool,
correlation_id: str,
host_id: int | None = None,
username: str | None = None,
) -> None:
"""Beendet eine Sitzung vor ihrem eigentlichen Beginn -- mit Logeintrag
und mit einem fuer den Benutzer lesbaren Grund.
Bisher endeten alle diese Pfade in einem nackten `websocket.close(code=...)`
ohne jede Protokollierung. Im Verbindungslog war deshalb ueberhaupt nichts
zu sehen, wenn eine RDP-Sitzung an einer dieser Vorbedingungen scheiterte.
Befund B.2 ("gesamter RDP-Pfad"): zusaetzlich fehlten strukturierte
Felder (host_id, Benutzer) UND eine Korrelations-ID -- man sah zwar DASS
etwas abgelehnt wurde, aber die Ablehnung liess sich nicht mit einer
spaeteren Nutzerrueckfrage ("bei mir ging um 14:03 nichts") verknuepfen.
Der Grundtext wird als WebSocket-Close-Reason mitgegeben:
guacamole-common-js reicht ihn (siehe close_tunnel()) als
Guacamole.Status.message an client.onerror weiter, wo static/js/rdp.js ihn
direkt anzeigt. Voraussetzung dafuer ist ein zustande gekommener
Handshake -- vor `accept()` sieht der Browser nur einen HTTP-Fehler.
Der Grundtext wird als WebSocket-Close-Reason UND als vollstaendige
Guacamole-'error'-Instruktion mitgegeben (siehe _send_error_instruction);
letztere unterliegt nicht der 123-Byte-Grenze der Close-Reason.
"""
logger.warning("RDP-Verbindung abgelehnt (code=%s): %s", code, reason)
# Close-Reason ist auf 123 Byte begrenzt (RFC 6455).
reason_bytes = reason.encode("utf-8")[:123]
logger.warning(
"RDP-Verbindung abgelehnt [corr=%s] (code=%s, host_id=%s, user=%s): %s",
correlation_id, code, host_id, username, reason,
)
full_reason = f"{reason} (Ref: {correlation_id})"
if not accepted:
await _accept(websocket)
await _send_error_instruction(websocket, full_reason, code)
reason_bytes = full_reason.encode("utf-8")[:123]
await websocket.close(code=code, reason=reason_bytes.decode("utf-8", errors="ignore"))
async def _guacd_to_ws(tunnel, websocket: WebSocket, recorder: SessionRecorder) -> None:
"""Liest Instruktionen von guacd und reicht sie an den Browser weiter.
Befund A2: laeuft ab direkt nach open_tunnel() (die NICHT mehr selbst auf
'ready' wartet), somit ist ab dem Senden von 'connect' durchgehend jemand
am Lesen -- unabhaengig davon, wie lange guacd fuer den eigentlichen
RDP-/NLA-Handshake braucht.
'ready' wird hier (statt in open_tunnel()) erkannt, um die connection_id
festzuhalten, und dann wie jede andere Instruktion normal weitergereicht
-- guacamole-common-js erwartet 'ready' ohnehin als regulaeren Teil des
Instruktionsstroms. Eine 'error'-Instruktion (Befund B.2/C1) wird NICHT
weitergereicht, sondern als GuacdStatusError geworfen: der aufrufende
Code in rdp_tunnel() bildet daraus die deutsche Klartextmeldung fuer Log
und Browser (guac_status_text/_send_error_instruction), statt dass der
Browser den rohen Guacamole-Statuscode selbst interpretieren muss.
"""
while True:
instr = await read_instruction(tunnel.reader)
if instr and instr[0] == "ready":
tunnel.connection_id = instr[1] if len(instr) > 1 else ""
elif instr and instr[0] == "error":
message = instr[1] if len(instr) > 1 else ""
try:
code = int(instr[2]) if len(instr) > 2 else 512
except ValueError:
code = 512
raise GuacdStatusError(code, message)
text = encode_instruction(*instr)
recorder.record("output", text)
await websocket.send_text(text)
async def _ws_to_guacd(tunnel, websocket: WebSocket, recorder: SessionRecorder, *, clipboard_enabled: bool) -> None:
async def _ws_to_guacd(tunnel, websocket: WebSocket, recorder: SessionRecorder, *, clipboard_enabled: bool, correlation_id: str) -> None:
while True:
message = await websocket.receive_text()
try:
instr = parse_instruction_text(message)
except (GuacamoleProtocolError, ValueError, IndexError):
continue # ungueltige Clientnachricht ignorieren statt die Verbindung zu killen
if instr and instr[0] == INTERNAL_DATA_OPCODE:
# Tunnelinterne Instruktion von guacamole-common-js (leerer
# Opcode), z.B. "0.,4.ping,13.<timestamp>;". Diese gehoert dem
# Tunnel, NICHT guacd -- bisher wurde sie unbesehen an guacd
# weitergereicht, das damit nichts anfangen kann. Ein ping wird
# gespiegelt: der Browser bricht den Tunnel nach
# tunnel.receiveTimeout (Standard 15 s) ohne empfangene Daten mit
# "Server timeout" ab, eine stille Sitzung liefe also in einen
# Fehler.
if len(instr) > 1 and instr[1] == "ping":
await websocket.send_text(encode_instruction(*instr))
instructions = parse_instructions_text(message)
except GuacamoleProtocolError:
# Ungueltige Clientnachricht ignorieren statt die Verbindung zu
# killen -- aber (Befund B.2, ws_tunnel.py:96-98) nicht mehr
# lautlos: ein gekuerzter Nachrichtenausschnitt landet als DEBUG
# im Log, damit ein wiederkehrendes Muster (z.B. ein kaputter
# Client) ueberhaupt auffallen kann.
logger.debug(
"[corr=%s] Ungueltige WS-Nachricht ignoriert: %r",
correlation_id, message[:200],
)
continue
if not clipboard_enabled and instr and instr[0] == "clipboard":
continue # Defense-in-Depth: Clipboard serverseitig blocken
# Befund D5: eine einzelne WebSocket-Textnachricht kann MEHRERE
# Guacamole-Instruktionen buendeln. Jede wird einzeln gegen den
# Zwischenablage-Filter geprueft (statt nur instr[0] der Nachricht)
# und nur die ueberlebenden Instruktionen werden neu kodiert
# weitergereicht -- eine gefilterte 'clipboard'-Instruktion darf sich
# nicht mehr an zweiter/spaeterer Stelle in derselben Nachricht
# vorbeischmuggeln koennen.
forward: list[str] = []
for instr in instructions:
if instr and instr[0] == INTERNAL_DATA_OPCODE:
# Tunnelinterne Instruktion von guacamole-common-js (leerer
# Opcode), z.B. "0.,4.ping,13.<timestamp>;". Gehoert dem
# Tunnel, NICHT guacd. Ein ping wird gespiegelt: der Browser
# bricht den Tunnel nach tunnel.receiveTimeout (Standard 15s)
# ohne empfangene Daten mit "Server timeout" ab.
if len(instr) > 1 and instr[1] == "ping":
await websocket.send_text(encode_instruction(*instr))
continue
recorder.record("input", message)
tunnel.writer.write(message.encode("utf-8"))
if not clipboard_enabled and instr and instr[0] == "clipboard":
continue # Defense-in-Depth: Clipboard serverseitig blocken
forward.append(encode_instruction(*instr))
if not forward:
continue
combined = "".join(forward)
recorder.record("input", combined)
tunnel.writer.write(combined.encode("utf-8"))
await tunnel.writer.drain()
@ -125,14 +233,28 @@ async def rdp_tunnel(
width: int = Query(default=1280, ge=320, le=7680),
height: int = Query(default=800, ge=240, le=4320),
dpi: int = Query(default=96, ge=48, le=384),
timezone: str | None = Query(default=None, max_length=64),
):
# Befund B.2 ("gesamter RDP-Pfad"): Korrelations-ID VOR jeder moeglichen
# Ablehnung erzeugen (nicht erst nach dem RBAC-Check) -- damit hat
# WIRKLICH JEDE Logzeile zu dieser Verbindung, inklusive der fruehesten
# Ablehnungen, ein gemeinsames Merkmal. Wird nach erfolgreichem accept()
# zusaetzlich als tunnel_uuid an den Browser gesendet (siehe unten) --
# dieselbe ID dient also sowohl der Server-Log-Korrelation als auch der
# Client-seitigen Tunnel-Identifikation.
correlation_id = str(uuid_mod.uuid4())
user = await get_current_user_ws(websocket)
if user is None:
# Einziger Pfad, der bewusst OHNE vorheriges accept() schliesst: eine
# nicht authentifizierte Verbindung soll gar nicht erst zustande
# kommen. Alle folgenden Ablehnungen laufen ueber _reject(), damit der
# Benutzer im Browser den tatsaechlichen Grund zu sehen bekommt.
logger.warning("RDP-Verbindung abgelehnt: keine gueltige Sitzung (host_id=%s)", host_id)
# kommen (vor accept() sieht der Browser ohnehin nur einen
# HTTP-Fehler, eine Guacamole-'error'-Instruktion kann also nicht
# ankommen). Alle folgenden Ablehnungen laufen ueber _reject().
logger.warning(
"RDP-Verbindung abgelehnt [corr=%s]: keine gueltige Sitzung (host_id=%s)",
correlation_id, host_id,
)
await websocket.close(code=4401)
return
@ -145,7 +267,30 @@ async def rdp_tunnel(
await _reject(
websocket, 4403,
f"Keine Berechtigung 'rdp_connect' fuer Host {host_id}",
accepted=False,
accepted=False, correlation_id=correlation_id, host_id=host_id, username=user.username,
)
return
# E.4 (Umsetzungsauftrag Teil E): Obergrenzen je Benutzer und global,
# VOR dem DB-INSERT und jedem Ressourcenverbrauch geprueft -- vorher gab
# es keine der beiden Grenzen, mit der Mehrsitzungs-Seitenleiste (Teil F)
# wird es zur Normalitaet, dass ein Benutzer mehrere Sitzungen haelt.
# Klartext-Ablehnung statt eines wortlosen Verbindungsendes (Teil B gilt
# auch hier).
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, correlation_id=correlation_id, host_id=host_id, username=user.username,
)
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, correlation_id=correlation_id, host_id=host_id, username=user.username,
)
return
@ -156,21 +301,27 @@ async def rdp_tunnel(
# Empfang der ersten Instruktion auf OPEN und startet damit seine
# Timeoutueberwachung neu -- ohne diesen Frame blieb der Client bis zum
# ersten Bild von guacd in "Warte auf Server ..." haengen und lief bei
# einem langsamen RDP-Handshake in den 15-Sekunden-Timeout.
tunnel_uuid = str(uuid_mod.uuid4())
# einem langsamen RDP-Handshake in den 15-Sekunden-Timeout. Dieselbe ID
# wie correlation_id (siehe oben) -- damit ist ein vom Benutzer im UI
# angezeigter Fehler direkt mit den Server-Logzeilen dieser Verbindung
# verknuepfbar.
tunnel_uuid = correlation_id
await websocket.send_text(encode_instruction(INTERNAL_DATA_OPCODE, tunnel_uuid))
try:
host = await load_host(conn, host_id)
except HostNotConfiguredError as exc:
await _reject(websocket, 4404, str(exc), accepted=True)
await _reject(
websocket, 4404, str(exc), accepted=True,
correlation_id=correlation_id, host_id=host_id, username=user.username,
)
return
if host["protocol"] != "rdp":
await _reject(
websocket, 4400,
f"Host {host['hostname']} ist kein RDP-Ziel (protocol={host['protocol']})",
accepted=True,
accepted=True, correlation_id=correlation_id, host_id=host_id, username=user.username,
)
return
@ -179,11 +330,40 @@ async def rdp_tunnel(
# Beschreibung des Zielsystems. Seit Migration 0012 sind RDP-Zugangsdaten
# zudem ein eigenstaendiges, wiederverwendbares Objekt (analog SSH-Keys),
# das einem Host ueber host_rdp_credential_map zugewiesen wird, statt
# 1:1 am Host zu haengen.
# 1:1 am Host zu haengen. Seit Teil D Schritt 4 (Achse B) laeuft die
# Auswahl NICHT mehr blind ueber den Host allein:
# app.rbac.resolve_credential_for_user_on_host() beruecksichtigt
# zusaetzlich, ob DIESER Benutzer ueber eine seiner Gruppen
# (group_rdp_credential_grants) Zugriff auf den zugeordneten
# Zugangsdatensatz hat. AmbiguousCredentialError kann hier strukturell
# nicht auftreten (host_rdp_credential_map hat PK auf host_id, also
# hoechstens ein Treffer) -- trotzdem defensiv abgefangen, statt
# unbehandelt durchzureichen.
try:
rdp_credential_id = await resolve_credential_for_user_on_host(
conn, user_id=user.id, host_id=host_id, kind="rdp_credential"
)
except AmbiguousCredentialError as exc:
logger.error("Unerwartete Mehrdeutigkeit bei RDP-Zugangsdaten: %s", exc)
await _reject(
websocket, 4404,
f"Fuer Host {host['hostname']} sind mehrdeutige RDP-Zugangsdaten hinterlegt "
"(Adminbereich -> Zugangsdaten).",
accepted=True, correlation_id=correlation_id, host_id=host_id, username=user.username,
)
return
if rdp_credential_id is None:
await _reject(
websocket, 4404,
f"Fuer Host {host['hostname']} ist kein RDP-Passwort hinterlegt oder keines Ihrer "
"Benutzergruppen freigegeben (Adminbereich -> Zugangsdaten bzw. "
"Benutzergruppen -> Zugangsdaten).",
accepted=True, correlation_id=correlation_id, host_id=host_id, username=user.username,
)
return
cred_cursor = await conn.execute(
"SELECT rc.password_enc, rc.username, rc.domain FROM host_rdp_credential_map m "
"JOIN rdp_credentials rc ON rc.id = m.rdp_credential_id WHERE m.host_id = ?",
(host_id,),
"SELECT password_enc, username, domain FROM rdp_credentials WHERE id = ?",
(rdp_credential_id,),
)
cred_row = await cred_cursor.fetchone()
if cred_row is None:
@ -191,7 +371,7 @@ async def rdp_tunnel(
websocket, 4404,
f"Fuer Host {host['hostname']} ist kein RDP-Passwort hinterlegt "
"(Adminbereich -> Zugangsdaten).",
accepted=True,
accepted=True, correlation_id=correlation_id, host_id=host_id, username=user.username,
)
return
@ -206,17 +386,22 @@ async def rdp_tunnel(
)
await write_audit_event(
conn, event_type="rdp_session_start", user_id=user.id, client_ip=client_ip,
details={"host_id": host_id, "hostname": host["hostname"], "session_id": session_id},
details={"host_id": host_id, "hostname": host["hostname"], "session_id": session_id, "corr": correlation_id},
)
await conn.commit()
logger.debug(
"RDP-Sitzung %s gestartet: user=%s host=%s (%s:%s) client_ip=%s",
session_id, user.username, host["hostname"], host["address"], host["port"], client_ip,
# Befund B.2 (ws_tunnel.py:212-215): Sitzungsstart/-ende gehoeren auf
# INFO, nicht DEBUG -- im Produktivbetrieb (basicConfig(level=INFO),
# app/main.py) waren sie damit ohne den globalen DEBUG-Override in
# app/security/log_stream.py schlicht NICHT sichtbar.
logger.info(
"RDP-Sitzung %s gestartet [corr=%s]: user=%s host=%s (%s:%s) client_ip=%s",
session_id, correlation_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"
error_text: str | None = None
error_code = 512
tunnel = None
tasks: list[asyncio.Task] = []
password = None
@ -229,19 +414,26 @@ async def rdp_tunnel(
# riss ein Fehler hier VOR jeglicher Protokollierung durch und die
# Sitzung verschwand spurlos (schwarzer Bildschirm, kein Log-Eintrag).
password = decrypt_secret(cred_row[0], associated_data=b"rdp_password")
safe_timezone = timezone if (timezone and _TIMEZONE_RE.fullmatch(timezone)) else None
params = build_rdp_params(
host, password.decode(), username=cred_row[1], domain=cred_row[2]
host, password.decode(), session_id=session_id, username=cred_row[1], domain=cred_row[2],
client_name=f"jumphost-{user.username}", timezone=safe_timezone,
)
tunnel = await open_tunnel(
guacd_host=settings.guacd_host, guacd_port=settings.guacd_port,
protocol="rdp", params=params, screen_width=width, screen_height=height, dpi=dpi,
connect_timeout=settings.guacd_connect_timeout_s,
handshake_timeout=settings.guacd_handshake_timeout_s,
)
logger.info(
"RDP-Sitzung %s [corr=%s]: guacd-Tunnel zu %s:%s aufgebaut, 'connect' fuer %s gesendet",
session_id, correlation_id, settings.guacd_host, settings.guacd_port, host["hostname"],
)
logger.debug("RDP-Sitzung %s: guacd-Tunnel zu %s aufgebaut", session_id, host["hostname"])
clipboard_enabled = bool(host.get("clipboard_enabled", True))
tasks = [
asyncio.create_task(_guacd_to_ws(tunnel, websocket, recorder)),
asyncio.create_task(
_ws_to_guacd(tunnel, websocket, recorder, clipboard_enabled=clipboard_enabled)
_ws_to_guacd(tunnel, websocket, recorder, clipboard_enabled=clipboard_enabled, correlation_id=correlation_id)
),
]
done, pending = await asyncio.wait(tasks, return_when=asyncio.FIRST_EXCEPTION)
@ -253,11 +445,37 @@ async def rdp_tunnel(
raise exc
except WebSocketDisconnect:
end_reason = "logout"
except GuacdUnreachableError as exc:
# Befund B.2: eigene Fehlerklasse statt eines rohen OSError --
# unterscheidbar von "guacd erreichbar, aber Ziel/Anmeldung fehlerhaft".
logger.warning("RDP-Sessionfehler (session_id=%s) [corr=%s]: %s", session_id, correlation_id, exc)
end_reason = "error"
error_text = str(exc)
error_code = 514 # UPSTREAM_TIMEOUT als naechstliegender Guacamole-Code fuer "guacd nicht erreichbar"
except GuacdStatusError as exc:
# guacd hat eine 'error'-Instruktion gesendet (haeufigster Fall:
# falsches Passwort/Zielsystem nicht erreichbar/Zertifikatsproblem).
logger.warning(
"RDP-Sessionfehler (session_id=%s) [corr=%s]: guacd meldet Status %s: %s",
session_id, correlation_id, exc.status_code, exc.guac_message,
)
end_reason = "error"
error_text = f"{guac_status_text(exc.status_code)}: {exc.guac_message}" if exc.guac_message else guac_status_text(exc.status_code)
error_code = exc.status_code
except GuacdDisconnectedError as exc:
# EOF mitten im Protokoll -- typischerweise ein Absturz von guacd/
# FreeRDP. Vorher landete das unbehandelt im generischen
# (GuacamoleProtocolError, ConnectionError, OSError)-Zweig als
# IncompleteReadError mit rohem Python-Klassennamen im Browser.
logger.warning("RDP-Sessionfehler (session_id=%s) [corr=%s]: %s", session_id, correlation_id, exc)
end_reason = "error"
error_text = "guacd hat die Verbindung unerwartet beendet"
error_code = 515
except (GuacamoleProtocolError, ConnectionError, OSError) as exc:
logger.warning("RDP-Sessionfehler (session_id=%s): %s", session_id, exc)
logger.warning("RDP-Sessionfehler (session_id=%s) [corr=%s]: %s", session_id, correlation_id, exc)
end_reason = "error"
# Grund an den Browser durchreichen (guacamole-common-js zeigt die
# Close-Reason als Guacamole.Status.message an, siehe _reject).
# 'error'-Instruktion/Close-Reason an, siehe _send_error_instruction).
# Betrifft u.a. den haeufigsten Konfigurationsfehler: kein
# RDP-Benutzername am Host, siehe build_rdp_params().
error_text = str(exc) or exc.__class__.__name__
@ -272,9 +490,10 @@ async def rdp_tunnel(
# verschluesselten RDP-Passwort) ab, die vorher unbehandelt bis vor
# das erste await in dieser Funktion durchriss und die Sitzung ohne
# jede Fehlermeldung/Protokollierung sofort beendete.
logger.exception("Unerwarteter Fehler in RDP-Sitzung %s: %s", session_id, exc)
logger.exception("Unerwarteter Fehler in RDP-Sitzung %s [corr=%s]: %s", session_id, correlation_id, exc)
end_reason = "error"
error_text = f"Interner Fehler: {exc.__class__.__name__}"
error_code = 512
finally:
active_sessions.unregister(session_id)
del password # Klartext-Passwort so schnell wie moeglich freigeben
@ -282,8 +501,13 @@ async def rdp_tunnel(
task.cancel()
if tunnel:
await tunnel.close()
recorder.close()
logger.debug("RDP-Sitzung %s beendet: reason=%s", session_id, end_reason)
await recorder.aclose()
# E6 (Umsetzungsauftrag Teil E): das je Sitzung umgeleitete
# RDP-Laufwerk (siehe build_rdp_params()/rdp_drive_path()) wird beim
# Sitzungsende entfernt -- sonst sammeln sich pro Sitzung
# Verzeichnisse unbegrenzt auf der Platte an.
await _cleanup_rdp_drive_path(host_id, session_id)
logger.info("RDP-Sitzung %s beendet [corr=%s]: reason=%s", session_id, correlation_id, end_reason)
await conn.execute(
"UPDATE sessions SET ended_at = strftime('%Y-%m-%dT%H:%M:%fZ','now'), end_reason = ? "
"WHERE id = ?",
@ -291,13 +515,15 @@ async def rdp_tunnel(
)
await write_audit_event(
conn, event_type="rdp_session_end", user_id=user.id, client_ip=client_ip,
details={"host_id": host_id, "session_id": session_id, "reason": end_reason},
details={"host_id": host_id, "session_id": session_id, "reason": end_reason, "corr": correlation_id},
)
await conn.commit()
try:
if error_text:
full_reason = f"{error_text} (Ref: {correlation_id})"
await _send_error_instruction(websocket, full_reason, error_code)
await websocket.close(
code=4500, reason=error_text.encode("utf-8")[:123].decode("utf-8", errors="ignore")
code=4500, reason=full_reason.encode("utf-8")[:123].decode("utf-8", errors="ignore")
)
else:
await websocket.close()

View File

@ -1,52 +1,278 @@
"""
Session-Aufzeichnung mit Hash-Verkettung (siehe Konzept 6.5).
Jede Session schreibt eine eigene JSONL-Datei unter settings.recordings_dir.
Jede Zeile verkettet sich mit der vorherigen (gleiches Prinzip wie das
Audit-Log, app/security/audit.py), damit nachtraegliche Manipulation der
Aufzeichnung erkennbar ist.
Jede Session schreibt eine oder mehrere JSONL-Dateien unter
settings.recordings_dir. Jede Zeile verkettet sich mit der vorherigen
(gleiches Prinzip wie das Audit-Log, app/security/audit.py), damit
nachtraegliche Manipulation der Aufzeichnung erkennbar ist.
Umsetzungsauftrag Teil A (D2) / Teil E (E2): `record()` wurde vorher
synchron im Event-Loop aufgerufen und hat bei JEDER Instruktion
`write()` + `flush()` gemacht -- beim RDP-Bildstrom also praktisch bei
jedem Frame. Das haelt den einzigen Event-Loop der Anwendung fuer ALLE
Benutzer an (siehe E.0) und kann bei laufenden Sitzungen unbegrenzt
Plattenplatz verbrauchen.
Design der Behebung -- zwei klar getrennte Zustaendigkeiten:
* Event-Loop-Thread (synchron, `record()`): berechnet die Hash-Kette
UND entscheidet ueber Rotation/Groessenbegrenzung -- beides reine
CPU-/Zaehlerarbeit ohne I/O, daher unbedenklich synchron. Jede
fertige Zeile landet mit ihrem Ziel-Teildateiindex in einem
In-Memory-Puffer.
* Executor-Thread (`asyncio.to_thread`, `_flush_loop`): fuehrt NUR
noch das eigentliche blockierende Datei-I/O aus (open/write/flush/
close) -- trifft keine Entscheidungen und teilt sich daher keinen
veraenderlichen Zustand mit dem Event-Loop-Thread ausser den fertig
vorbereiteten (Teilindex, Zeile)-Tupeln.
Damit bleibt die Hash-Kette deterministisch in Aufrufreihenfolge
korrekt, unabhaengig davon, wann tatsaechlich geschrieben wird. Jede
Teildatei (Rotation) hat ihre eigene, in sich geschlossene Kette ab
GENESIS_HASH -- das genuegt fuer Manipulationserkennung je Datei und
vermeidet krossen Zustand zwischen den Threads.
Bekannte, bewusst akzeptierte Einschraenkung: `close()`/`aclose()`
versucht einen Abschluss-Flush, aber bei hartem Prozessabsturz (kein
regulaeres Sitzungsende) koennen die letzten <= FLUSH_INTERVAL_S
Sekunden bzw. <= MAX_BUFFERED_ENTRIES gepufferte Eintraege verloren
gehen. Die Kette selbst bleibt in jedem Fall bis zum letzten
geschriebenen Eintrag gueltig (kein "gebrochener" Zustand wie beim
alten Audit-Log-Bug E1) -- es fehlt hoechstens ein Rest am Ende.
"""
from __future__ import annotations
import asyncio
import hashlib
import json
import logging
import time
from pathlib import Path
from app.config import settings
logger = logging.getLogger("jumphost.recordings")
GENESIS_HASH = "0" * 64
# Grobe Rahmenkosten (JSON-Huelle {"entry":...,"prev_hash":...,"hash":...}
# plus Zeilenumbruch) fuer die Vorab-Groessenschaetzung in record() --
# muss nicht exakt sein, nur konservativ genug, um Rotation/Truncation
# rechtzeitig auszuloesen, bevor eine Teildatei stark ueberschritten wird.
_FRAME_OVERHEAD_BYTES = 96
class SessionRecorder:
#: Wie oft der Hintergrund-Flush hoechstens schlaeft, bevor er den
#: Puffer erneut prueft (er wacht frueher auf, wenn der Puffer voll wird).
FLUSH_INTERVAL_S: float = settings.recording_flush_interval_s
#: Ab wie vielen gepufferten Eintraegen sofort (statt erst nach
#: FLUSH_INTERVAL_S) geflusht wird -- begrenzt den Speicherbedarf des
#: Puffers bei sehr schnellen Sitzungen (RDP-Bildstrom).
MAX_BUFFERED_ENTRIES: int = 200
def __init__(self, session_id: int) -> None:
self.session_id = session_id
self.path = settings.recordings_dir / f"session_{session_id}.jsonl"
self._prev_hash = GENESIS_HASH
self._base_path = settings.recordings_dir / f"session_{session_id}.jsonl"
# Nach aussen (DB recording_path, Admin-UI) bleibt dies der stabile
# "Ankerpfad" der Sitzung -- Teil 1 der ggf. rotierten Sequenz.
self.path = self._base_path
self._max_part_bytes = settings.recording_max_part_bytes
self._max_total_bytes = settings.recording_max_total_bytes
self._start_ts = time.time()
self._fh = open(self.path, "a", encoding="utf-8")
self._prev_hash = GENESIS_HASH
self._part_index = 1
self._part_queued_bytes = 0
self._total_queued_bytes = 0
self._truncated = False
self._closed = False
# Nur vom Event-Loop-Thread beruehrt (record()/_enqueue*).
self._buffer: list[tuple[int, str]] = []
# Nur vom Executor-Thread beruehrt (_write_batch_sync und Freunde).
self._writer_fh = self._open_part(self._part_index)
self._writer_part_index = self._part_index
self._flush_event = asyncio.Event()
self._flush_task: asyncio.Task | None = asyncio.get_event_loop().create_task(
self._flush_loop(), name=f"recorder-flush-{session_id}"
)
# -- Pfade -----------------------------------------------------------
def _part_path(self, part_index: int) -> Path:
if part_index == 1:
return self._base_path
return self._base_path.with_suffix(self._base_path.suffix + f".{part_index}")
def _open_part(self, part_index: int):
path = self._part_path(part_index)
fh = open(path, "a", encoding="utf-8")
try:
self.path.chmod(0o600)
path.chmod(0o600)
except OSError:
pass
return fh
# -- Event-Loop-Seite (synchron, kein I/O) ----------------------------
def record(self, direction: str, data: str) -> None:
"""direction: 'input' (Tastatureingabe) oder 'output' (Terminal-/RDP-Ausgabe)."""
"""direction: 'input' (Tastatureingabe) oder 'output' (Terminal-/RDP-Ausgabe).
Bleibt bewusst eine SYNCHRONE Methode (kein `async def`/`await`):
alle bestehenden Aufrufstellen (app/rdp_proxy/ws_tunnel.py,
app/ssh_proxy/terminal_ws.py) rufen sie ohne `await` aus
Hot-Path-Code auf. Sie darf daher niemals blockierendes I/O
ausfuehren -- das eigentliche Schreiben passiert ausschliesslich
im Hintergrund-Task (_flush_loop) im Executor.
"""
if self._closed or self._truncated:
return
offset = round(time.time() - self._start_ts, 4)
entry = {"t": offset, "dir": direction, "data": data}
entry_json = json.dumps(entry, ensure_ascii=False, sort_keys=True)
estimated_bytes = len(entry_json.encode("utf-8")) + _FRAME_OVERHEAD_BYTES
if self._total_queued_bytes + estimated_bytes > self._max_total_bytes:
self._enqueue_truncation_marker()
return
if self._part_queued_bytes > 0 and self._part_queued_bytes + estimated_bytes > self._max_part_bytes:
# Rotation: naechste Teildatei beginnt mit einer eigenen,
# frischen Genesis-Hash-Kette -- unabhaengig von der vorherigen.
self._part_index += 1
self._part_queued_bytes = 0
self._prev_hash = GENESIS_HASH
logger.info(
"Sitzungsaufzeichnung %s: rotiere auf Teil %d (Groessenbegrenzung %d Bytes erreicht)",
self.session_id, self._part_index, self._max_part_bytes,
)
self._append_line(entry, entry_json)
def _append_line(self, entry: dict, entry_json: str) -> None:
entry_hash = hashlib.sha256((self._prev_hash + "|" + entry_json).encode()).hexdigest()
line = json.dumps({"entry": entry, "prev_hash": self._prev_hash, "hash": entry_hash})
self._fh.write(line + "\n")
self._fh.flush()
line_bytes = len(line.encode("utf-8")) + 1
self._prev_hash = entry_hash
self._part_queued_bytes += line_bytes
self._total_queued_bytes += line_bytes
self._buffer.append((self._part_index, line))
if len(self._buffer) >= self.MAX_BUFFERED_ENTRIES:
self._flush_event.set()
def close(self) -> None:
if not self._fh.closed:
self._fh.close()
def _enqueue_truncation_marker(self) -> None:
if self._truncated:
return
self._truncated = True
logger.warning(
"Sitzungsaufzeichnung %s: Gesamtgroessenbegrenzung (%d Bytes) erreicht, "
"Aufzeichnung wird ab hier abgeschnitten (Sitzung laeuft normal weiter).",
self.session_id, self._max_total_bytes,
)
offset = round(time.time() - self._start_ts, 4)
entry = {"t": offset, "dir": "system", "data": "[recording truncated: max size reached]"}
entry_json = json.dumps(entry, ensure_ascii=False, sort_keys=True)
self._append_line(entry, entry_json)
self._flush_event.set()
# -- Executor-Seite (blockierendes Datei-I/O, nie im Event-Loop) -----
def _write_batch_sync(self, items: list[tuple[int, str]]) -> None:
"""Laeuft ausschliesslich via asyncio.to_thread; die einzige Stelle,
die self._writer_fh/self._writer_part_index anfasst -- sequentiell,
da _flush_loop jeden to_thread-Aufruf abwartet, bevor der naechste
beginnt (kein zweiter Executor-Thread gleichzeitig fuer denselben
Recorder)."""
try:
for part_index, line in items:
if part_index != self._writer_part_index:
self._writer_fh.close()
self._writer_part_index = part_index
self._writer_fh = self._open_part(part_index)
self._writer_fh.write(line + "\n")
self._writer_fh.flush()
except OSError:
logger.exception(
"Sitzungsaufzeichnung %s: Schreibfehler im Aufzeichnungs-Flush", self.session_id
)
# -- Hintergrund-Task --------------------------------------------------
async def _flush_loop(self) -> None:
try:
while True:
try:
await asyncio.wait_for(self._flush_event.wait(), timeout=self.FLUSH_INTERVAL_S)
except asyncio.TimeoutError:
pass
self._flush_event.clear()
await self._drain()
if self._closed and not self._buffer:
return
except asyncio.CancelledError:
# Letzter bestmoeglicher Abschluss-Flush vor dem Beenden des Tasks.
await self._drain()
raise
async def _drain(self) -> None:
if not self._buffer:
return
items, self._buffer = self._buffer, []
await asyncio.to_thread(self._write_batch_sync, items)
# -- Abschluss -----------------------------------------------------------
async def aclose(self) -> None:
"""Beendet die Aufzeichnung: signalisiert dem Hintergrund-Task das
Ende, wartet auf dessen letzten Flush (bis zu FLUSH_INTERVAL_S plus
Schreibzeit) und schliesst die Datei. MUSS aus einer Coroutine
aufgerufen werden (siehe Aufrufstellen in ws_tunnel.py/terminal_ws.py,
beide bereits in einem `finally:`-Block einer async-Funktion)."""
if self._closed:
return
self._closed = True
self._flush_event.set()
if self._flush_task is not None:
try:
await asyncio.wait_for(self._flush_task, timeout=self.FLUSH_INTERVAL_S + 5.0)
except asyncio.TimeoutError:
logger.warning(
"Sitzungsaufzeichnung %s: Abschluss-Flush-Task reagierte nicht rechtzeitig, "
"breche ab (letzte gepufferte Eintraege koennen fehlen).", self.session_id,
)
self._flush_task.cancel()
except asyncio.CancelledError:
pass
try:
self._writer_fh.flush()
self._writer_fh.close()
except OSError:
pass
def _iter_part_paths(base_path: Path):
"""Liefert alle Teildateien einer (ggf. rotierten) Aufzeichnung in
Reihenfolge: zuerst base_path selbst, danach .2, .3, ... solange sie
existieren."""
if base_path.exists():
yield base_path
part_index = 2
while True:
candidate = base_path.with_suffix(base_path.suffix + f".{part_index}")
if not candidate.exists():
return
yield candidate
part_index += 1
def verify_recording(path: Path) -> bool:
"""Prueft die Hash-Kette EINER Teildatei (Rueckwaertskompatibel: eine
unrotierte Aufzeichnung besteht nur aus dieser einen Datei). Fuer eine
vollstaendig rotierte Aufzeichnung siehe verify_recording_set()."""
prev_hash = GENESIS_HASH
with open(path, encoding="utf-8") as fh:
for line in fh:
@ -61,3 +287,32 @@ def verify_recording(path: Path) -> bool:
return False
prev_hash = row["hash"]
return True
def verify_recording_set(base_path: Path) -> bool:
"""Prueft ALLE Teildateien einer (ggf. rotierten) Aufzeichnung. Jede
Teildatei hat ihre eigene, unabhaengige Hash-Kette (siehe Moduldoc) --
das Gesamtergebnis ist gueltig, wenn jede einzelne Teildatei fuer sich
gueltig ist."""
parts = list(_iter_part_paths(base_path))
if not parts:
return False
return all(verify_recording(p) for p in parts)
def iter_recording_entries(base_path: Path):
"""Liefert die geparsten 'entry'-Objekte ALLER Teildateien in
chronologischer Reihenfolge (Teildateien sind bereits zeitlich
fortlaufend, da Rotation nur beim Erreichen der Groessenbegrenzung
ausgeloest wird)."""
for part_path in _iter_part_paths(base_path):
with open(part_path, encoding="utf-8") as fh:
for line in fh:
line = line.strip()
if not line:
continue
yield json.loads(line)["entry"]
def count_recording_entries(base_path: Path) -> int:
return sum(1 for _ in iter_recording_entries(base_path))

View File

@ -0,0 +1,63 @@
"""
Session-Aufzeichnung mit Hash-Verkettung (siehe Konzept 6.5).
Jede Session schreibt eine eigene JSONL-Datei unter settings.recordings_dir.
Jede Zeile verkettet sich mit der vorherigen (gleiches Prinzip wie das
Audit-Log, app/security/audit.py), damit nachtraegliche Manipulation der
Aufzeichnung erkennbar ist.
"""
from __future__ import annotations
import hashlib
import json
import time
from pathlib import Path
from app.config import settings
GENESIS_HASH = "0" * 64
class SessionRecorder:
def __init__(self, session_id: int) -> None:
self.session_id = session_id
self.path = settings.recordings_dir / f"session_{session_id}.jsonl"
self._prev_hash = GENESIS_HASH
self._start_ts = time.time()
self._fh = open(self.path, "a", encoding="utf-8")
try:
self.path.chmod(0o600)
except OSError:
pass
def record(self, direction: str, data: str) -> None:
"""direction: 'input' (Tastatureingabe) oder 'output' (Terminal-/RDP-Ausgabe)."""
offset = round(time.time() - self._start_ts, 4)
entry = {"t": offset, "dir": direction, "data": data}
entry_json = json.dumps(entry, ensure_ascii=False, sort_keys=True)
entry_hash = hashlib.sha256((self._prev_hash + "|" + entry_json).encode()).hexdigest()
line = json.dumps({"entry": entry, "prev_hash": self._prev_hash, "hash": entry_hash})
self._fh.write(line + "\n")
self._fh.flush()
self._prev_hash = entry_hash
def close(self) -> None:
if not self._fh.closed:
self._fh.close()
def verify_recording(path: Path) -> bool:
prev_hash = GENESIS_HASH
with open(path, encoding="utf-8") as fh:
for line in fh:
if not line.strip():
continue
row = json.loads(line)
if row["prev_hash"] != prev_hash:
return False
entry_json = json.dumps(row["entry"], ensure_ascii=False, sort_keys=True)
expected = hashlib.sha256((prev_hash + "|" + entry_json).encode()).hexdigest()
if expected != row["hash"]:
return False
prev_hash = row["hash"]
return True

View File

@ -34,14 +34,15 @@ from dataclasses import dataclass, field
class ActiveSession:
session_id: int
task: asyncio.Task
user_id: int
watchers: set[asyncio.Queue] = field(default_factory=set)
_active: dict[int, ActiveSession] = {}
def register(session_id: int, task: asyncio.Task) -> None:
_active[session_id] = ActiveSession(session_id=session_id, task=task)
def register(session_id: int, task: asyncio.Task, user_id: int) -> None:
_active[session_id] = ActiveSession(session_id=session_id, task=task, user_id=user_id)
def unregister(session_id: int) -> None:
@ -56,6 +57,22 @@ def all_ids() -> set[int]:
return set(_active.keys())
def count_total() -> int:
"""Anzahl aller laufenden Sitzungen auf diesem Prozess (E.4,
Umsetzungsauftrag Teil E: globale Obergrenze)."""
return len(_active)
def count_for_user(user_id: int) -> int:
"""Anzahl laufender Sitzungen eines Benutzers auf diesem Prozess (E.4:
Obergrenze je Benutzer). Bewusst prozesslokal wie die gesamte Registry
(siehe Moduldoc) -- bei einem einzelnen uvicorn-Worker (Standard-
Deployment) ist das gleichbedeutend mit "insgesamt", bei mehreren
Workern zaehlt es nur die auf DIESEM Worker laufenden Sitzungen dieses
Benutzers."""
return sum(1 for entry in _active.values() if entry.user_id == user_id)
def add_watcher(session_id: int) -> asyncio.Queue | None:
"""Meldet einen Beobachter fuer eine laufende Sitzung an. Gibt None
zurueck, wenn die Sitzung nicht (mehr) auf diesem Prozess laeuft --

View File

@ -15,6 +15,8 @@ import json
from datetime import datetime, timezone
from typing import Any
import asyncio
import aiosqlite
GENESIS_HASH = "0" * 64
@ -25,6 +27,22 @@ def _entry_hash(prev_hash: str, ts: str, event_type: str, details_json: str) ->
return hashlib.sha256(payload).hexdigest()
# E1 (Umsetzungsauftrag_Sonnet5.md Teil E.1): Lesen von prev_hash und das
# nachfolgende INSERT muessen atomar sein. Ohne Schutz koennen zwei
# gleichzeitig laufende Aufrufe (z.B. zwei Sitzungen, die im selben Moment
# starten/enden) denselben prev_hash lesen und beide darauf aufbauen -- die
# Kette bricht dann dauerhaft, weil UPDATE/DELETE auf audit_log durch
# DB-Trigger verboten sind (0001_initial.sql). Zwei Schutzschichten:
# 1. Ein prozessweiter asyncio.Lock serialisiert alle Aufrufe innerhalb
# dieses (einzigen) Event-Loops -- ausreichend, solange die Anwendung in
# einem uvicorn-Worker mit einer globalen Verbindung laeuft (E.0/E.5).
# 2. BEGIN IMMEDIATE/COMMIT als zweite, von der Prozesstopologie
# unabhaengige Schutzschicht: falls doch einmal mehrere Verbindungen auf
# dieselbe Datei schreiben, sorgt die IMMEDIATE-Schreibsperre dafuer,
# dass SELECT und INSERT nicht auseinandergerissen werden.
_write_lock = asyncio.Lock()
async def write_audit_event(
conn: aiosqlite.Connection,
*,
@ -37,20 +55,33 @@ async def write_audit_event(
Muss innerhalb derselben Transaktion wie die fachliche Aktion laufen (oder
zumindest unmittelbar danach), damit kein Ereignis unauditiert bleibt.
Lesen des prev_hash und das INSERT sind durch einen Lock plus eine
BEGIN-IMMEDIATE-Transaktion atomar (siehe E1 in Umsetzungsauftrag_Sonnet5.md).
"""
cursor = await conn.execute("SELECT entry_hash FROM audit_log ORDER BY id DESC LIMIT 1")
row = await cursor.fetchone()
prev_hash = row[0] if row else GENESIS_HASH
async with _write_lock:
await conn.execute("BEGIN IMMEDIATE")
try:
cursor = await conn.execute(
"SELECT entry_hash FROM audit_log ORDER BY id DESC LIMIT 1"
)
row = await cursor.fetchone()
prev_hash = row[0] if row else GENESIS_HASH
ts = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%S.%fZ")
details_json = json.dumps(details, sort_keys=True, ensure_ascii=False)
entry_hash = _entry_hash(prev_hash, ts, event_type, details_json)
ts = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%S.%fZ")
details_json = json.dumps(details, sort_keys=True, ensure_ascii=False)
entry_hash = _entry_hash(prev_hash, ts, event_type, details_json)
await conn.execute(
"INSERT INTO audit_log (ts, user_id, client_ip, event_type, details_json, prev_hash, entry_hash) "
"VALUES (?, ?, ?, ?, ?, ?, ?)",
(ts, user_id, client_ip, event_type, details_json, prev_hash, entry_hash),
)
await conn.execute(
"INSERT INTO audit_log (ts, user_id, client_ip, event_type, details_json, prev_hash, entry_hash) "
"VALUES (?, ?, ?, ?, ?, ?, ?)",
(ts, user_id, client_ip, event_type, details_json, prev_hash, entry_hash),
)
except BaseException:
await conn.execute("ROLLBACK")
raise
else:
await conn.execute("COMMIT")
async def verify_chain(conn: aiosqlite.Connection) -> tuple[bool, int | None]:

View File

@ -11,6 +11,8 @@ from __future__ import annotations
import time
from collections import defaultdict, deque
from app.config import settings
class SlidingWindowRateLimiter:
def __init__(self, max_events: int, window_seconds: int) -> None:
@ -29,6 +31,16 @@ class SlidingWindowRateLimiter:
return True
# Max. 10 Login-Versuche pro Minute und Quell-IP; ergaenzt den
# Account-basierten Lockout in app/auth/routes.py.
login_rate_limiter = SlidingWindowRateLimiter(max_events=10, window_seconds=60)
# E8 (Umsetzungsauftrag Teil E): Werte konfigurierbar (settings.login_rate_
# limit_*, siehe app/config.py) statt fest verdrahtet -- ergaenzt den
# Account-basierten Lockout in app/auth/routes.py. ACHTUNG: greift pro
# Quell-IP. Sitzen mehrere interne Benutzer hinter derselben NAT-Adresse
# (typischer Buero-Fall), teilen sie sich dieses Budget; das ist eine
# bewusste Betriebsentscheidung und muss zusammen mit dem nginx-limit_req
# und der fail2ban-Schwelle (beide ebenfalls konfigurierbar, siehe
# ansible/roles/nginx_proxy und ansible/roles/fail2ban) im Betriebshandbuch
# festgehalten werden.
login_rate_limiter = SlidingWindowRateLimiter(
max_events=settings.login_rate_limit_max_events,
window_seconds=settings.login_rate_limit_window_s,
)

View File

@ -0,0 +1,83 @@
"""
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)

View File

@ -15,6 +15,7 @@ import logging
import asyncssh
import aiosqlite
from app.rbac import AmbiguousCredentialError, resolve_credential_for_user_on_host
from app.security.crypto import decrypt_secret
logger = logging.getLogger("jumphost.ssh_proxy")
@ -215,6 +216,10 @@ SSH_SETUP_ERRORS: tuple[type[Exception], ...] = (
HostKeyMismatchError,
HostKeyDiscoveryError,
PrivateKeyUnusableError,
# Teil D Schritt 4 (Achse B): mehr als ein fuer den Benutzer nutzbarer
# SSH-Key an einem Host -- eine Konfigurationsfrage, keine interne
# Ausnahme, gehoert deshalb in dieselbe Klasse wie die Eintraege oben.
AmbiguousCredentialError,
)
@ -229,6 +234,13 @@ def describe_connection_error(exc: Exception) -> str:
)
if isinstance(exc, HostKeyDiscoveryError):
return f"Das Ziel ist nicht erreichbar: {exc.reason}"
if isinstance(exc, AmbiguousCredentialError):
return (
f"Mehrdeutige Zugangsdaten fuer diesen Host: mehrere freigegebene SSH-Schluessel "
f"(IDs {exc.credential_ids}) treffen fuer Sie zu, keine automatische Auswahl "
"moeglich. Bitte im Adminbereich bereinigen (ueberzaehligen Schluessel entfernen "
"oder Gruppenfreigabe einschraenken)."
)
return str(exc)
@ -294,13 +306,22 @@ async def load_host(conn: aiosqlite.Connection, host_id: int) -> dict:
async def load_ssh_key_credential_for_host(
conn: aiosqlite.Connection, host_id: int
conn: aiosqlite.Connection, host_id: int, *, user_id: int
) -> tuple[asyncssh.SSHKey, str | None] | None:
"""Laedt den dem Host zugeordneten Schluessel, entschluesselt ihn und gibt
ihn zusammen mit dem am Schluessel hinterlegten Benutzernamen zurueck.
Gibt None zurueck (statt zu werfen), wenn kein Schluessel zugeordnet ist
-- der Aufrufer (connect_to_host) faellt dann auf ein SSH-Passwort
zurueck, falls eines hinterlegt ist (Migration 0011).
"""Laedt den fuer DIESEN Benutzer geltenden, dem Host zugeordneten
Schluessel, entschluesselt ihn und gibt ihn zusammen mit dem am
Schluessel hinterlegten Benutzernamen zurueck. Gibt None zurueck (statt
zu werfen), wenn kein Schluessel zutrifft -- der Aufrufer
(connect_to_host) faellt dann auf ein SSH-Passwort zurueck, falls eines
hinterlegt ist (Migration 0011).
Seit Teil D Schritt 4 (Achse B) laeuft die Auswahl NICHT mehr blind ueber
den Host allein: app.rbac.resolve_credential_for_user_on_host()
beruecksichtigt zusaetzlich, welcher der dem Host zugeordneten
Schluessel ueber group_ssh_key_grants mindestens einer Benutzergruppe
dieses Users freigegeben ist. Wirft AmbiguousCredentialError, wenn mehr
als ein Schluessel zutrifft (bewusst kein stilles LIMIT 1 mehr, siehe
dortige Docstring).
Der Benutzername gehoert seit Migration 0010 zu den Zugangsdaten
(ssh_keys.username) und nicht mehr zum Host: er ist Teil der Anmeldung,
@ -308,11 +329,14 @@ async def load_ssh_key_credential_for_host(
Passphrase hinterlegt (ssh_keys.passphrase_enc, Migration 0009), wird sie
ebenfalls entschluesselt und an asyncssh uebergeben.
"""
ssh_key_id = await resolve_credential_for_user_on_host(
conn, user_id=user_id, host_id=host_id, kind="ssh_key"
)
if ssh_key_id is None:
return None
cursor = await conn.execute(
"SELECT sk.private_key_enc, sk.passphrase_enc, sk.username FROM ssh_keys sk "
"JOIN host_ssh_key_map m ON m.ssh_key_id = sk.id "
"WHERE m.host_id = ? LIMIT 1",
(host_id,),
"SELECT private_key_enc, passphrase_enc, username FROM ssh_keys WHERE id = ?",
(ssh_key_id,),
)
row = await cursor.fetchone()
if row is None:
@ -332,14 +356,25 @@ async def load_ssh_key_credential_for_host(
async def load_ssh_password_credential_for_host(
conn: aiosqlite.Connection, host_id: int
conn: aiosqlite.Connection, host_id: int, *, user_id: int
) -> tuple[str, str] | None:
"""Laedt das (Passwort, Benutzername)-Paar fuer einen Host OHNE
zugeordneten SSH-Key (ssh_password_credentials, Migration 0011). Gibt
None zurueck, wenn kein SSH-Passwort hinterlegt ist."""
"""Laedt das (Passwort, Benutzername)-Paar, das fuer DIESEN Benutzer auf
einem Host OHNE (fuer ihn nutzbaren) SSH-Key gilt (ssh_password_credentials,
Migration 0011, seit Migration 0016 ueber host_ssh_password_credential_map
referenziert -- eigenstaendiges Objekt mit eigener ID, siehe dortiger
Kommentar). Gibt None zurueck, wenn kein SSH-Passwort zutrifft. Wie beim
SSH-Key laeuft die Auswahl seit Teil D Schritt 4 ueber
app.rbac.resolve_credential_for_user_on_host() (Achse B); strukturell
kann hier keine Mehrdeutigkeit auftreten (host_ssh_password_credential_map
hat PK auf host_id, also hoechstens ein Treffer)."""
credential_id = await resolve_credential_for_user_on_host(
conn, user_id=user_id, host_id=host_id, kind="ssh_password_credential"
)
if credential_id is None:
return None
cursor = await conn.execute(
"SELECT password_enc, username FROM ssh_password_credentials WHERE host_id = ?",
(host_id,),
"SELECT password_enc, username FROM ssh_password_credentials WHERE id = ?",
(credential_id,),
)
row = await cursor.fetchone()
if row is None:
@ -351,12 +386,15 @@ async def load_ssh_password_credential_for_host(
del password
async def load_private_key_for_host(conn: aiosqlite.Connection, host_id: int) -> asyncssh.SSHKey:
async def load_private_key_for_host(
conn: aiosqlite.Connection, host_id: int, *, user_id: int
) -> asyncssh.SSHKey:
"""Rueckwaertskompatible Fassung ohne Benutzername (siehe
load_ssh_key_credential_for_host). Wirft HostNotConfiguredError, wenn kein
Schluessel zugeordnet ist -- anders als connect_to_host beruecksichtigt
diese Fassung KEIN SSH-Passwort als Alternative."""
result = await load_ssh_key_credential_for_host(conn, host_id)
load_ssh_key_credential_for_host). Wirft HostNotConfiguredError, wenn
kein fuer diesen Benutzer nutzbarer Schluessel zutrifft -- anders als
connect_to_host beruecksichtigt diese Fassung KEIN SSH-Passwort als
Alternative."""
result = await load_ssh_key_credential_for_host(conn, host_id, user_id=user_id)
if result is None:
raise HostNotConfiguredError(f"Kein SSH-Schluessel fuer Host {host_id} hinterlegt")
key, _username = result
@ -484,7 +522,9 @@ def _public_key_body(openssh_line: str) -> tuple[str, str]:
return (parts[0], parts[1])
async def connect_to_host(conn: aiosqlite.Connection, host_id: int) -> asyncssh.SSHClientConnection:
async def connect_to_host(
conn: aiosqlite.Connection, host_id: int, *, user_id: int
) -> asyncssh.SSHClientConnection:
host = await load_host(conn, host_id)
if host["protocol"] != "ssh":
raise HostNotConfiguredError("Host ist kein SSH-Ziel")
@ -496,13 +536,23 @@ async def connect_to_host(conn: aiosqlite.Connection, host_id: int) -> asyncssh.
# SSH-Key hat immer Vorrang vor einem SSH-Passwort (Migration 0011,
# "Linux kann statt SSH-Key auch Passwort haben" -- explizit als
# Alternative gewuenscht, nicht als gleichrangige zweite Option: ist ein
# Schluessel zugeordnet, wird er benutzt, unabhaengig davon, ob zusaetzlich
# ein Passwort hinterlegt ist).
key_credential = await load_ssh_key_credential_for_host(conn, host_id)
password_credential = None if key_credential is not None else await load_ssh_password_credential_for_host(conn, host_id)
# fuer DIESEN Benutzer nutzbarer Schluessel zugeordnet, wird er benutzt,
# unabhaengig davon, ob zusaetzlich ein Passwort hinterlegt ist). Beide
# Aufloesungen sind seit Teil D Schritt 4 benutzerabhaengig (Achse B) --
# siehe load_ssh_key_credential_for_host()/load_ssh_password_credential_for_host().
key_credential = await load_ssh_key_credential_for_host(conn, host_id, user_id=user_id)
password_credential = (
None
if key_credential is not None
else await load_ssh_password_credential_for_host(conn, host_id, user_id=user_id)
)
if key_credential is None and password_credential is None:
raise HostNotConfiguredError(
f"Weder ein SSH-Schluessel noch ein SSH-Passwort fuer Host {host_id} hinterlegt"
f"Weder ein SSH-Schluessel noch ein SSH-Passwort fuer Host {host_id} hinterlegt "
"oder Ihrer Benutzergruppe freigegeben. Falls Sie ueber eine Gruppe "
"'ssh_connect' fuer diesen Host haben, aber trotzdem diese Meldung sehen: "
"die Zugangsdaten sind zwar zugeordnet, aber keiner Ihrer Gruppen ueber "
"Achse B freigegeben (Adminbereich -> Benutzergruppen -> Zugangsdaten)."
)
if key_credential is not None:

View File

@ -5,6 +5,7 @@ Groessenlimit, Sha256-Hashing und optionaler AV-Scan sind Pflicht (Konzept
"""
from __future__ import annotations
import asyncio
import hashlib
import logging
@ -13,6 +14,7 @@ from fastapi import APIRouter, Depends, HTTPException, Query, Request, UploadFil
from fastapi.responses import StreamingResponse
from app.auth.deps import CurrentUser, get_current_user
from app.config import settings
from app.db import get_db
from app.rbac import user_has_role_for_host
from app.security.audit import write_audit_event
@ -31,6 +33,20 @@ router = APIRouter(prefix="/ssh", tags=["file-transfer"])
MAX_UPLOAD_BYTES = 200 * 1024 * 1024 # 200 MiB, ueber Ansible-Variable konfigurierbar (siehe Konzept)
# E4 (Umsetzungsauftrag Teil E): Ein-/Auslesen in Bloecken statt in einem
# einzigen file.read(MAX_UPLOAD_BYTES + 1)/remote_file.read()-Aufruf --
# sowohl fuers Streaming zum/vom SFTP-Ziel als auch fuer inkrementelles
# Hashing (ein einzelner hashlib.sha256(<200 MiB>)-Aufruf haelt den
# Event-Loop zwar nur kurz, aber synchron und ohne jede Zwischen-await-
# Gelegenheit an -- inkrementelles Update() je Chunk verteilt das).
TRANSFER_CHUNK_BYTES = 1 * 1024 * 1024 # 1 MiB
# E4: Obergrenze gleichzeitig laufender Transfers -- Prozess-weiter
# In-Memory-Zustand wie login_rate_limiter/active_sessions (siehe E.0),
# bewusst NICHT pro Worker (es gibt ohnehin nur einen, --workers waere laut
# E.0 ohnehin keine Option).
_transfer_semaphore = asyncio.Semaphore(settings.max_concurrent_transfers)
def _client_ip(request: Request) -> str:
return request.client.host if request.client else "unknown"
@ -80,66 +96,103 @@ async def upload_file(
host=Depends(_require_file_transfer),
user: CurrentUser = Depends(get_current_user),
):
data = await file.read(MAX_UPLOAD_BYTES + 1)
if len(data) > MAX_UPLOAD_BYTES:
raise HTTPException(status.HTTP_413_CONTENT_TOO_LARGE, "Datei zu gross")
# E4 (Umsetzungsauftrag Teil E): in Bloecken statt in einem einzigen
# file.read(MAX_UPLOAD_BYTES + 1)-Aufruf lesen. Der AV-Scan (scan_bytes)
# braucht weiterhin den vollstaendigen Inhalt in einem Stueck -- das
# Limit wird deshalb WAEHREND des Einlesens durchgesetzt (frueher
# Abbruch, sobald es ueberschritten ist, statt still bis MAX+1 zu lesen),
# und das Hashing laeuft inkrementell mit, statt als ein einzelner
# blockierender hashlib.sha256(<gesamte Datei>)-Aufruf am Ende.
hasher = hashlib.sha256()
chunks: list[bytes] = []
total = 0
while True:
chunk = await file.read(TRANSFER_CHUNK_BYTES)
if not chunk:
break
total += len(chunk)
if total > MAX_UPLOAD_BYTES:
raise HTTPException(status.HTTP_413_CONTENT_TOO_LARGE, "Datei zu gross")
hasher.update(chunk)
chunks.append(chunk)
data = b"".join(chunks)
del chunks
sha256 = hasher.hexdigest()
async with _transfer_semaphore:
# E3 (Umsetzungsauftrag Teil E): scan_bytes() ruft synchron
# subprocess.run(..., timeout=30) auf -- bis zu 30 Sekunden, in denen
# der EINZIGE Event-Loop der Anwendung fuer ALLE Benutzer stillstand
# (siehe E.0). asyncio.to_thread() lagert den blockierenden Aufruf in
# einen Worker-Thread aus.
av_result = await asyncio.to_thread(scan_bytes, data)
if av_result.startswith("infected"):
conn = get_db()
await write_audit_event(
conn, event_type="file_transfer_blocked_malware", user_id=user.id,
client_ip=_client_ip(request),
details={"host_id": host_id, "filename": file.filename, "av_scan_result": av_result},
)
await conn.commit()
raise HTTPException(status.HTTP_400_BAD_REQUEST, f"Datei durch AV-Scan blockiert: {av_result}")
av_result = scan_bytes(data)
if av_result.startswith("infected"):
conn = get_db()
await write_audit_event(
conn, event_type="file_transfer_blocked_malware", user_id=user.id,
client_ip=_client_ip(request),
details={"host_id": host_id, "filename": file.filename, "av_scan_result": av_result},
)
await conn.commit()
raise HTTPException(status.HTTP_400_BAD_REQUEST, f"Datei durch AV-Scan blockiert: {av_result}")
sha256 = hashlib.sha256(data).hexdigest()
conn = get_db()
try:
ssh_conn = await connect_to_host(conn, host_id)
try:
async with ssh_conn.start_sftp_client() as sftp:
async with sftp.open(remote_path, "wb") as remote_file:
await remote_file.write(data)
finally:
ssh_conn.close()
except SSH_SETUP_ERRORS as exc:
# Alles, was den Verbindungsaufbau verhindert und in der Konfiguration
# begruendet ist (fehlender Benutzername/Schluessel, unbrauchbarer
# Schluessel, nicht gepinnter oder abweichender Host-Key, Ziel nicht
# erreichbar): 400 mit Klartext statt eines unbehandelten 500.
logger.warning("Dateitransfer fuer Host %s nicht moeglich: %s", host_id, exc)
raise HTTPException(status.HTTP_400_BAD_REQUEST, describe_connection_error(exc))
except asyncssh.Error as exc:
# Bugfix: SSH_SETUP_ERRORS deckt nur Fehler VOR der Anmeldung ab
# (Pinning/Konfiguration). asyncssh.Error ist asyncssh's gemeinsame
# Basisklasse -- das schliesst sowohl einen echten Verbindungsfehler
# WAEHREND asyncssh.connect() ein (z.B. Verbindung abgelehnt/abgebrochen,
# von connect_to_host() bewusst unuebersetzt weitergereicht) als auch
# SFTP-Fehler NACH erfolgreicher Anmeldung (z.B. SFTPNoSuchFile, wenn
# das Zielverzeichnis nicht existiert, oder SFTPPermissionDenied). Beides
# war hier bisher NICHT gefangen und lief unbehandelt bis Starlette
# durch, das bei einer unbehandelten Exception (debug=False) eine
# KLARTEXT-500-Antwort "Internal Server Error" liefert statt JSON --
# daher der Frontend-Fehler "Unexpected token 'I', 'Internal S'... is
# not valid JSON" beim Upload.
logger.warning("SFTP-Fehler bei Host %s: %s", host_id, exc)
raise HTTPException(status.HTTP_400_BAD_REQUEST, f"SFTP-Fehler: {exc}")
except HTTPException:
raise
except Exception:
# Letztes Auffangnetz (gleiches Muster wie terminal_ws.py): niemals
# eine unbehandelte Ausnahme bis zu Starlettes Klartext-500 durchreichen
# -- das Frontend erwartet hier immer eine JSON-Antwort.
logger.exception("Unerwarteter Fehler beim Datei-Upload fuer Host %s", host_id)
raise HTTPException(status.HTTP_500_INTERNAL_SERVER_ERROR, "Unerwarteter Fehler beim Dateitransfer")
ssh_conn = await connect_to_host(conn, host_id, user_id=user.id)
try:
async with ssh_conn.start_sftp_client() as sftp:
async with sftp.open(remote_path, "wb") as remote_file:
# In Bloecken schreiben statt eines einzelnen
# write(<gesamte Datei>): der AV-Scan (scan_bytes)
# braucht den vollstaendigen Inhalt vor dem Schreiben
# in einem Stueck, `data` haelt die Datei deshalb
# weiterhin komplett im Speicher (unveraendert
# gegenueber vorher) -- echtes speicherbegrenztes
# Streaming ist beim Upload durch das "erst scannen,
# dann schreiben"-Erfordernis inhaerent nicht moeglich,
# ohne den AV-Scan selbst auf Datei-/Stream-Basis
# umzustellen. Der chunk-weise write() vermeidet
# zumindest einen einzelnen sehr grossen asyncssh-
# Aufruf und haelt jeden einzelnen await kurz.
for offset in range(0, len(data), TRANSFER_CHUNK_BYTES):
await remote_file.write(data[offset:offset + TRANSFER_CHUNK_BYTES])
finally:
ssh_conn.close()
except SSH_SETUP_ERRORS as exc:
# Alles, was den Verbindungsaufbau verhindert und in der Konfiguration
# begruendet ist (fehlender Benutzername/Schluessel, unbrauchbarer
# Schluessel, nicht gepinnter oder abweichender Host-Key, Ziel nicht
# erreichbar): 400 mit Klartext statt eines unbehandelten 500.
logger.warning("Dateitransfer fuer Host %s nicht moeglich: %s", host_id, exc)
raise HTTPException(status.HTTP_400_BAD_REQUEST, describe_connection_error(exc))
except asyncssh.Error as exc:
# Bugfix: SSH_SETUP_ERRORS deckt nur Fehler VOR der Anmeldung ab
# (Pinning/Konfiguration). asyncssh.Error ist asyncssh's gemeinsame
# Basisklasse -- das schliesst sowohl einen echten Verbindungsfehler
# WAEHREND asyncssh.connect() ein (z.B. Verbindung abgelehnt/abgebrochen,
# von connect_to_host() bewusst unuebersetzt weitergereicht) als auch
# SFTP-Fehler NACH erfolgreicher Anmeldung (z.B. SFTPNoSuchFile, wenn
# das Zielverzeichnis nicht existiert, oder SFTPPermissionDenied). Beides
# war hier bisher NICHT gefangen und lief unbehandelt bis Starlette
# durch, das bei einer unbehandelten Exception (debug=False) eine
# KLARTEXT-500-Antwort "Internal Server Error" liefert statt JSON --
# daher der Frontend-Fehler "Unexpected token 'I', 'Internal S'... is
# not valid JSON" beim Upload.
logger.warning("SFTP-Fehler bei Host %s: %s", host_id, exc)
raise HTTPException(status.HTTP_400_BAD_REQUEST, f"SFTP-Fehler: {exc}")
except HTTPException:
raise
except Exception:
# Letztes Auffangnetz (gleiches Muster wie terminal_ws.py): niemals
# eine unbehandelte Ausnahme bis zu Starlettes Klartext-500 durchreichen
# -- das Frontend erwartet hier immer eine JSON-Antwort.
logger.exception("Unerwarteter Fehler beim Datei-Upload fuer Host %s", host_id)
raise HTTPException(status.HTTP_500_INTERNAL_SERVER_ERROR, "Unerwarteter Fehler beim Dateitransfer")
await _log_transfer(
conn, user=user, host_id=host_id, client_ip=_client_ip(request), direction="upload",
filename=file.filename or remote_path, size=len(data), sha256=sha256, av_result=av_result,
)
await _log_transfer(
conn, user=user, host_id=host_id, client_ip=_client_ip(request), direction="upload",
filename=file.filename or remote_path, size=len(data), sha256=sha256, av_result=av_result,
)
return {"status": "ok", "sha256": sha256, "size": len(data), "av_scan_result": av_result}
@ -151,23 +204,44 @@ async def download_file(
host=Depends(_require_file_transfer),
user: CurrentUser = Depends(get_current_user),
):
# E4 (Umsetzungsauftrag Teil E): echtes Streaming statt
# remote_file.read() der kompletten Datei in einen einzigen `data`-Puffer
# gefolgt von StreamingResponse(yield data) (das war effektiv KEIN
# Streaming -- der gesamte Speicher-Peak war identisch zu vorher, nur in
# eine andere Form verpackt). Anders als beim Upload gibt es beim
# Download KEINEN AV-Scan-Zwang, der den vollstaendigen Inhalt vor dem
# Weiterreichen braucht -- echtes chunkweises Streaming direkt in die
# HTTP-Antwort ist hier also tatsaechlich moeglich und sinnvoll.
#
# Verbindungsaufbau + stat() laufen bewusst NOCH VOR der
# StreamingResponse (mit vollstaendiger Fehlerbehandlung wie bisher) --
# sobald die Antwort einmal zu streamen begonnen hat, kann der
# HTTP-Statuscode nicht mehr geaendert werden. sftp/remote_file bleiben
# ueber die gesamte Dauer des Streams offen und werden erst im
# finally-Block des Generators geschlossen.
conn = get_db()
await _transfer_semaphore.acquire()
try:
ssh_conn = await connect_to_host(conn, host_id)
ssh_conn = await connect_to_host(conn, host_id, user_id=user.id)
try:
async with ssh_conn.start_sftp_client() as sftp:
sftp = await ssh_conn.start_sftp_client()
try:
stat = await sftp.stat(remote_path)
if stat.size and stat.size > MAX_UPLOAD_BYTES:
raise HTTPException(status.HTTP_413_CONTENT_TOO_LARGE, "Datei zu gross")
async with sftp.open(remote_path, "rb") as remote_file:
data = await remote_file.read()
finally:
remote_file = await sftp.open(remote_path, "rb")
except Exception:
sftp.exit()
raise
except Exception:
ssh_conn.close()
raise
except SSH_SETUP_ERRORS as exc:
# Alles, was den Verbindungsaufbau verhindert und in der Konfiguration
# begruendet ist (fehlender Benutzername/Schluessel, unbrauchbarer
# Schluessel, nicht gepinnter oder abweichender Host-Key, Ziel nicht
# erreichbar): 400 mit Klartext statt eines unbehandelten 500.
_transfer_semaphore.release()
logger.warning("Dateitransfer fuer Host %s nicht moeglich: %s", host_id, exc)
raise HTTPException(status.HTTP_400_BAD_REQUEST, describe_connection_error(exc))
except asyncssh.Error as exc:
@ -175,26 +249,66 @@ async def download_file(
# Bugfix: SFTP-Fehler nach erfolgreicher Anmeldung (z.B. Datei nicht
# gefunden, keine Leseberechtigung) liefen bisher unbehandelt bis zu
# Starlettes Klartext-500 durch.
_transfer_semaphore.release()
logger.warning("SFTP-Fehler bei Host %s: %s", host_id, exc)
raise HTTPException(status.HTTP_400_BAD_REQUEST, f"SFTP-Fehler: {exc}")
except HTTPException:
_transfer_semaphore.release()
raise
except Exception:
_transfer_semaphore.release()
logger.exception("Unerwarteter Fehler beim Datei-Download fuer Host %s", host_id)
raise HTTPException(status.HTTP_500_INTERNAL_SERVER_ERROR, "Unerwarteter Fehler beim Dateitransfer")
# Ab hier (kein except griff) ist der Stream erfolgreich eroeffnet --
# das Semaphor wird bewusst NICHT hier freigegeben, sondern erst im
# finally-Block von _stream() unten, sobald der komplette Download
# abgeschlossen (oder abgebrochen) ist.
sha256 = hashlib.sha256(data).hexdigest()
filename = remote_path.rsplit("/", 1)[-1]
await _log_transfer(
conn, user=user, host_id=host_id, client_ip=_client_ip(request), direction="download",
filename=filename, size=len(data), sha256=sha256, av_result="not_applicable_download",
)
def _iter():
yield data
async def _stream():
hasher = hashlib.sha256()
total = 0
try:
while True:
chunk = await remote_file.read(TRANSFER_CHUNK_BYTES)
if not chunk:
break
total += len(chunk)
hasher.update(chunk)
yield chunk
finally:
try:
# SFTPClientFile wird andernorts in diesem Modul immer als
# `async with sftp.open(...) as remote_file:` verwendet --
# das bedeutet close() ist dort eine Koroutine (__aexit__
# ruft sie awaited auf). Hier manuell dasselbe nachgebildet,
# da der Dateihandle ueber die gesamte Stream-Dauer offen
# bleiben muss und daher nicht in einem `async with` um nur
# den Lesevorgang herum verwaltet werden kann.
await remote_file.close()
except Exception:
pass
sftp.exit()
ssh_conn.close()
_transfer_semaphore.release()
try:
await _log_transfer(
conn, user=user, host_id=host_id, client_ip=_client_ip(request), direction="download",
filename=filename, size=total, sha256=hasher.hexdigest(), av_result="not_applicable_download",
)
except Exception:
# Der Download selbst ist zu diesem Zeitpunkt beim Client
# bereits (teilweise) angekommen -- ein fehlgeschlagener
# Logeintrag darf den bereits laufenden Stream nicht mehr
# rueckwirkend als Fehler erscheinen lassen, muss aber sichtbar
# sein.
logger.exception(
"Download-Protokollierung fuer Host %s / %s fehlgeschlagen", host_id, remote_path
)
return StreamingResponse(
_iter(),
_stream(),
media_type="application/octet-stream",
headers={"Content-Disposition": f'attachment; filename="{filename}"'},
)

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

View File

@ -1,97 +0,0 @@
"""
Mandanten-Scoping: zentrale Stelle, die die Mandanten-Sichtbarkeit eines
Principals (Super-Admin / Mandanten-Admin-Session / Mandanten-gebundenes
API-Token, siehe app/auth/deps.py) in SQL-WHERE-Fragmente und
Zugriffspruefungen uebersetzt. JEDER /admin-Endpunkt, der mandantengebundene
Ressourcen (Hostgruppen, Hosts, Benutzergruppen, SSH-Keys, API-Tokens,
Rollenvergaben, Audit-Log) liest oder schreibt, muss ueber TenantScope gehen
-- damit die Isolation an genau einer Stelle durchgesetzt wird statt in jedem
Endpunkt einzeln neu erfunden zu werden.
Sicherheitsprinzip: eine Zugriffsverletzung ueber eine ID aus einem fremden
Mandanten liefert 404 (nicht 403) -- ein Mandanten-Admin soll aus der
Fehlerantwort nicht einmal ableiten koennen, DASS eine ID in einem anderen
Mandanten existiert.
"""
from __future__ import annotations
import aiosqlite
from fastapi import HTTPException, status
class TenantScope:
def __init__(self, tenant_ids: set[int] | None):
# tenant_ids is None -> Super-Admin, keine Einschraenkung.
# tenant_ids ist Set -> nur diese Mandanten sichtbar/aenderbar
# (leeres Set == gar kein Mandant -> nichts sichtbar).
self.all_tenants = tenant_ids is None
self.tenant_ids: set[int] = set(tenant_ids) if tenant_ids else set()
def sql_filter(self, column: str) -> tuple[str, tuple]:
"""SQL-Fragment (mit fuehrendem ' AND ...') + Parameter-Tuple, das an
eine bestehende WHERE-Klausel angehaengt werden kann."""
if self.all_tenants:
return "", ()
if not self.tenant_ids:
return " AND 0", ()
placeholders = ",".join("?" for _ in self.tenant_ids)
return f" AND {column} IN ({placeholders})", tuple(self.tenant_ids)
def check(self, tenant_id: int | None) -> None:
"""Wirft 404, wenn tenant_id ausserhalb des erlaubten Bereichs liegt."""
if self.all_tenants:
return
if tenant_id is None or tenant_id not in self.tenant_ids:
raise HTTPException(status.HTTP_404_NOT_FOUND, "Nicht gefunden")
def single_tenant_id(self) -> int | None:
"""Fuer Endpunkte, die zwingend GENAU einen Mandanten brauchen (z.B.
beim Anlegen einer neuen Hostgruppe). None bei Super-Admin (muss dann
explizit im Request mitgegeben werden), sonst der einzige erlaubte
Mandant -- oder ein 400/409, falls der Aufrufer mehrere hat (in dieser
Version hat ein Mandanten-Admin praktisch immer genau einen Mandanten,
die Datenstruktur erlaubt aber theoretisch mehrere)."""
if self.all_tenants or len(self.tenant_ids) != 1:
return None
return next(iter(self.tenant_ids))
async def resolve_host_group_tenant(conn: aiosqlite.Connection, host_group_id: int) -> int | None:
cursor = await conn.execute("SELECT tenant_id FROM host_groups WHERE id = ?", (host_group_id,))
row = await cursor.fetchone()
return row[0] if row else None
async def resolve_host_tenant(conn: aiosqlite.Connection, host_id: int) -> int | None:
cursor = await conn.execute(
"SELECT hg.tenant_id FROM hosts h JOIN host_groups hg ON hg.id = h.host_group_id WHERE h.id = ?",
(host_id,),
)
row = await cursor.fetchone()
return row[0] if row else None
async def tenant_user_ids(conn: aiosqlite.Connection, tenant_id: int) -> set[int]:
"""Alle Benutzer, die zu diesem Mandanten gehoeren: Mandanten-Admins,
Benutzer mit home_tenant_id = tenant_id, Mitglieder einer Benutzergruppe
dieses Mandanten, sowie Benutzer mit einer direkten Rollenvergabe auf
einer Hostgruppe dieses Mandanten. Wird sowohl fuer die Benutzer-Liste
einer Mandanten-Admin-Sicht als auch fuer die Audit-Log-Filterung genutzt."""
cursor = await conn.execute(
"""
SELECT user_id FROM tenant_admins WHERE tenant_id = ?
UNION
SELECT id FROM users WHERE home_tenant_id = ?
UNION
SELECT ugm.user_id FROM user_group_members ugm
JOIN user_groups ug ON ug.id = ugm.user_group_id
WHERE ug.tenant_id = ?
UNION
SELECT uhr.user_id FROM user_hostgroup_roles uhr
JOIN host_groups hg ON hg.id = uhr.host_group_id
WHERE hg.tenant_id = ?
""",
(tenant_id, tenant_id, tenant_id, tenant_id),
)
rows = await cursor.fetchall()
return {r[0] for r in rows}