more admin stuff 2

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

View File

@ -21,13 +21,17 @@ Privilege-Escalation-Schutz).
from __future__ import annotations
import json
import logging
from pathlib import Path
from fastapi import APIRouter, Depends, HTTPException, Request, status
from fastapi import APIRouter, Depends, HTTPException, Request, WebSocket, WebSocketDisconnect, status
from app.auth.deps import (
CurrentUser,
effective_tenant_ids,
get_current_user_ws,
require_admin_or_scope,
require_admin_scope_or_host_role,
require_admin_session,
require_global_admin,
)
@ -54,6 +58,7 @@ from app.models.schemas import (
UserGroupUpdateRequest,
UserUpdateRequest,
)
from app.security import active_sessions, log_stream
from app.security.api_tokens import (
VALID_SCOPES,
generate_token,
@ -64,9 +69,12 @@ from app.security.api_tokens import (
from app.security.audit import verify_chain, write_audit_event
from app.security.crypto import encrypt_secret
from app.security.passwords import hash_password
from app.ssh_proxy.proxy import discover_and_store_host_key
from app.recordings.recorder import verify_recording
from app.ssh_proxy.proxy import HostKeyDiscoveryError, HostNotConfiguredError, discover_and_store_host_key
from app.tenancy import TenantScope, resolve_host_group_tenant, resolve_host_tenant, tenant_user_ids
logger = logging.getLogger("jumphost.admin")
router = APIRouter(prefix="/admin", tags=["admin"])
@ -900,10 +908,23 @@ async def discover_host_key(
"""ACHTUNG: Verbindet einmalig OHNE Host-Key-Pruefung, um den Fingerprint zu
erfassen (bewusste Trust-Entscheidung, siehe Konzept 4.2). Danach gilt fuer
alle regulaeren Verbindungen wieder striktes Pinning. Jeder Aufruf wird
prominent im Audit-Log vermerkt."""
prominent im Audit-Log vermerkt.
Bugfix: discover_and_store_host_key() konnte bisher ein unbehandeltes
asyncssh/OSError durchreichen -> FastAPI antwortete mit 500 statt einer
verwertbaren Fehlermeldung (siehe app/ssh_proxy/proxy.py). Jetzt sauber
auf 502 (Verbindung fehlgeschlagen) bzw. 400 (kein SSH-Host) gemappt."""
conn = get_db()
await _assert_host_in_scope(conn, _scope(admin), host_id)
fingerprint = await discover_and_store_host_key(conn, host_id, admin_user_id=admin.id)
try:
fingerprint = await discover_and_store_host_key(conn, host_id, admin_user_id=admin.id)
except HostNotConfiguredError as exc:
raise HTTPException(status.HTTP_400_BAD_REQUEST, str(exc)) from exc
except HostKeyDiscoveryError as exc:
raise HTTPException(
status.HTTP_502_BAD_GATEWAY,
f"Host-Key konnte nicht ermittelt werden -- Ziel nicht erreichbar: {exc.reason}",
) from exc
await write_audit_event(
conn, event_type="host_key_discovered_trust_decision", user_id=admin.id,
client_ip=_client_ip(request), details={"host_id": host_id, "fingerprint": fingerprint},
@ -915,12 +936,18 @@ async def discover_host_key(
@router.put("/hosts/{host_id}/rdp-credentials")
async def set_rdp_credentials(
host_id: int, payload: RdpCredentialsRequest, request: Request,
admin: CurrentUser = Depends(require_admin_or_scope("hosts", "write")),
admin: CurrentUser = Depends(
require_admin_scope_or_host_role("hosts", "write", ("credentials_manage",))
),
):
"""Speichert/rotiert das RDP-Passwort fuer einen Host, verschluesselt mit
dem KEK (eigener AAD-Kontext, siehe app/security/crypto.py)."""
dem KEK (eigener AAD-Kontext, siehe app/security/crypto.py). Zugriff:
Admin/Mandanten-Admin/Token ODER ein Nicht-Admin mit Rolle
'credentials_manage' auf der Hostgruppe dieses Hosts (siehe RBAC-
Erweiterung 'Credentials ins RBAC-Modell')."""
conn = get_db()
await _assert_host_in_scope(conn, _scope(admin), host_id)
if admin.is_any_admin:
await _assert_host_in_scope(conn, _scope(admin), host_id)
encrypted = encrypt_secret(payload.password.encode(), associated_data=b"rdp_password")
await conn.execute(
"INSERT INTO rdp_credentials (host_id, password_enc, updated_at) "
@ -940,10 +967,13 @@ async def set_rdp_credentials(
@router.delete("/hosts/{host_id}/rdp-credentials")
async def delete_rdp_credentials(
host_id: int, request: Request,
admin: CurrentUser = Depends(require_admin_or_scope("hosts", "write")),
admin: CurrentUser = Depends(
require_admin_scope_or_host_role("hosts", "write", ("credentials_manage",))
),
):
conn = get_db()
await _assert_host_in_scope(conn, _scope(admin), host_id)
if admin.is_any_admin:
await _assert_host_in_scope(conn, _scope(admin), host_id)
await conn.execute("DELETE FROM rdp_credentials WHERE host_id = ?", (host_id,))
await write_audit_event(
conn, event_type="rdp_credentials_deleted", user_id=admin.id, client_ip=_client_ip(request),
@ -953,6 +983,42 @@ async def delete_rdp_credentials(
return {"status": "ok"}
@router.get("/hosts/{host_id}/credentials")
async def get_host_credentials(
host_id: int,
admin: CurrentUser = Depends(
require_admin_scope_or_host_role("hosts", "read", ("credentials_view", "credentials_manage"))
),
):
"""Zugangsdaten-Uebersicht fuer EINEN Host (zugeordnete SSH-Keys, ob ein
RDP-Passwort gesetzt ist -- niemals der Klartext selbst, siehe Konzept
6.4). Im Unterschied zu GET /admin/hosts/{id} (voller Datensatz,
admin-only) ist dieser schlanke Endpunkt bewusst auch fuer Nicht-Admins
mit Rolle 'credentials_view'/'credentials_manage' erreichbar (RBAC-
Erweiterung 'Credentials ins RBAC-Modell')."""
conn = get_db()
if admin.is_any_admin:
await _assert_host_in_scope(conn, _scope(admin), host_id)
else:
host_row = await (await conn.execute("SELECT 1 FROM hosts WHERE id = ?", (host_id,))).fetchone()
if host_row is None:
raise HTTPException(status.HTTP_404_NOT_FOUND, "Host nicht gefunden")
keys_cursor = await conn.execute(
"SELECT sk.id, sk.label FROM host_ssh_key_map m JOIN ssh_keys sk ON sk.id = m.ssh_key_id "
"WHERE m.host_id = ?",
(host_id,),
)
ssh_keys = [{"id": k[0], "label": k[1]} for k in await keys_cursor.fetchall()]
rdp_row = await (await conn.execute(
"SELECT updated_at FROM rdp_credentials WHERE host_id = ?", (host_id,)
)).fetchone()
return {
"host_id": host_id, "ssh_keys": ssh_keys,
"rdp_credentials_set": rdp_row is not None,
"rdp_credentials_updated_at": rdp_row[0] if rdp_row else None,
}
@router.get("/rdp-credentials")
async def list_rdp_credentials(admin: CurrentUser = Depends(require_admin_or_scope("hosts", "read"))):
"""Uebersicht aller RDP/Windows-Hosts fuer den 'Zugangsdaten'-Tab: welche
@ -1281,12 +1347,19 @@ async def delete_ssh_key(
@router.post("/hosts/{host_id}/ssh-keys/{key_id}")
async def map_ssh_key_to_host(
host_id: int, key_id: int, request: Request,
admin: CurrentUser = Depends(require_admin_or_scope("ssh_keys", "write")),
admin: CurrentUser = Depends(
require_admin_scope_or_host_role("ssh_keys", "write", ("credentials_manage",))
),
):
conn = get_db()
scope = _scope(admin)
await _assert_host_in_scope(conn, scope, host_id)
await _assert_ssh_key_in_scope(conn, scope, key_id)
if admin.is_any_admin:
scope = _scope(admin)
await _assert_host_in_scope(conn, scope, host_id)
await _assert_ssh_key_in_scope(conn, scope, key_id)
else:
key_row = await (await conn.execute("SELECT 1 FROM ssh_keys WHERE id = ?", (key_id,))).fetchone()
if key_row is None:
raise HTTPException(status.HTTP_404_NOT_FOUND, "SSH-Key nicht gefunden")
await conn.execute(
"INSERT OR IGNORE INTO host_ssh_key_map (host_id, ssh_key_id) VALUES (?, ?)",
(host_id, key_id),
@ -1302,11 +1375,13 @@ async def map_ssh_key_to_host(
@router.delete("/hosts/{host_id}/ssh-keys/{key_id}")
async def unmap_ssh_key_from_host(
host_id: int, key_id: int, request: Request,
admin: CurrentUser = Depends(require_admin_or_scope("ssh_keys", "write")),
admin: CurrentUser = Depends(
require_admin_scope_or_host_role("ssh_keys", "write", ("credentials_manage",))
),
):
conn = get_db()
scope = _scope(admin)
await _assert_host_in_scope(conn, scope, host_id)
if admin.is_any_admin:
await _assert_host_in_scope(conn, _scope(admin), host_id)
await conn.execute(
"DELETE FROM host_ssh_key_map WHERE host_id = ? AND ssh_key_id = ?", (host_id, key_id)
)
@ -1463,3 +1538,130 @@ async def verify_audit_log(admin: CurrentUser = Depends(require_admin_or_scope("
conn = get_db()
intact, broken_at = await verify_chain(conn)
return {"intact": intact, "first_broken_id": broken_at}
# --- Sessionview (nur Super-Admin) --------------------------------------------
#
# Zeigt ALLE Sitzungen ueber alle Mandanten hinweg -- bewusst require_global_
# admin (kein Mandanten-Admin, kein Token), da Sitzungsdaten (Client-IP,
# genutzter Host) ueber Mandantengrenzen hinweg sichtbar waeren. 'Beenden'
# nutzt app/security/active_sessions.py (siehe dort fuer die Prozess-lokale
# Registry und die genaue Beenden-Semantik per asyncio.Task.cancel()).
@router.get("/sessions")
async def list_sessions(
active_only: bool = False, limit: int = 200, offset: int = 0,
admin: CurrentUser = Depends(require_global_admin),
):
limit = max(1, min(limit, 1000))
conn = get_db()
where = "WHERE s.ended_at IS NULL" if active_only else ""
cursor = await conn.execute(
f"""
SELECT s.id, s.user_id, u.username, s.host_id, h.hostname, hg.name,
s.protocol, s.started_at, s.ended_at, s.client_ip, s.end_reason,
s.recording_path
FROM sessions s
JOIN users u ON u.id = s.user_id
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 LIMIT ? OFFSET ?
""",
(limit, offset),
)
rows = await cursor.fetchall()
active_ids = active_sessions.all_ids()
result = []
for r in rows:
recording_path = r[11]
result.append({
"id": r[0], "user_id": r[1], "username": r[2], "host_id": r[3],
"hostname": r[4], "host_group_name": r[5], "protocol": r[6],
"started_at": r[7], "ended_at": r[8], "client_ip": r[9], "end_reason": r[10],
"is_active": r[8] is None,
"killable": r[0] in active_ids,
"has_recording": bool(recording_path) and Path(recording_path).exists(),
})
return result
@router.post("/sessions/{session_id}/terminate")
async def terminate_session(
session_id: int, request: Request, admin: CurrentUser = Depends(require_global_admin),
):
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:
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:
raise HTTPException(
status.HTTP_409_CONFLICT,
"Sitzung laeuft nicht auf diesem Server-Prozess (evtl. anderer Worker) "
"und kann von hier aus nicht beendet werden",
)
entry.task.cancel()
await write_audit_event(
conn, event_type="session_terminated_by_admin", user_id=admin.id, client_ip=_client_ip(request),
details={"session_id": session_id, "target_user_id": row[1], "host_id": row[2]},
)
await conn.commit()
return {"status": "ok"}
@router.get("/sessions/{session_id}/recording")
async def get_session_recording(session_id: int, admin: CurrentUser = Depends(require_global_admin)):
"""Prueft und liefert die Hash-verkettete Sitzungsaufzeichnung (siehe
app/recordings/recorder.py) -- bewusst nur Metadaten + Integritaetsstatus,
kein vollstaendiger Tastatur-/Bildschirm-Dump in der Response (Konzept
6.5: Aufzeichnungen sind hochsensibel)."""
conn = get_db()
row = await (await conn.execute(
"SELECT recording_path FROM sessions WHERE id = ?", (session_id,)
)).fetchone()
if row is None or not row[0]:
raise HTTPException(status.HTTP_404_NOT_FOUND, "Keine Aufzeichnung fuer diese Sitzung vorhanden")
path = Path(row[0])
if not path.exists():
raise HTTPException(status.HTTP_404_NOT_FOUND, "Aufzeichnungsdatei nicht (mehr) vorhanden")
try:
verified = verify_recording(path)
except Exception:
logger.exception("Aufzeichnung %s konnte nicht gelesen/verifiziert werden", session_id)
raise HTTPException(status.HTTP_500_INTERNAL_SERVER_ERROR, "Aufzeichnung konnte nicht gelesen werden")
with open(path, encoding="utf-8") as fh:
entry_count = sum(1 for line in fh if line.strip())
return {"session_id": session_id, "verified": verified, "entry_count": entry_count}
# --- Verbindungslog (nur Super-Admin, Live-Tail) ------------------------------
#
# 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.
@router.websocket("/ws/logs")
async def stream_logs(websocket: WebSocket):
user = await get_current_user_ws(websocket)
if user is None or not user.is_admin:
await websocket.close(code=4403)
return
await websocket.accept()
queue = log_stream.subscribe()
try:
for line in log_stream.recent_lines():
await websocket.send_json({"type": "line", "line": line})
while True:
line = await queue.get()
await websocket.send_json({"type": "line", "line": line})
except WebSocketDisconnect:
pass
finally:
log_stream.unsubscribe(queue)

View File

@ -204,6 +204,43 @@ async def _validate_api_token(token: str, *, resource: str, action: str) -> Curr
return CurrentUser(id=user_id, username=username, is_admin=False, token_tenant_id=tenant_id)
def require_admin_scope_or_host_role(resource: str, action: str, role_names: tuple[str, ...]):
"""Wie require_admin_or_scope, erlaubt zusaetzlich einen regulaeren
(Nicht-Admin-)User per Session, wenn er fuer den per Pfadparameter
'host_id' angegebenen Host mindestens eine der uebergebenen Hostgruppen-
Rollen besitzt (siehe RBAC-Erweiterung 'Credentials ins RBAC-Modell':
Rollen 'credentials_view'/'credentials_manage', app/models/schemas.py).
Token-Auth bleibt unveraendert ausschliesslich ueber den Admin-Scope
moeglich -- kein Token traegt je eine Hostgruppen-Rolle."""
async def _dep(
host_id: int,
response: Response,
jh_session: str | None = Cookie(default=None, alias=SESSION_COOKIE_NAME),
authorization: str | None = Header(default=None),
) -> CurrentUser:
if authorization and authorization.startswith("Bearer "):
token = authorization.removeprefix("Bearer ").strip()
principal = await _validate_api_token(token, resource=resource, action=action)
if principal is None:
raise HTTPException(
status.HTTP_401_UNAUTHORIZED,
"Ungueltiges, abgelaufenes oder nicht ausreichend berechtigtes Token",
)
return principal
user = await get_current_user(response, jh_session)
if user.is_any_admin:
return user
conn = get_db()
for role_name in role_names:
if await user_has_role_for_host(conn, user_id=user.id, host_id=host_id, role_name=role_name):
return user
raise HTTPException(status.HTTP_403_FORBIDDEN, "Keine Berechtigung fuer diese Zugangsdaten")
return _dep
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

View File

@ -30,6 +30,7 @@ async def my_hosts(user: CurrentUser = Depends(get_current_user)):
for h in hosts:
h["can_connect"] = True
h["can_file_transfer"] = True
h["can_view_credentials"] = True
return hosts
cursor = await conn.execute(
@ -82,7 +83,26 @@ async def my_hosts(user: CurrentUser = Depends(get_current_user)):
(user.id, user.id),
)
ft_groups = {row[0] for row in await ft_cursor.fetchall()}
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),
)
cred_groups = {row[0] for row in await cred_cursor.fetchall()}
for h in hosts:
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

View File

@ -0,0 +1,19 @@
-- RBAC-Erweiterung "Credentials ins RBAC-Modell aufnehmen": zwei neue Rollen,
-- die -- wie ssh_connect/rdp_connect/file_transfer/clipboard -- pro
-- Hostgruppe an einzelne User oder Benutzergruppen vergeben werden koennen
-- (user_hostgroup_roles / group_hostgroup_roles, unveraendert). Damit koennen
-- auch Nicht-Admins gezielt Zugriff auf Zugangsdaten (SSH-Keys, RDP-Passwoerter)
-- der Hosts "ihrer" Hostgruppe bekommen, ohne globaler oder Mandanten-Admin
-- sein zu muessen. Durchsetzung: app/auth/deps.py::require_admin_scope_or_host_role,
-- verwendet in app/admin/routes.py fuer die RDP-Credential- und SSH-Key-
-- Zuordnungs-Endpunkte sowie den neuen GET /admin/hosts/{id}/credentials.
--
-- credentials_view: darf sehen, OB/wann ein RDP-Passwort gesetzt ist und
-- welche SSH-Keys einem Host zugeordnet sind (NIE den
-- Klartext selbst -- der wird ueber KEINEN Endpunkt
-- jemals im Klartext zurueckgegeben, siehe Konzept 6.4).
-- credentials_manage: wie credentials_view, zusaetzlich RDP-Passwort setzen/
-- loeschen und SSH-Keys einem Host zuordnen/entfernen.
INSERT OR IGNORE INTO roles (id, name) VALUES
(7, 'credentials_view'),
(8, 'credentials_manage');

View File

@ -19,6 +19,7 @@ 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.rdp_proxy.ws_tunnel import router as rdp_ws_router
from app.security import log_stream
from app.ssh_proxy.sftp import router as sftp_router
from app.ssh_proxy.terminal_ws import router as ssh_ws_router
@ -27,6 +28,10 @@ logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(name
@asynccontextmanager
async def lifespan(app: FastAPI):
# Ring-Buffer-Handler fuers Live-'Verbindungslog' (Admin-only, siehe
# app/security/log_stream.py) -- MUSS vor init_db() installiert werden,
# damit auch fruehe Startmeldungen im Puffer landen.
log_stream.install()
await init_db()
yield
await close_db()

View File

@ -18,6 +18,13 @@ 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",
# 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-
# Passwoerter) der Hosts dieser Gruppe, siehe app/auth/deps.py::
# require_admin_scope_or_host_role und admin/routes.py (rdp-credentials,
# ssh-keys-Zuordnung, GET /admin/hosts/{id}/credentials).
"credentials_view", "credentials_manage",
]
@ -146,7 +153,7 @@ class HostUpdateRequest(BaseModel):
class RoleGrantRequest(BaseModel):
user_id: int
host_group_id: int
role_names: list[ROLE_NAME] = Field(min_length=1, max_length=6)
role_names: list[ROLE_NAME] = Field(min_length=1, max_length=8)
expires_at: str | None = None
@ -174,7 +181,7 @@ class GroupMemberRequest(BaseModel):
class GroupRoleGrantRequest(BaseModel):
user_group_id: int
host_group_id: int
role_names: list[ROLE_NAME] = Field(min_length=1, max_length=6)
role_names: list[ROLE_NAME] = Field(min_length=1, max_length=8)
expires_at: str | None = None

View File

@ -19,6 +19,7 @@ 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
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 (
@ -115,6 +116,11 @@ async def rdp_tunnel(
details={"host_id": host_id, "hostname": host["hostname"], "session_id": session_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,
)
active_sessions.register(session_id, asyncio.current_task())
end_reason = "logout"
tunnel = None
@ -125,6 +131,7 @@ async def rdp_tunnel(
guacd_host=settings.guacd_host, guacd_port=settings.guacd_port,
protocol="rdp", params=params, screen_width=width, screen_height=height, dpi=dpi,
)
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)),
@ -144,13 +151,20 @@ async def rdp_tunnel(
except (GuacamoleProtocolError, ConnectionError, OSError) as exc:
logger.warning("RDP-Sessionfehler (session_id=%s): %s", session_id, exc)
end_reason = "error"
except asyncio.CancelledError:
# Zwangs-Beendigung durch einen Superadmin ueber die Sessionview
# (POST /admin/sessions/{id}/terminate, siehe app/security/active_sessions.py).
end_reason = "terminated_by_admin"
raise
finally:
active_sessions.unregister(session_id)
del password # Klartext-Passwort so schnell wie moeglich freigeben
for task in tasks:
task.cancel()
if tunnel:
await tunnel.close()
recorder.close()
logger.debug("RDP-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 = ? "
"WHERE id = ?",

View File

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

View File

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

View File

@ -30,6 +30,20 @@ class HostKeyMismatchError(Exception):
super().__init__(f"Host-Key-Mismatch: erwartet={expected!r} beobachtet={observed!r}")
class HostKeyDiscoveryError(Exception):
"""Wird geworfen, wenn beim Host-Key-Discovery-Versuch (siehe
discover_and_store_host_key) ueberhaupt KEIN Fingerprint beobachtet
werden konnte (TCP/DNS/Timeout-Fehler VOR dem SSH-Key-Exchange) -- im
Unterschied zu einem erwarteten Auth-Fehler NACH dem KEX (siehe dort).
Der admin-only Endpunkt (admin/routes.py) faengt dies ab und liefert
eine saubere 502 statt eines unbehandelten 500."""
def __init__(self, host_id: int, reason: str) -> None:
self.host_id = host_id
self.reason = reason
super().__init__(f"Host-Key-Ermittlung fuer Host {host_id} fehlgeschlagen: {reason}")
class _PinnedHostKeyClient(asyncssh.SSHClient):
"""Erzwingt Strict Host Key Checking gegen einen fest hinterlegten
SHA-256-Fingerprint. Kein automatisches Trust-on-First-Use (TOFU)."""
@ -114,14 +128,45 @@ async def discover_and_store_host_key(
"""Verbindet EINMALIG ohne Pinning, um den Host-Key-Fingerprint zu erfassen
und in der DB zu hinterlegen. Nur ueber einen dedizierten, admin-only
Endpunkt aufrufbar -- jeder Aufruf ist eine bewusste Vertrauensentscheidung
und wird im Audit-Log als solche vermerkt (siehe admin/routes.py)."""
und wird im Audit-Log als solche vermerkt (siehe admin/routes.py).
Bugfix (Fehler 500 bei 'Host-Key ermitteln'): asyncssh.connect() fuehrt
nach dem Key-Exchange (bei dem validate_host_public_key() den Fingerprint
bereits erfasst) automatisch die Authentifizierung durch -- ohne
hinterlegten Client-Key/Passwort schlaegt die IMMER fehl (asyncssh.Error,
z.B. PermissionDenied), obwohl der Fingerprint laengst bekannt ist. Diese
fuer die reine Discovery irrelevante Auth-Fehlermeldung wurde bisher NICHT
abgefangen und riss als unbehandelte Exception bis zu FastAPI durch ->
500 Internal Server Error bei praktisch jedem Aufruf. Fix: Verbindungs-
fehler abfangen; wenn dabei bereits ein Fingerprint beobachtet wurde, gilt
die Discovery als erfolgreich. Nur wenn wirklich KEIN Fingerprint erfasst
wurde (Host nicht erreichbar, DNS-Fehler, Timeout -- also ein Fehler VOR
dem Key-Exchange), ist es ein echter Fehler (HostKeyDiscoveryError)."""
host = await load_host(conn, host_id)
if host["protocol"] != "ssh":
raise HostNotConfiguredError("Host-Key-Ermittlung ist nur fuer SSH-Ziele moeglich")
client = _PinnedHostKeyClient(None, discovery_mode=True)
connection = await asyncssh.connect(
host["address"], port=host["port"], username=host["ssh_username"],
known_hosts=None, client_factory=lambda: client, connect_timeout=10,
)
connection.close()
connection = None
try:
connection = await asyncssh.connect(
host["address"], port=host["port"], username=host["ssh_username"],
known_hosts=None, client_factory=lambda: client, connect_timeout=10,
)
except (asyncssh.Error, OSError) as exc:
if client.observed_fingerprint is None:
logger.warning("Host-Key-Ermittlung fuer Host %s fehlgeschlagen: %s", host_id, exc)
raise HostKeyDiscoveryError(host_id, str(exc)) from exc
# Fingerprint wurde bereits waehrend des Key-Exchange erfasst -- der
# anschliessende Auth-Fehler ist fuer die Discovery unschaedlich.
logger.info(
"Host-Key fuer Host %s erfasst (Auth-Phase erwartungsgemaess fehlgeschlagen: %s)",
host_id, exc,
)
finally:
if connection is not None:
connection.close()
fingerprint = client.observed_fingerprint
await conn.execute(
"UPDATE hosts SET ssh_host_key_fingerprint = ? WHERE id = ?", (fingerprint, host_id)

View File

@ -21,6 +21,7 @@ from app.auth.deps import get_current_user_ws
from app.db import get_db
from app.rbac import 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.ssh_proxy.proxy import HostNotConfiguredError, connect_to_host, load_host
@ -83,6 +84,11 @@ async def ssh_terminal(websocket: WebSocket, host_id: int):
details={"host_id": host_id, "hostname": host["hostname"], "session_id": session_id},
)
await conn.commit()
logger.debug(
"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())
end_reason = "logout"
ssh_conn = None
@ -90,6 +96,7 @@ async def ssh_terminal(websocket: WebSocket, host_id: int):
pump_task = None
try:
ssh_conn = await connect_to_host(conn, host_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))
@ -120,7 +127,13 @@ async def ssh_terminal(websocket: WebSocket, host_id: int):
logger.debug("Fehlermeldung konnte nicht mehr an Client gesendet werden", exc_info=True)
except HostNotConfiguredError:
end_reason = "error"
except asyncio.CancelledError:
# Zwangs-Beendigung durch einen Superadmin ueber die Sessionview
# (POST /admin/sessions/{id}/terminate, siehe app/security/active_sessions.py).
end_reason = "terminated_by_admin"
raise
finally:
active_sessions.unregister(session_id)
if pump_task:
pump_task.cancel()
if process:
@ -128,6 +141,7 @@ async def ssh_terminal(websocket: WebSocket, host_id: int):
if ssh_conn:
ssh_conn.close()
recorder.close()
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 = ? "
"WHERE id = ?",