umbau 1.0
This commit is contained in:
@ -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
|
||||
|
||||
|
||||
1146
app/admin/routes.py
1146
app/admin/routes.py
File diff suppressed because it is too large
Load Diff
102
app/auth/deps.py
102
app/auth/deps.py
@ -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)
|
||||
|
||||
@ -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,
|
||||
}
|
||||
|
||||
@ -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,
|
||||
}
|
||||
|
||||
@ -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"
|
||||
|
||||
29
app/db.py
29
app/db.py
@ -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,)
|
||||
)
|
||||
|
||||
21
app/db/migrations/0013_fix_placeholder_rdp_username.sql
Normal file
21
app/db/migrations/0013_fix_placeholder_rdp_username.sql
Normal 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)';
|
||||
196
app/db/migrations/0014_drop_tenants.sql
Normal file
196
app/db/migrations/0014_drop_tenants.sql
Normal 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;
|
||||
34
app/db/migrations/0015_remove_dead_roles.sql
Normal file
34
app/db/migrations/0015_remove_dead_roles.sql
Normal 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');
|
||||
49
app/db/migrations/0016_ssh_password_credential_objects.sql
Normal file
49
app/db/migrations/0016_ssh_password_credential_objects.sql
Normal 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;
|
||||
64
app/db/migrations/0017_personal_groups.sql
Normal file
64
app/db/migrations/0017_personal_groups.sql
Normal 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;
|
||||
91
app/db/migrations/0018_credential_group_grants.sql
Normal file
91
app/db/migrations/0018_credential_group_grants.sql
Normal 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';
|
||||
26
app/db/migrations/0019_retire_direct_role_grants.sql
Normal file
26
app/db/migrations/0019_retire_direct_role_grants.sql
Normal 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;
|
||||
22
app/db/migrations/0020_session_user_ended_index.sql
Normal file
22
app/db/migrations/0020_session_user_ended_index.sql
Normal 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);
|
||||
77
app/main.py
77
app/main.py
@ -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 --------------------------------------------------------
|
||||
|
||||
@ -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):
|
||||
|
||||
171
app/rbac.py
171
app/rbac.py
@ -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]
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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))
|
||||
|
||||
63
app/recordings/recorder.py.bak_1788208152
Normal file
63
app/recordings/recorder.py.bak_1788208152
Normal 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
|
||||
@ -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 --
|
||||
|
||||
@ -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]:
|
||||
|
||||
@ -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,
|
||||
)
|
||||
|
||||
83
app/security/session_reaper.py
Normal file
83
app/security/session_reaper.py
Normal 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)
|
||||
@ -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:
|
||||
|
||||
@ -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}"'},
|
||||
)
|
||||
|
||||
@ -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 = ? "
|
||||
|
||||
@ -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}
|
||||
Reference in New Issue
Block a user