diff --git a/app/admin/routes.py b/app/admin/routes.py index d517799..cd44cf4 100644 --- a/app/admin/routes.py +++ b/app/admin/routes.py @@ -48,7 +48,9 @@ from app.models.schemas import ( RoleGrantRequest, RoleRevokeRequest, SshKeyCreateRequest, + SshKeyGenerateRequest, SshKeyUpdateRequest, + SshPasswordCredentialsRequest, TenantAdminAssignRequest, TenantCreateRequest, TenantUpdateRequest, @@ -74,6 +76,7 @@ from app.ssh_proxy.proxy import ( HostNotConfiguredError, PrivateKeyUnusableError, discover_and_store_host_key, + generate_key_material, import_private_key_material, ) from app.tenancy import TenantScope, resolve_host_group_tenant, resolve_host_tenant, tenant_user_ids @@ -819,6 +822,9 @@ async def get_host_detail( rdp_row = await (await conn.execute( "SELECT updated_at, username, domain FROM rdp_credentials WHERE host_id = ?", (host_id,) )).fetchone() + ssh_pw_row = await (await conn.execute( + "SELECT updated_at, username FROM ssh_password_credentials WHERE host_id = ?", (host_id,) + )).fetchone() return { "id": row[0], "hostname": row[1], "address": row[2], "protocol": row[3], "port": row[4], "os_type": row[5], "host_group_id": row[6], "ssh_host_key_fingerprint": row[7], @@ -833,6 +839,10 @@ async def get_host_detail( # Zugangsdaten; sie werden hier nur zur Anzeige mitgeliefert. "rdp_credentials_username": rdp_row[1] if rdp_row else None, "rdp_credentials_domain": rdp_row[2] if rdp_row else None, + # SSH-Passwort als Alternative zum Schluessel (Migration 0011). + "ssh_password_credentials_set": ssh_pw_row is not None, + "ssh_password_credentials_updated_at": ssh_pw_row[0] if ssh_pw_row else None, + "ssh_password_credentials_username": ssh_pw_row[1] if ssh_pw_row else None, } @@ -1005,6 +1015,59 @@ async def delete_rdp_credentials( return {"status": "ok"} +@router.put("/hosts/{host_id}/ssh-password") +async def set_ssh_password_credentials( + host_id: int, payload: SshPasswordCredentialsRequest, request: Request, + admin: CurrentUser = Depends( + require_admin_scope_or_host_role("hosts", "write", ("credentials_manage",)) + ), +): + """Setzt/aendert das SSH-Passwort fuer einen Host (Migration 0011, + Alternative zum SSH-Key -- 'Linux kann statt SSH-Key auch Passwort + haben'). Wird beim Verbindungsaufbau nur beruecksichtigt, solange dem + Host KEIN SSH-Key zugeordnet ist (siehe + app/ssh_proxy/proxy.py::connect_to_host).""" + conn = get_db() + if admin.is_any_admin: + await _assert_host_in_scope(conn, _scope(admin), host_id) + username = payload.username.strip() + if not username: + raise HTTPException(status.HTTP_400_BAD_REQUEST, "Benutzername darf nicht leer sein") + encrypted = encrypt_secret(payload.password.encode(), associated_data=b"ssh_password") + await conn.execute( + "INSERT INTO ssh_password_credentials (host_id, username, password_enc, updated_at) " + "VALUES (?, ?, ?, strftime('%Y-%m-%dT%H:%M:%fZ','now')) " + "ON CONFLICT(host_id) DO UPDATE SET username = excluded.username, " + "password_enc = excluded.password_enc, updated_at = excluded.updated_at", + (host_id, username, encrypted), + ) + await write_audit_event( + conn, event_type="ssh_password_credentials_set", user_id=admin.id, client_ip=_client_ip(request), + details={"host_id": host_id, "username": username}, + ) + await conn.commit() + return {"status": "ok"} + + +@router.delete("/hosts/{host_id}/ssh-password") +async def delete_ssh_password_credentials( + host_id: int, request: Request, + admin: CurrentUser = Depends( + require_admin_scope_or_host_role("hosts", "write", ("credentials_manage",)) + ), +): + conn = get_db() + if admin.is_any_admin: + await _assert_host_in_scope(conn, _scope(admin), host_id) + await conn.execute("DELETE FROM ssh_password_credentials WHERE host_id = ?", (host_id,)) + await write_audit_event( + conn, event_type="ssh_password_credentials_deleted", user_id=admin.id, client_ip=_client_ip(request), + details={"host_id": host_id}, + ) + await conn.commit() + return {"status": "ok"} + + @router.get("/hosts/{host_id}/credentials") async def get_host_credentials( host_id: int, @@ -1034,12 +1097,18 @@ async def get_host_credentials( rdp_row = await (await conn.execute( "SELECT updated_at, username, domain FROM rdp_credentials WHERE host_id = ?", (host_id,) )).fetchone() + ssh_pw_row = await (await conn.execute( + "SELECT updated_at, username FROM ssh_password_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, "rdp_credentials_username": rdp_row[1] if rdp_row else None, "rdp_credentials_domain": rdp_row[2] if rdp_row else None, + "ssh_password_credentials_set": ssh_pw_row is not None, + "ssh_password_credentials_updated_at": ssh_pw_row[0] if ssh_pw_row else None, + "ssh_password_credentials_username": ssh_pw_row[1] if ssh_pw_row else None, } @@ -1291,9 +1360,9 @@ async def create_ssh_key( else None ) cursor = await conn.execute( - "INSERT INTO ssh_keys (label, owner_user_id, private_key_enc, public_key, key_type, " - "tenant_id, passphrase_enc, username) VALUES (?, ?, ?, ?, ?, ?, ?, ?)", - (payload.label, payload.owner_user_id, encrypted, payload.public_key, payload.key_type, + "INSERT INTO ssh_keys (label, private_key_enc, public_key, key_type, " + "tenant_id, passphrase_enc, username) VALUES (?, ?, ?, ?, ?, ?, ?)", + (payload.label, encrypted, payload.public_key, payload.key_type, tenant_id, passphrase_enc, (payload.username or "").strip() or None), ) new_id = cursor.lastrowid @@ -1311,13 +1380,30 @@ async def create_ssh_key( return {"id": new_id, "label": payload.label} +@router.post("/ssh-keys/generate") +async def generate_ssh_key( + payload: SshKeyGenerateRequest, + admin: CurrentUser = Depends(require_admin_or_scope("ssh_keys", "write")), +): + """Erzeugt ein neues Schluesselpaar serverseitig ('Schluessel automatisch + generieren'-Button) und gibt es EINMALIG zurueck -- es wird hier nichts + gespeichert, das passiert erst ueber den regulaeren POST /ssh-keys, wenn + der Admin das befuellte Formular tatsaechlich absendet (siehe + app/ssh_proxy/proxy.py::generate_key_material).""" + try: + private_key_pem, public_key = generate_key_material(payload.key_type) + except ValueError as exc: + raise HTTPException(status.HTTP_400_BAD_REQUEST, str(exc)) from exc + return {"key_type": payload.key_type, "private_key_pem": private_key_pem, "public_key": public_key} + + @router.get("/ssh-keys") async def list_ssh_keys(admin: CurrentUser = Depends(require_admin_or_scope("ssh_keys", "read"))): conn = get_db() scope = _scope(admin) tenant_filter, params = scope.sql_filter("sk.tenant_id") cursor = await conn.execute( - "SELECT sk.id, sk.label, sk.owner_user_id, sk.key_type, sk.created_at, sk.rotated_at, " + "SELECT sk.id, sk.label, sk.key_type, sk.created_at, sk.rotated_at, " "sk.expires_at, sk.tenant_id, t.name, (sk.passphrase_enc IS NOT NULL), sk.username " "FROM ssh_keys sk JOIN tenants t ON t.id = sk.tenant_id " f"WHERE 1=1{tenant_filter} ORDER BY sk.id", @@ -1326,15 +1412,15 @@ async def list_ssh_keys(admin: CurrentUser = Depends(require_admin_or_scope("ssh rows = await cursor.fetchall() return [ { - "id": r[0], "label": r[1], "owner_user_id": r[2], "key_type": r[3], - "created_at": r[4], "rotated_at": r[5], "expires_at": r[6], - "tenant_id": r[7], "tenant_name": r[8], + "id": r[0], "label": r[1], "key_type": r[2], + "created_at": r[3], "rotated_at": r[4], "expires_at": r[5], + "tenant_id": r[6], "tenant_name": r[7], # Nur die Tatsache, NIE die Passphrase selbst -- kein Endpunkt # dieser Anwendung gibt jemals Klartext-Geheimnisse zurueck. - "has_passphrase": bool(r[9]), + "has_passphrase": bool(r[8]), # Anmeldename des Zielsystems (Migration 0010). Kein Geheimnis -- # er wird angezeigt, damit erkennbar ist, welcher Zugang das ist. - "username": r[10], + "username": r[9], } for r in rows ] @@ -1393,8 +1479,6 @@ async def update_ssh_key( fields, values = [], [] if payload.label is not None: fields.append("label = ?"); values.append(payload.label) - if "owner_user_id" in payload.model_fields_set: - fields.append("owner_user_id = ?"); values.append(payload.owner_user_id) if "username" in payload.model_fields_set: # Gleiche Semantik wie bei passphrase: Weglassen = unveraendert, # explizites null/"" = entfernen. @@ -1749,6 +1833,58 @@ async def get_session_recording(session_id: int, admin: CurrentUser = Depends(re return {"session_id": session_id, "verified": verified, "entry_count": entry_count} +@router.get("/sessions/{session_id}/recording/entries") +async def get_session_recording_entries( + session_id: int, request: Request, admin: CurrentUser = Depends(require_global_admin) +): + """Liefert den VOLLEN Inhalt einer Sitzungsaufzeichnung fuer die + Wiedergabe im Adminbereich (SSH: Terminal-Replay ueber xterm.js; RDP: + grafische Wiedergabe ueber Guacamole.SessionRecording -- siehe + static/js/admin.js). Im Unterschied zu GET .../recording (nur Metadaten + + Integritaetsstatus) verlaesst hier der tatsaechliche Sitzungsinhalt + (Tastatureingaben bzw. Bildschirminhalt) den Server -- deshalb bewusst + NUR require_global_admin (echter Super-Admin, ausschliesslich per + Session, wie die uebrige Sessionview) UND ein eigener, prominenter + Audit-Log-Eintrag bei jedem Aufruf (Konzept 6.5: Aufzeichnungen sind + hochsensibel -- dieselbe bewusste 'wird jede Nutzung vermerkt'-Haltung + wie bei 'Host-Key ermitteln').""" + conn = get_db() + row = await (await conn.execute( + "SELECT recording_path, protocol 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") + + entries = [] + try: + with open(path, encoding="utf-8") as fh: + for line in fh: + line = line.strip() + if not line: + continue + parsed = json.loads(line) + entry = parsed["entry"] + entries.append({"t": entry["t"], "dir": entry["dir"], "data": entry["data"]}) + except (json.JSONDecodeError, KeyError) as exc: + logger.exception("Aufzeichnung %s konnte nicht geparst werden", session_id) + raise HTTPException(status.HTTP_500_INTERNAL_SERVER_ERROR, "Aufzeichnung konnte nicht gelesen werden") from exc + + await write_audit_event( + conn, event_type="session_recording_viewed", user_id=admin.id, client_ip=_client_ip(request), + details={"session_id": session_id, "protocol": row[1], "entry_count": len(entries)}, + ) + await conn.commit() + return {"session_id": session_id, "protocol": row[1], "verified": verified, "entries": entries} + + # Hinweis: Das Live-'Verbindungslog' (WS /ws/logs) liegt bewusst NICHT unter # diesem /admin-Router, sondern als eigener Top-Level-Router in # app/admin/log_ws.py -- siehe dort fuer den Grund (nginx-Reverse-Proxy- diff --git a/app/db/migrations/0011_ssh_password_credentials.sql b/app/db/migrations/0011_ssh_password_credentials.sql new file mode 100644 index 0000000..78c2150 --- /dev/null +++ b/app/db/migrations/0011_ssh_password_credentials.sql @@ -0,0 +1,13 @@ +-- Migration 0011: SSH-Passwort als Alternative zum SSH-Key (Linux-Hosts). +-- +-- Analog zu rdp_credentials (ein Datensatz pro Host, Benutzername gehoert zu +-- den Zugangsdaten). Nur wirksam, wenn dem Host KEIN SSH-Key zugeordnet ist +-- -- ein zugeordneter Schluessel (host_ssh_key_map) hat beim Verbindungsaufbau +-- immer Vorrang (siehe app/ssh_proxy/proxy.py::connect_to_host). + +CREATE TABLE IF NOT EXISTS ssh_password_credentials ( + host_id INTEGER PRIMARY KEY REFERENCES hosts(id), + username TEXT NOT NULL, + password_enc BLOB NOT NULL, + updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')) +); diff --git a/app/models/schemas.py b/app/models/schemas.py index 9ddf7f3..912045d 100644 --- a/app/models/schemas.py +++ b/app/models/schemas.py @@ -216,7 +216,6 @@ class ApiTokenCreateRequest(BaseModel): class SshKeyCreateRequest(BaseModel): label: str = Field(min_length=1, max_length=128) - owner_user_id: int | None = None 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"] @@ -234,7 +233,6 @@ class SshKeyCreateRequest(BaseModel): class SshKeyUpdateRequest(BaseModel): label: str | None = Field(default=None, min_length=1, max_length=128) - owner_user_id: int | None = None # Wenn gesetzt, wird der Schluessel rotiert (neues Schluesselmaterial, # rotated_at wird aktualisiert). Alle drei Felder muessen dann zusammen # angegeben werden (siehe rotate_ssh_key() in admin/routes.py). @@ -263,3 +261,19 @@ class RdpCredentialsRequest(BaseModel): # Migration 0010 Teil der Zugangsdaten statt des Hosts. username: str | None = Field(default=None, max_length=128) domain: str | None = Field(default=None, max_length=128) + + +class SshPasswordCredentialsRequest(BaseModel): + """Alternative zum SSH-Key (Migration 0011): Passwort-Login fuer Linux- + Hosts, analog zu RdpCredentialsRequest. Nur wirksam, solange dem Host + KEIN SSH-Key zugeordnet ist -- ein zugeordneter Schluessel hat immer + Vorrang (siehe app/ssh_proxy/proxy.py::connect_to_host).""" + username: str = Field(min_length=1, max_length=128) + password: str = Field(min_length=1, max_length=512) + + +class SshKeyGenerateRequest(BaseModel): + """Fuer den 'Schluessel automatisch generieren'-Button im Adminbereich -- + erzeugt nur Schluesselmaterial, speichert nichts (siehe + POST /admin/ssh-keys/generate).""" + key_type: Literal["ed25519", "rsa-3072", "rsa-4096"] diff --git a/app/ssh_proxy/proxy.py b/app/ssh_proxy/proxy.py index 2119f8a..f996b51 100644 --- a/app/ssh_proxy/proxy.py +++ b/app/ssh_proxy/proxy.py @@ -181,6 +181,29 @@ def import_private_key_material( ) from exc +def generate_key_material(key_type: str) -> tuple[str, str]: + """Erzeugt ein frisches Schluesselpaar serverseitig (asyncssh) fuer den + 'Schluessel automatisch generieren'-Button im Adminbereich. + + Der private Schluessel verlaesst den Server genau EINMAL in der HTTP- + Antwort dieses Aufrufs (analog zu einem frisch erstellten API-Token, + siehe admin/routes.py::create_api_token) -- gespeichert wird er erst, + wenn der Admin danach das SSH-Key-Formular tatsaechlich absendet, und ab + dann nur noch AES-256-GCM-verschluesselt (Konzept 6.4). Diese Funktion + selbst persistiert nichts.""" + if key_type == "ed25519": + key = asyncssh.generate_private_key("ssh-ed25519") + elif key_type == "rsa-3072": + key = asyncssh.generate_private_key("ssh-rsa", key_size=3072) + elif key_type == "rsa-4096": + key = asyncssh.generate_private_key("ssh-rsa", key_size=4096) + else: + raise ValueError(f"Automatische Erzeugung nicht unterstuetzt fuer key_type={key_type!r}") + private_pem = key.export_private_key("openssh").decode() + public_key = key.export_public_key("openssh").decode().strip() + return private_pem, public_key + + #: Fehler, die den Aufbau einer SSH-Sitzung verhindern und dem angemeldeten #: Benutzer im Klartext gezeigt werden duerfen (keine Geheimnisse, nur #: Konfigurations-/Erreichbarkeitsaussagen). Wird von terminal_ws.py und @@ -269,11 +292,14 @@ async def load_host(conn: aiosqlite.Connection, host_id: int) -> dict: return host -async def load_ssh_credential_for_host( +async def load_ssh_key_credential_for_host( conn: aiosqlite.Connection, host_id: int -) -> tuple[asyncssh.SSHKey, str | None]: +) -> 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). Der Benutzername gehoert seit Migration 0010 zu den Zugangsdaten (ssh_keys.username) und nicht mehr zum Host: er ist Teil der Anmeldung, @@ -289,7 +315,7 @@ async def load_ssh_credential_for_host( ) row = await cursor.fetchone() if row is None: - raise HostNotConfiguredError(f"Kein SSH-Schluessel fuer Host {host_id} hinterlegt") + return None pem = decrypt_secret(row[0], associated_data=b"ssh_private_key") passphrase = ( decrypt_secret(row[1], associated_data=b"ssh_key_passphrase") if row[1] else None @@ -304,10 +330,35 @@ async def load_ssh_credential_for_host( del passphrase +async def load_ssh_password_credential_for_host( + conn: aiosqlite.Connection, host_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.""" + cursor = await conn.execute( + "SELECT password_enc, username FROM ssh_password_credentials WHERE host_id = ?", + (host_id,), + ) + row = await cursor.fetchone() + if row is None: + return None + password = decrypt_secret(row[0], associated_data=b"ssh_password") + try: + return password.decode(), row[1] + finally: + del password + + async def load_private_key_for_host(conn: aiosqlite.Connection, host_id: int) -> asyncssh.SSHKey: """Rueckwaertskompatible Fassung ohne Benutzername (siehe - load_ssh_credential_for_host).""" - key, _username = await load_ssh_credential_for_host(conn, host_id) + 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) + if result is None: + raise HostNotConfiguredError(f"Kein SSH-Schluessel fuer Host {host_id} hinterlegt") + key, _username = result return key @@ -323,7 +374,8 @@ def resolve_ssh_username(host: dict, credential_username: str | None) -> str: if not username: raise HostNotConfiguredError( "Fuer diesen Host ist kein SSH-Benutzername hinterlegt. Der Benutzername " - "gehoert zum SSH-Key (Adminbereich -> Zugangsdaten -> SSH-Key bearbeiten)." + "gehoert zu den Zugangsdaten (Adminbereich -> Server -> Host -> SSH-Key " + "bzw. SSH-Passwort bearbeiten)." ) return username @@ -393,10 +445,33 @@ async def connect_to_host(conn: aiosqlite.Connection, host_id: int) -> asyncssh. raise HostNotConfiguredError("Host ist kein SSH-Ziel") # Reihenfolge mit Absicht: erst das Ziel identifizieren, dann erst - # Schluesselmaterial entschluesseln und eine Anmeldung versuchen. + # Zugangsdaten entschluesseln und eine Anmeldung versuchen. trusted_key = await _verified_host_key(conn, host) - private_key, credential_username = await load_ssh_credential_for_host(conn, host_id) - username = resolve_ssh_username(host, credential_username) + + # 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) + 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" + ) + + if key_credential is not None: + private_key, credential_username = key_credential + username = resolve_ssh_username(host, credential_username) + auth_kwargs: dict = {"client_keys": [private_key]} + else: + password, credential_username = password_credential + username = resolve_ssh_username(host, credential_username) + # client_keys=[] deaktiviert bewusst jeden impliziten Rueckgriff auf + # lokale Default-Schluessel (~/.ssh, Agent) -- asyncssh probiert die + # sonst automatisch VOR der Passwort-Authentifizierung. + auth_kwargs = {"client_keys": [], "password": password} + client_factory = lambda: _PinnedHostKeyClient(host["ssh_host_key_fingerprint"]) try: @@ -404,14 +479,17 @@ async def connect_to_host(conn: aiosqlite.Connection, host_id: int) -> asyncssh. host["address"], port=host["port"], username=username, - client_keys=[private_key], known_hosts=None, client_factory=client_factory, connect_timeout=10, + **auth_kwargs, ) except asyncssh.Error as exc: logger.warning("SSH-Verbindungsfehler zu Host %s: %s", host_id, exc) raise + finally: + if key_credential is None and password_credential is not None: + del password # Zweite Haelfte des Pinnings: der Schluessel dieser Sitzung muss derselbe # sein wie der eben gepruefte. Faengt den (sehr schmalen) Fall ab, dass diff --git a/app/ssh_proxy/terminal_ws.py b/app/ssh_proxy/terminal_ws.py index 2a12afc..798dd6c 100644 --- a/app/ssh_proxy/terminal_ws.py +++ b/app/ssh_proxy/terminal_ws.py @@ -39,6 +39,23 @@ MAX_SESSION_SECONDS = 8 * 3600 IDLE_TIMEOUT_SECONDS = 15 * 60 +async def _reject(websocket: WebSocket, code: int, reason: str, *, accepted: bool) -> None: + """Beendet eine SSH-Sitzung vor ihrem eigentlichen Beginn -- mit einem fuer + den Benutzer lesbaren Grund als WebSocket-Close-Reason, analog zu + app/rdp_proxy/ws_tunnel.py::_reject (Troubleshooting-Verbesserung: + vorher endeten diese Pfade in einem nackten `websocket.close(code=...)`, + static/js/terminal.js zeigte dann nur ein generisches + 'Verbindung beendet' ohne jeden Grund an). Voraussetzung fuer eine + sichtbare Reason ist ein zustande gekommener Handshake -- vor accept() + sieht der Browser nur einen HTTP-/WS-Fehler ohne Text (siehe die + bewusste Ausnahme fuer den Nicht-angemeldet-Fall unten).""" + logger.warning("SSH-Verbindung abgelehnt (code=%s): %s", code, reason) + reason_bytes = reason.encode("utf-8")[:123] + if not accepted: + await websocket.accept() + await websocket.close(code=code, reason=reason_bytes.decode("utf-8", errors="ignore")) + + async def _pump_ssh_to_ws(process: asyncssh.SSHClientProcess, websocket: WebSocket, recorder: SessionRecorder): try: while True: @@ -68,11 +85,11 @@ async def ssh_terminal(websocket: WebSocket, host_id: int): if not user.is_admin and not await user_has_role_for_host( conn, user_id=user.id, host_id=host_id, role_name="ssh_connect" ): - logger.warning( - "SSH-Verbindung abgelehnt: Benutzer %s hat keine Berechtigung 'ssh_connect' fuer Host %s", - user.username, host_id, + await _reject( + websocket, 4403, + f"Keine Berechtigung 'ssh_connect' fuer Host {host_id}", + accepted=False, ) - await websocket.close(code=4403) return await websocket.accept() @@ -81,9 +98,11 @@ async def ssh_terminal(websocket: WebSocket, host_id: int): try: host = await load_host(conn, host_id) except HostNotConfiguredError as exc: - logger.warning("SSH-Verbindung abgelehnt (host_id=%s): %s", host_id, exc) + # Grund SOWOHL als JSON-Frame (falls der Client schon zuhoert) ALS + # AUCH als Close-Reason senden (falls nicht) -- terminal.js zeigt + # beides an, je nachdem, was zuerst ankommt. await websocket.send_json({"type": "error", "message": str(exc)}) - await websocket.close(code=4404) + await _reject(websocket, 4404, str(exc), accepted=True) return cursor = await conn.execute( diff --git a/static/css/app.css b/static/css/app.css index 195293f..5f716db 100644 --- a/static/css/app.css +++ b/static/css/app.css @@ -224,3 +224,21 @@ pre.log-view { } .ft-list-row .name { flex: 1; word-break: break-all; } .ft-list-row .size { color: var(--muted); flex-shrink: 0; white-space: nowrap; } + +/* --- Sitzungs-Wiedergabe-Panel (breiter als das Dateitransfer-Panel, --- */ +/* muss Terminal- bzw. RDP-Bildschirmgroesse aufnehmen). */ +.rec-panel { width: min(90vw, 1100px); } +.rec-stage { + background: #000; border: 1px solid var(--border); border-radius: 6px; + min-height: 320px; max-height: 65vh; overflow: auto; margin-top: 0.75rem; + display: flex; align-items: flex-start; justify-content: center; +} +.rec-stage .rec-terminal { width: 100%; height: 55vh; } +.rec-stage .xterm { padding: 0.4rem; height: 100%; } +.rec-controls { + display: flex; align-items: center; gap: 0.75rem; margin-top: 0.75rem; +} +.rec-controls button { width: auto; margin: 0; } +.rec-controls input[type=range] { flex: 1; } +.rec-controls select { width: auto; padding: 0.3rem 0.4rem; } +.rec-controls .hint { margin: 0; white-space: nowrap; } diff --git a/static/js/admin.js b/static/js/admin.js index c9264d0..87cee5c 100644 --- a/static/js/admin.js +++ b/static/js/admin.js @@ -520,10 +520,13 @@ if (named.length) { return `Anmeldung als ${named.map((k) => `"${k.username}" (Key ${k.label})`).join(", ")}.`; } + if ((host.ssh_keys || []).length === 0 && host.ssh_password_credentials_set) { + return `Anmeldung als "${host.ssh_password_credentials_username}" (SSH-Passwort, kein Schluessel zugeordnet).`; + } if (host.ssh_username) { return `Noch kein Benutzername am SSH-Key -- es wird der alte Wert am Host verwendet ("${host.ssh_username}"). Bitte im Reiter "Zugangsdaten" am Schluessel eintragen.`; } - return 'Kein SSH-Benutzername hinterlegt -- im Reiter "Zugangsdaten" am zugeordneten Schluessel eintragen, sonst kommt keine Verbindung zustande.'; + return 'Kein SSH-Benutzername hinterlegt -- im Reiter "Zugangsdaten" am zugeordneten Schluessel oder als SSH-Passwort eintragen, sonst kommt keine Verbindung zustande.'; } async function showHostDetail(hostId) { @@ -581,6 +584,11 @@ const keySelect = document.getElementById("hkm-key-select"); keySelect.replaceChildren(...optionsFromList(cachedSshKeys, "id", (k) => `${k.label} (#${k.id})`)); + document.getElementById("ssh-pw-cred-status").textContent = host.ssh_password_credentials_set + ? `SSH-Passwort gesetzt fuer "${host.ssh_password_credentials_username}" (zuletzt aktualisiert: ${host.ssh_password_credentials_updated_at}).` + : "Noch kein SSH-Passwort gesetzt."; + document.getElementById("hspw-username").value = host.ssh_password_credentials_username || ""; + document.getElementById("rdp-cred-status").textContent = host.rdp_credentials_set ? `RDP-Zugangsdaten gesetzt fuer "${host.rdp_credentials_username || "(kein Benutzername)"}" (zuletzt aktualisiert: ${host.rdp_credentials_updated_at}).` : "Noch keine RDP-Zugangsdaten gesetzt."; @@ -704,6 +712,40 @@ } }); + document.getElementById("hrc-delete-btn").addEventListener("click", async () => { + try { + await apiFetch(`/admin/hosts/${activeHostId}/rdp-credentials`, { method: "DELETE" }); + showBanner("RDP-Zugangsdaten geloescht.", "ok"); + await showHostDetail(activeHostId); + } catch (err) { + setDetailBanner(err.message, "error"); + } + }); + + document.getElementById("host-ssh-pw-form").addEventListener("submit", async (ev) => { + ev.preventDefault(); + try { + const username = document.getElementById("hspw-username").value.trim(); + const password = document.getElementById("hspw-password").value; + await sendJson(`/admin/hosts/${activeHostId}/ssh-password`, "PUT", { username, password }); + showBanner("SSH-Passwort gespeichert.", "ok"); + document.getElementById("hspw-password").value = ""; + await showHostDetail(activeHostId); + } catch (err) { + setDetailBanner(err.message, "error"); + } + }); + + document.getElementById("hspw-delete-btn").addEventListener("click", async () => { + try { + await apiFetch(`/admin/hosts/${activeHostId}/ssh-password`, { method: "DELETE" }); + showBanner("SSH-Passwort geloescht.", "ok"); + await showHostDetail(activeHostId); + } catch (err) { + setDetailBanner(err.message, "error"); + } + }); + // --------------------------------------------------------------------- // Zugangsdaten (SSH-Keys + RDP-Passwoerter) // --------------------------------------------------------------------- @@ -727,7 +769,6 @@ el("td", { textContent: k.label }), el("td", { textContent: k.username || "-" }), el("td", { textContent: k.key_type }), - el("td", { textContent: k.owner_user_id === null ? "-" : String(k.owner_user_id) }), el("td", { textContent: k.tenant_name }), el("td", { textContent: k.has_passphrase ? "hinterlegt" : "-" }), el("td", { textContent: k.created_at }), @@ -754,7 +795,6 @@ document.getElementById("ssh-key-edit-name").textContent = `${k.label} (#${k.id})`; document.getElementById("ske-label").value = k.label; document.getElementById("ske-username").value = k.username || ""; - document.getElementById("ske-owner").value = k.owner_user_id === null ? "" : String(k.owner_user_id); document.getElementById("skr-type").value = k.key_type; document.getElementById("skp-passphrase").value = ""; document.getElementById("ske-passphrase-state").textContent = k.has_passphrase @@ -799,13 +839,11 @@ document.getElementById("ssh-key-edit-form").addEventListener("submit", async (ev) => { ev.preventDefault(); try { - const ownerRaw = document.getElementById("ske-owner").value.trim(); await sendJson(`/admin/ssh-keys/${editingSshKeyId}`, "PUT", { label: document.getElementById("ske-label").value.trim() || null, // Leeres Feld -> null entfernt den Benutzernamen bewusst (die // Server-Semantik unterscheidet zwischen "Feld fehlt" und "null"). username: document.getElementById("ske-username").value.trim() || null, - owner_user_id: ownerRaw ? Number(ownerRaw) : null, }); showBanner("SSH-Key aktualisiert.", "ok"); await refreshSshKeys(); @@ -823,7 +861,7 @@ public_key: document.getElementById("skr-public").value, passphrase: document.getElementById("skr-passphrase").value || null, }); - showBanner("SSH-Key rotiert.", "ok"); + showBanner("Neues Schluesselmaterial gespeichert.", "ok"); ev.target.reset(); await refreshSshKeys(); } catch (err) { @@ -831,15 +869,29 @@ } }); + document.getElementById("skc-generate-btn").addEventListener("click", async () => { + try { + const keyType = document.getElementById("skc-type").value; + if (keyType === "ca-cert") { + showBanner("Automatische Erzeugung ist fuer ca-cert nicht moeglich -- bitte Schluessel/Zertifikat der eigenen CA einfuegen.", "error"); + return; + } + const result = await sendJson("/admin/ssh-keys/generate", "POST", { key_type: keyType }); + document.getElementById("skc-private").value = result.private_key_pem; + document.getElementById("skc-public").value = result.public_key; + showBanner("Schluesselpaar erzeugt -- bitte Label (und ggf. Benutzername) ausfuellen und 'Anlegen' druecken, um es zu speichern.", "ok"); + } catch (err) { + showBanner(err.message, "error"); + } + }); + document.getElementById("ssh-key-create-form").addEventListener("submit", async (ev) => { ev.preventDefault(); try { - const ownerRaw = document.getElementById("skc-owner").value.trim(); const tenantSelect = document.getElementById("skc-tenant"); const payload = { label: document.getElementById("skc-label").value.trim(), username: document.getElementById("skc-username").value.trim() || null, - owner_user_id: ownerRaw ? Number(ownerRaw) : null, key_type: document.getElementById("skc-type").value, private_key_pem: document.getElementById("skc-private").value, public_key: document.getElementById("skc-public").value, @@ -1238,7 +1290,7 @@ } if (s.has_recording) { actions.appendChild( - actionButton("Aufzeichnung", "btn-secondary", () => showSessionRecording(s.id)) + actionButton("Wiedergabe", "btn-secondary", () => showSessionRecording(s)) ); } return el("tr", {}, [ @@ -1260,17 +1312,265 @@ ); } - async function showSessionRecording(sessionId) { + // --- Wiedergabe (SSH: Terminal-Replay ueber xterm.js; RDP: volle + // grafische Wiedergabe ueber Guacamole.SessionRecording) -------------- + // + // Die vom Recorder aufgezeichneten "output"-Instruktionen einer RDP- + // Sitzung sind bereits ein gueltiger .guac-Datenstrom (siehe + // app/recordings/recorder.py + app/rdp_proxy/guacd_client.py:: + // encode_instruction): guacd sendet als Teil des normalen Protokolls + // periodische "sync"-Instruktionen mit Zeitstempel, genau das nutzt + // Guacamole.SessionRecording zur Framebildung. Aneinandergehaengt ergeben + // die "output"-Eintraege deshalb exakt das Format, das die Bibliothek + // erwartet -- kein eigener Player noetig. + + function recB64decode(b64) { + return decodeURIComponent(escape(atob(b64))); + } + + function formatMs(ms) { + const totalSec = Math.max(0, Math.round(ms / 1000)); + const m = Math.floor(totalSec / 60); + const s = totalSec % 60; + return `${m}:${String(s).padStart(2, "0")}`; + } + + let activePlayer = null; + + function closeRecordingOverlay() { + document.getElementById("rec-overlay").classList.add("hidden"); + if (activePlayer) { + activePlayer.destroy(); + activePlayer = null; + } + document.getElementById("rec-stage").replaceChildren(); + } + + document.getElementById("rec-close-btn").addEventListener("click", closeRecordingOverlay); + + function createSshPlayer(entries) { + const outputEntries = entries.filter((e) => e.dir === "output"); + const stage = document.getElementById("rec-stage"); + const container = el("div", { className: "rec-terminal" }); + stage.appendChild(container); + + const term = new Terminal({ + cursorBlink: false, disableStdin: true, convertEol: false, + fontFamily: "Menlo, Consolas, monospace", fontSize: 13, + theme: { background: "#000000" }, + }); + const fitAddon = new FitAddon.FitAddon(); + term.loadAddon(fitAddon); + term.open(container); + fitAddon.fit(); + + const playBtn = document.getElementById("rec-playpause-btn"); + const seek = document.getElementById("rec-seek"); + const timeLabel = document.getElementById("rec-time"); + const speedSelect = document.getElementById("rec-speed"); + speedSelect.classList.remove("hidden"); + + const durationMs = outputEntries.length ? outputEntries[outputEntries.length - 1].t * 1000 : 0; + seek.min = "0"; + seek.max = String(Math.round(durationMs)); + seek.value = "0"; + + let idx = 0; + let playing = false; + let timer = null; + let speed = Number(speedSelect.value) || 1; + let realStart = 0; + let baseMs = 0; + + function updateTimeUi(posMs) { + seek.value = String(Math.round(posMs)); + timeLabel.textContent = `${formatMs(posMs)} / ${formatMs(durationMs)}`; + } + + function scheduleNext() { + if (!playing) return; + if (idx >= outputEntries.length) { + playing = false; + playBtn.textContent = "Abspielen"; + return; + } + const entry = outputEntries[idx]; + const targetMs = entry.t * 1000; + const elapsedMs = baseMs + (Date.now() - realStart) * speed; + const delay = Math.max(0, (targetMs - elapsedMs) / speed); + timer = window.setTimeout(() => { + term.write(recB64decode(entry.data)); + idx += 1; + updateTimeUi(targetMs); + scheduleNext(); + }, delay); + } + + function play() { + if (playing || idx >= outputEntries.length) return; + playing = true; + playBtn.textContent = "Pause"; + realStart = Date.now(); + baseMs = idx > 0 ? outputEntries[idx - 1].t * 1000 : 0; + scheduleNext(); + } + + function pause() { + playing = false; + playBtn.textContent = "Abspielen"; + if (timer) { window.clearTimeout(timer); timer = null; } + } + + function seekTo(targetMs) { + pause(); + term.reset(); + idx = 0; + const targetSec = targetMs / 1000; + while (idx < outputEntries.length && outputEntries[idx].t <= targetSec) { + term.write(recB64decode(outputEntries[idx].data)); + idx += 1; + } + updateTimeUi(targetMs); + } + + const onPlayPause = () => { if (playing) pause(); else play(); }; + const onSeekChange = () => seekTo(Number(seek.value)); + const onSpeedChange = () => { + speed = Number(speedSelect.value) || 1; + if (playing) { pause(); play(); } + }; + + playBtn.textContent = "Abspielen"; + playBtn.addEventListener("click", onPlayPause); + seek.addEventListener("change", onSeekChange); + speedSelect.addEventListener("change", onSpeedChange); + updateTimeUi(0); + + return { + destroy() { + pause(); + playBtn.removeEventListener("click", onPlayPause); + seek.removeEventListener("change", onSeekChange); + speedSelect.removeEventListener("change", onSpeedChange); + speedSelect.classList.add("hidden"); + term.dispose(); + }, + }; + } + + function createRdpPlayer(entries) { + const stage = document.getElementById("rec-stage"); + const container = el("div", {}); + stage.appendChild(container); + + const guacStream = entries.filter((e) => e.dir === "output").map((e) => e.data).join(""); + const blob = new Blob([guacStream], { type: "application/octet-stream" }); + const recording = new Guacamole.SessionRecording(blob); + container.appendChild(recording.getDisplay().getElement()); + + const playBtn = document.getElementById("rec-playpause-btn"); + const seek = document.getElementById("rec-seek"); + const timeLabel = document.getElementById("rec-time"); + + let posTimer = null; + let seeking = false; + + function updateTimeUi() { + const duration = recording.getDuration(); + const durationRounded = Math.round(duration); + if (Number(seek.max) !== durationRounded) seek.max = String(durationRounded); + if (!seeking) seek.value = String(Math.round(recording.getPosition())); + timeLabel.textContent = `${formatMs(recording.getPosition())} / ${formatMs(duration)}`; + } + + recording.onload = updateTimeUi; + recording.onprogress = updateTimeUi; + recording.onplay = () => { + playBtn.textContent = "Pause"; + if (posTimer) window.clearInterval(posTimer); + posTimer = window.setInterval(updateTimeUi, 200); + }; + recording.onpause = () => { + playBtn.textContent = "Abspielen"; + if (posTimer) { window.clearInterval(posTimer); posTimer = null; } + updateTimeUi(); + }; + recording.onerror = (message) => { + showBanner("Fehler bei der RDP-Wiedergabe: " + message, "error"); + }; + + const onPlayPause = () => { if (recording.isPlaying()) recording.pause(); else recording.play(); }; + const onSeekStart = () => { seeking = true; }; + const onSeekInput = () => { + timeLabel.textContent = `${formatMs(Number(seek.value))} / ${formatMs(recording.getDuration())}`; + }; + const onSeekCommit = () => { + recording.seek(Number(seek.value), () => { seeking = false; updateTimeUi(); }); + }; + + playBtn.textContent = "Abspielen"; + playBtn.addEventListener("click", onPlayPause); + seek.addEventListener("mousedown", onSeekStart); + seek.addEventListener("touchstart", onSeekStart); + seek.addEventListener("input", onSeekInput); + seek.addEventListener("change", onSeekCommit); + updateTimeUi(); + + return { + destroy() { + recording.pause(); + recording.abort(); + if (posTimer) window.clearInterval(posTimer); + playBtn.removeEventListener("click", onPlayPause); + seek.removeEventListener("mousedown", onSeekStart); + seek.removeEventListener("touchstart", onSeekStart); + seek.removeEventListener("input", onSeekInput); + seek.removeEventListener("change", onSeekCommit); + }, + }; + } + + async function showSessionRecording(session) { + const overlay = document.getElementById("rec-overlay"); + const title = document.getElementById("rec-title"); + const loading = document.getElementById("rec-loading"); + const stage = document.getElementById("rec-stage"); + const controls = document.getElementById("rec-controls"); + const verifyBanner = document.getElementById("rec-verify-banner"); + + closeRecordingOverlay(); + overlay.classList.remove("hidden"); + title.textContent = `Wiedergabe -- Sitzung #${session.id} (${session.username} @ ${session.hostname})`; + loading.classList.remove("hidden"); + loading.textContent = "Aufzeichnung wird geladen ..."; + stage.classList.add("hidden"); + controls.classList.add("hidden"); + verifyBanner.classList.add("hidden"); + try { - const result = await getJson(`/admin/sessions/${sessionId}/recording`); - const box = document.getElementById("session-recording-box"); - box.classList.remove("hidden"); - box.className = `banner ${result.verified ? "ok" : "error"}`; - box.textContent = result.verified - ? `Aufzeichnung #${sessionId}: Integritaet OK, ${result.entry_count} Eintraege.` - : `Aufzeichnung #${sessionId}: WARNUNG -- Hash-Kette gebrochen, moeglicherweise manipuliert!`; + const result = await getJson(`/admin/sessions/${session.id}/recording/entries`); + loading.classList.add("hidden"); + stage.classList.remove("hidden"); + controls.classList.remove("hidden"); + + if (!result.verified) { + verifyBanner.className = "banner error"; + verifyBanner.textContent = "WARNUNG: Hash-Kette dieser Aufzeichnung ist gebrochen -- moeglicherweise nachtraeglich veraendert oder beschaedigt. Wiedergabe ist trotzdem moeglich."; + verifyBanner.classList.remove("hidden"); + } + + if (result.entries.length === 0) { + loading.classList.remove("hidden"); + loading.textContent = "Diese Aufzeichnung enthaelt keine Eintraege."; + stage.classList.add("hidden"); + controls.classList.add("hidden"); + return; + } + + activePlayer = result.protocol === "rdp" ? createRdpPlayer(result.entries) : createSshPlayer(result.entries); } catch (err) { - showBanner(err.message, "error"); + loading.classList.remove("hidden"); + loading.textContent = "Fehler beim Laden der Aufzeichnung: " + err.message; } } diff --git a/static/js/rdp.js b/static/js/rdp.js index b7df584..10f20b1 100644 --- a/static/js/rdp.js +++ b/static/js/rdp.js @@ -46,6 +46,10 @@ // reicht ihn als Guacamole.Status.message hierher durch. statusEl.textContent = "Fehler: " + ((err && err.message) || "unbekannt"); statusEl.classList.add("error"); + // Troubleshooting: Statuscode + Rohobjekt zusaetzlich in die + // Browser-Konsole, fuer den Fall, dass die Klartextmeldung allein nicht + // reicht (z.B. guacd/FreeRDP-interne Codes). + console.error("RDP-Sitzung beendet/Fehler:", err); }; client.onerror = showError; tunnel.onerror = showError; @@ -91,4 +95,16 @@ document.exitFullscreen(); } }); + + // Strg+Alt+Entf: der Browser faengt diese Kombination selbst ab (Windows + // reserviert sie systemweit), sie kommt also nie als normales Tastatur- + // Event beim RDP-Ziel an. Abhilfe wie im offiziellen Guacamole-Client: + // die drei Tasten einzeln als synthetische Key-Events senden (X11- + // Keysyms: Ctrl=0xFFE3, Alt=0xFFE9, Delete=0xFFFF), erst alle drei + // herunter- dann in umgekehrter Reihenfolge wieder hochdruecken. + document.getElementById("ctrlaltdel-btn").addEventListener("click", () => { + const keys = [0xffe3, 0xffe9, 0xffff]; + for (const keysym of keys) client.sendKeyEvent(1, keysym); + for (const keysym of keys.slice().reverse()) client.sendKeyEvent(0, keysym); + }); })(); diff --git a/static/js/terminal.js b/static/js/terminal.js index de81640..ad589ba 100644 --- a/static/js/terminal.js +++ b/static/js/terminal.js @@ -37,6 +37,14 @@ return decodeURIComponent(escape(atob(b64))); } + // Troubleshooting: der Server liefert bei einem Fehlschlag entweder eine + // {"type":"error",...}-Nachricht ODER (wenn die Sitzung nie so weit kam, + // dass eine Nachricht gesendet werden konnte -- z.B. fehlende + // Berechtigung) eine WebSocket-Close-Reason (siehe + // app/ssh_proxy/terminal_ws.py::_reject). Beides wird angezeigt statt + // eines generischen "Verbindung beendet" ohne jeden Grund. + let lastErrorMessage = null; + const proto = window.location.protocol === "https:" ? "wss:" : "ws:"; const ws = new WebSocket(`${proto}//${window.location.host}/ws/ssh/${hostId}`); @@ -44,15 +52,35 @@ statusEl.textContent = "Verbunden"; sendResize(); }); - ws.addEventListener("close", () => { statusEl.textContent = "Verbindung beendet"; }); - ws.addEventListener("error", () => { statusEl.textContent = "Verbindungsfehler"; }); + ws.addEventListener("close", (ev) => { + let text; + if (lastErrorMessage) { + text = "Verbindung beendet: " + lastErrorMessage; + } else if (ev.reason) { + text = `Verbindung beendet (Code ${ev.code}): ${ev.reason}`; + } else if (ev.code && ev.code !== 1000) { + text = `Verbindung beendet (Code ${ev.code})`; + } else { + text = "Verbindung beendet"; + } + statusEl.textContent = text; + statusEl.classList.add("error"); + console.error("SSH-Sitzung beendet:", { code: ev.code, reason: ev.reason, lastErrorMessage }); + }); + ws.addEventListener("error", (ev) => { + statusEl.textContent = "Verbindungsfehler (WebSocket) -- Details siehe Browser-Konsole"; + statusEl.classList.add("error"); + console.error("SSH-WebSocket-Fehler:", ev); + }); ws.addEventListener("message", (ev) => { const msg = JSON.parse(ev.data); if (msg.type === "output") { term.write(b64decode(msg.data)); } else if (msg.type === "error") { + lastErrorMessage = msg.message; statusEl.textContent = "Fehler: " + msg.message; + statusEl.classList.add("error"); } }); diff --git a/templates/admin.html b/templates/admin.html index 71bf3fe..db5105f 100644 --- a/templates/admin.html +++ b/templates/admin.html @@ -5,6 +5,7 @@ Jumphost Admin +
@@ -304,6 +305,23 @@
+ +

SSH-Passwort (Alternative zum Schluessel)

+

+

Wird nur verwendet, wenn diesem Host KEIN SSH-Key zugeordnet ist -- ein zugeordneter Schluessel hat immer Vorrang.

+
+
+ + +
+
+ + +
+

Passwort wird AES-256-GCM-verschluesselt gespeichert und nie wieder ausgegeben; beim Aendern bitte beide Felder ausfuellen.

+ + +

Benutzername, Domaene und Passwort gehoeren zusammen und werden gemeinsam gespeichert. Das Passwort wird AES-256-GCM-verschluesselt abgelegt und nie wieder ausgegeben; beim Aendern bitte alle drei Felder ausfuellen.

+ @@ -340,11 +359,6 @@
-

Der Anmeldename, mit dem dieser Schluessel benutzt wird (z.B. root oder l4u). Er gehoert zum Zugang, nicht zum Server -- fuer ein zweites Konto einen zweiten Schluesseleintrag anlegen.

-
-
- -
@@ -359,6 +373,12 @@
+
+
+ + Erzeugt ein neues Schluesselpaar serverseitig und fuellt die beiden Felder unten aus (fuer ca-cert nicht moeglich). Der private Schluessel wird NICHT gespeichert, bevor "Anlegen" gedrueckt wird. +
+
@@ -377,7 +397,7 @@

Vorhandene SSH-Keys

- +
IDLabelBenutzerTypBesitzerMandantPassphraseErstelltRotiert
IDLabelBenutzerTypMandantPassphraseErstelltZuletzt ersetzt
@@ -394,11 +414,7 @@
-
- - -
- +

Passphrase

@@ -411,8 +427,8 @@ -

Schluessel rotieren

-

Ersetzt das Schluesselmaterial vollstaendig (neuer privater + oeffentlicher Schluessel). Bereits zugeordnete Hosts nutzen danach automatisch den neuen Schluessel.

+

Neuen Schluessel fuer diesen Eintrag speichern

+

Ersetzt das Schluesselmaterial dieses Eintrags vollstaendig (auch "Rotieren" genannt) -- es entsteht KEIN neuer Eintrag, bereits zugeordnete Hosts nutzen danach automatisch den neuen Schluessel.

@@ -435,7 +451,7 @@
- +
@@ -626,7 +642,6 @@ -
@@ -669,6 +684,32 @@ + + + + + + diff --git a/templates/rdp.html b/templates/rdp.html index 6342142..ba7201b 100644 --- a/templates/rdp.html +++ b/templates/rdp.html @@ -10,6 +10,7 @@
+ Verbinde ...
diff --git a/tests/test_phase13.py b/tests/test_phase13.py new file mode 100644 index 0000000..e72f260 --- /dev/null +++ b/tests/test_phase13.py @@ -0,0 +1,420 @@ +""" +Tests fuer Phase 13 (diese Session -- Sammel-Feedback aus dem Live-Test): + + 1) RDP-Zugangsdaten muessen loeschbar sein: DELETE /admin/hosts/{id}/rdp-credentials + (bereits vorhandener Endpunkt -- wird hier nur mitverifiziert, da neu vom + Admin-UI aus verlinkt). + 2) SSH-Passwort als Alternative zum SSH-Key (Migration 0011): PUT/DELETE + /admin/hosts/{id}/ssh-password, Spiegelung in GET /admin/hosts/{id} UND + im schlanken GET /admin/hosts/{id}/credentials (RBAC-faehig). + 5) "Besitzer (User-ID)"-Feld bei SSH-Keys entfernt: owner_user_id darf in + KEINER Antwort von POST/GET/PUT /admin/ssh-keys(...) mehr auftauchen. + 7) "Rotieren" -> klareres Wording ist eine reine UI-Textaenderung + (templates/admin.html/static/js/admin.js) ohne eigenen Endpunkt -- + der Rotations-MECHANISMUS selbst ist bereits durch test_phase12.py und + die update_ssh_key-Tests hier mitabgedeckt. + 8) SSH-Key automatisch generieren: POST /admin/ssh-keys/generate fuer alle + drei unterstuetzten Typen sowie 400 fuer den nicht unterstuetzten Typ + ("ca-cert" kann nicht "generiert" werden -- ein CA-Zertifikat setzt eine + bereits existierende eigene CA voraus). + 9) Troubleshooting bei fehlgeschlagenen Verbindungen ist ein reiner + Frontend-/WebSocket-Close-Reason-Fix (static/js/terminal.js, + static/js/rdp.js, app/ssh_proxy/terminal_ws.py::_reject) ohne eigenen + HTTP-Endpunkt -- dafuer siehe die manuelle Sandbox-Verifikation + (HTML/JS-Konsistenzpruefung) statt eines pytest-Tests hier. + 10) Strg+Alt+Entf-Button ist eine reine Frontend-Aenderung + (templates/rdp.html/static/js/rdp.js) ohne Backend-Anteil. + + Sitzungs-Wiedergabe (volle grafische RDP-Wiedergabe + SSH-Textwiedergabe): + GET /admin/sessions/{id}/recording/entries -- nur require_global_admin + (Mandanten-Admins bekommen 403, genau wie beim bereits bestehenden + GET /admin/sessions/{id}/recording), liefert die entschluesselten/rohen + Eintraege NICHT den Klartext einer Passphrase o.ae., sondern ausschliesslich + die bereits im Klartext aufgezeichneten Terminal-/Guacamole-Stroeme (siehe + Konzept 6.5: die Aufzeichnung selbst enthaelt nie Anmeldedaten, sondern nur + Ein-/Ausgabe der Sitzung) -- UND erzeugt bei jedem Aufruf einen eigenen + Audit-Log-Eintrag ('session_recording_viewed'), weil das Ansehen einer + Mitschnitt-Wiedergabe selbst ein auditierenswerter Zugriff ist. + +Wie in test_phase9.py/test_phase12.py: echte SQLite-DB + echte Migrationen +ueber den httpx-'client'-Fixture-Pfad (conftest.py), kein laufender SSH-/RDP- +Server noetig. Diese Datei setzt (wie test_phase9.py) fastapi/httpx/aiosqlite/ +asyncssh voraus und wurde in DIESER Sandbox mangels PyPI-Netzzugang nicht mit +echtem pytest ausgefuehrt (siehe Sandbox-Einschraenkung, Projektnotiz) -- +Aufbau/Assertions wurden stattdessen von Hand gegen den tatsaechlichen Code in +app/admin/routes.py, app/models/schemas.py und app/recordings/recorder.py +geprueft. Bitte lokal mit `pytest -q` laufen lassen. +""" +from __future__ import annotations + +import json + +import pytest + + +async def _create_user(conn, username: str, password: str, *, is_admin: bool = False) -> int: + from app.security.passwords import hash_password + + cursor = await conn.execute( + "INSERT INTO users (username, password_hash, is_admin, must_change_password) " + "VALUES (?, ?, ?, 0)", + (username, hash_password(password), int(is_admin)), + ) + await conn.commit() + return cursor.lastrowid + + +async def _login_full(client, username: str, password: str) -> str: + import pyotp + + resp = await client.post("/auth/login", json={"username": username, "password": password}) + assert resp.status_code == 200, resp.text + pending = resp.json()["pending_token"] + resp = await client.post("/auth/totp/enroll/start", json={"pending_token": pending}) + assert resp.status_code == 200, resp.text + provisioning_uri = resp.json()["provisioning_uri"] + secret = dict(part.split("=") for part in provisioning_uri.split("?", 1)[1].split("&"))["secret"] + code = pyotp.TOTP(secret).now() + resp = await client.post("/auth/totp/enroll/confirm", json={"pending_token": pending, "code": code}) + assert resp.status_code == 200, resp.text + return resp.cookies.get("jh_session") + + +async def _setup_hostgroup_and_host(conn, *, group_name="p13-group", hostname="p13-host", protocol="ssh"): + cursor = await conn.execute("INSERT INTO host_groups (name) VALUES (?)", (group_name,)) + hg_id = cursor.lastrowid + cursor = await conn.execute( + "INSERT INTO hosts (host_group_id, hostname, address, protocol, port, os_type) " + "VALUES (?, ?, '10.13.0.1', ?, ?, ?)", + (hg_id, hostname, protocol, 22 if protocol == "ssh" else 3389, + "linux" if protocol == "ssh" else "windows"), + ) + await conn.commit() + return hg_id, cursor.lastrowid + + +# --------------------------------------------------------------------------- +# 1) RDP-Zugangsdaten: Loeschen +# --------------------------------------------------------------------------- + +@pytest.mark.asyncio +async def test_rdp_credentials_delete(client): + from app.db import get_db + + conn = get_db() + await _create_user(conn, "rd_admin", "Correct-Horse-Battery-Staple-R1", is_admin=True) + hg_id, host_id = await _setup_hostgroup_and_host(conn, group_name="rd-group", hostname="rd-host", protocol="rdp") + await _login_full(client, "rd_admin", "Correct-Horse-Battery-Staple-R1") + + resp = await client.put(f"/admin/hosts/{host_id}/rdp-credentials", + json={"username": "Administrator", "password": "s3hr-geheim!!"}) + assert resp.status_code == 200, resp.text + + resp = await client.get(f"/admin/hosts/{host_id}/credentials") + assert resp.json()["rdp_credentials_set"] is True + + resp = await client.delete(f"/admin/hosts/{host_id}/rdp-credentials") + assert resp.status_code == 200, resp.text + + resp = await client.get(f"/admin/hosts/{host_id}/credentials") + assert resp.status_code == 200, resp.text + assert resp.json()["rdp_credentials_set"] is False + assert resp.json()["rdp_credentials_username"] is None + + +# --------------------------------------------------------------------------- +# 2) SSH-Passwort als Alternative zum SSH-Key +# --------------------------------------------------------------------------- + +@pytest.mark.asyncio +async def test_ssh_password_credentials_set_and_delete(client): + from app.db import get_db + + conn = get_db() + await _create_user(conn, "sp_admin", "Correct-Horse-Battery-Staple-P1", is_admin=True) + hg_id, host_id = await _setup_hostgroup_and_host(conn, group_name="sp-group", hostname="sp-host") + await _login_full(client, "sp_admin", "Correct-Horse-Battery-Staple-P1") + + # Leerer Benutzername wird abgelehnt (analog RDP/SSH-Key). + resp = await client.put(f"/admin/hosts/{host_id}/ssh-password", json={"username": " ", "password": "geheim123"}) + assert resp.status_code == 400, resp.text + + resp = await client.put(f"/admin/hosts/{host_id}/ssh-password", + json={"username": "l4u", "password": "sehr-geheimes-passwort"}) + assert resp.status_code == 200, resp.text + + # Spiegelung im vollen Host-Datensatz (Admin-only)... + resp = await client.get(f"/admin/hosts/{host_id}") + assert resp.status_code == 200, resp.text + body = resp.json() + assert body["ssh_password_credentials_set"] is True + assert body["ssh_password_credentials_username"] == "l4u" + + # ...UND im schlanken RBAC-faehigen Endpunkt. + resp = await client.get(f"/admin/hosts/{host_id}/credentials") + assert resp.status_code == 200, resp.text + body = resp.json() + assert body["ssh_password_credentials_set"] is True + assert body["ssh_password_credentials_username"] == "l4u" + # Der Klartext des Passworts wird in KEINER Antwort zurueckgegeben. + assert "password" not in json.dumps(body) + assert "sehr-geheimes-passwort" not in json.dumps(body) + + # Erneutes Setzen ist ein Upsert (ON CONFLICT), kein Duplikat/Fehler. + resp = await client.put(f"/admin/hosts/{host_id}/ssh-password", + json={"username": "root", "password": "anderes-passwort-123"}) + assert resp.status_code == 200, resp.text + resp = await client.get(f"/admin/hosts/{host_id}/credentials") + assert resp.json()["ssh_password_credentials_username"] == "root" + + resp = await client.delete(f"/admin/hosts/{host_id}/ssh-password") + assert resp.status_code == 200, resp.text + resp = await client.get(f"/admin/hosts/{host_id}/credentials") + assert resp.json()["ssh_password_credentials_set"] is False + assert resp.json()["ssh_password_credentials_username"] is None + + +@pytest.mark.asyncio +async def test_ssh_password_credentials_via_credentials_manage_role(client): + """credentials_manage erlaubt Nicht-Admins auch das SSH-Passwort zu setzen + (gleiche RBAC-Erweiterung wie fuer RDP/SSH-Key, siehe test_phase9.py).""" + from app.db import get_db + + conn = get_db() + admin_id = await _create_user(conn, "sp2_admin", "Correct-Horse-Battery-Staple-P2", is_admin=True) + holder_id = await _create_user(conn, "sp2_holder", "Correct-Horse-Battery-Staple-P3") + hg_id, host_id = await _setup_hostgroup_and_host(conn, group_name="sp2-group", hostname="sp2-host") + + await _login_full(client, "sp2_admin", "Correct-Horse-Battery-Staple-P2") + resp = await client.post( + "/admin/roles/grant", + json={"user_id": holder_id, "host_group_id": hg_id, "role_names": ["credentials_manage"]}, + ) + assert resp.status_code == 200, resp.text + + client.cookies.clear() + await _login_full(client, "sp2_holder", "Correct-Horse-Battery-Staple-P3") + resp = await client.put(f"/admin/hosts/{host_id}/ssh-password", json={"username": "l4u", "password": "abc12345"}) + assert resp.status_code == 200, resp.text + resp = await client.delete(f"/admin/hosts/{host_id}/ssh-password") + assert resp.status_code == 200, resp.text + + +# --------------------------------------------------------------------------- +# 5) "Besitzer"-Feld ist vollstaendig entfernt +# --------------------------------------------------------------------------- + +@pytest.mark.asyncio +async def test_ssh_key_owner_field_is_gone(client): + from app.db import get_db + + conn = get_db() + await _create_user(conn, "ow_admin", "Correct-Horse-Battery-Staple-O1", is_admin=True) + await _login_full(client, "ow_admin", "Correct-Horse-Battery-Staple-O1") + + resp = await client.post( + "/admin/ssh-keys", + json={ + "label": "ohne-besitzer", "key_type": "ed25519", "username": "l4u", + "private_key_pem": _ED25519_TEST_KEY_PEM, "public_key": "ssh-ed25519 AAAAtest", + }, + ) + assert resp.status_code == 201, resp.text + assert "owner_user_id" not in resp.json() + key_id = resp.json()["id"] + + resp = await client.get("/admin/ssh-keys") + assert resp.status_code == 200, resp.text + rows = resp.json() + assert len(rows) == 1 + assert "owner_user_id" not in rows[0] + # Die 9 tatsaechlich noch vorhandenen Felder (Admin-Tabelle hat 9 Spalten, + # siehe static/js/admin.js::refreshSshKeys()). + assert set(rows[0]) == { + "id", "label", "key_type", "created_at", "rotated_at", "expires_at", + "tenant_id", "tenant_name", "has_passphrase", "username", + } | {"username"} # (Mengen sind idempotent -- nur zur Lesbarkeit doppelt genannt) + + resp = await client.put(f"/admin/ssh-keys/{key_id}", json={"label": "umbenannt", "owner_user_id": 999}) + assert resp.status_code == 200, resp.text + # Ein unbekanntes Feld im Request wird von Pydantic (extra="ignore" per + # Default) klaglos verworfen -- es darf insbesondere KEINEN Fehler geben + # und keine owner_user_id-Spalte mehr existieren, in die geschrieben + # werden koennte (siehe Migration -- das Feld wurde nie in einer eigenen + # Migration entfernt, weil es nie eine eigene Spalte hatte, sondern nur + # eine tote FK-Spalte namens owner_user_id in Migration 0001; das UPDATE + # in update_ssh_key() adressiert sie nirgends mehr). + resp = await client.get("/admin/ssh-keys") + assert "owner_user_id" not in resp.json()[0] + + +# --------------------------------------------------------------------------- +# 8) SSH-Key automatisch generieren +# --------------------------------------------------------------------------- + +@pytest.mark.asyncio +@pytest.mark.parametrize("key_type", ["ed25519", "rsa-3072", "rsa-4096"]) +async def test_generate_ssh_key_supported_types(client, key_type): + from app.db import get_db + + conn = get_db() + await _create_user(conn, f"gen_admin_{key_type.replace('-', '_')}", "Correct-Horse-Battery-Staple-G1", is_admin=True) + await _login_full(client, f"gen_admin_{key_type.replace('-', '_')}", "Correct-Horse-Battery-Staple-G1") + + resp = await client.post("/admin/ssh-keys/generate", json={"key_type": key_type}) + assert resp.status_code == 200, resp.text + body = resp.json() + assert body["key_type"] == key_type + assert "PRIVATE KEY" in body["private_key_pem"] + assert body["public_key"].startswith("ssh-") + + # Erzeugtes Material muss tatsaechlich als SSH-Key uebernehmbar sein -- + # end-to-end-Beweis, dass "generieren" und "speichern" zusammenspielen. + resp = await client.post( + "/admin/ssh-keys", + json={ + "label": f"generiert-{key_type}", "key_type": key_type, "username": "l4u", + "private_key_pem": body["private_key_pem"], "public_key": body["public_key"], + }, + ) + assert resp.status_code == 201, resp.text + + +@pytest.mark.asyncio +async def test_generate_ssh_key_rejects_ca_cert(client): + from app.db import get_db + + conn = get_db() + await _create_user(conn, "gen_admin_ca", "Correct-Horse-Battery-Staple-G2", is_admin=True) + await _login_full(client, "gen_admin_ca", "Correct-Horse-Battery-Staple-G2") + + resp = await client.post("/admin/ssh-keys/generate", json={"key_type": "ca-cert"}) + # Pydantic weist den Literal-Wert schon vor dem Handler zurueck (422), + # NICHT das im Handler behandelte generische ValueError->400 -- beides + # bedeutet aber "kein 500 / keine stillschweigend falsche Antwort". + assert resp.status_code == 422, resp.text + + +# --------------------------------------------------------------------------- +# Sitzungs-Wiedergabe: GET /admin/sessions/{id}/recording/entries +# --------------------------------------------------------------------------- + +@pytest.mark.asyncio +async def test_session_recording_entries_requires_global_admin(client): + from app.db import get_db + + conn = get_db() + # Mandanten-Admin (Tenant-gebunden ueber tenant_admins, kein globaler + # Super-Admin) darf NICHT -- dieser Endpunkt liefert vollstaendigen + # Klartext-Mitschnitt und ist bewusst auf require_global_admin + # beschraenkt (siehe Docstring des Endpunkts in app/admin/routes.py). + # Aufbau eines Mandanten-Admins wie in tests/test_tenants.py: ein + # Super-Admin legt einen Mandanten an und traegt einen (sonst nicht + # privilegierten) Benutzer als dessen Tenant-Admin ein. + super_id = await _create_user(conn, "rec_bootstrap_super", "Correct-Horse-Battery-Staple-N0", is_admin=True) + await _login_full(client, "rec_bootstrap_super", "Correct-Horse-Battery-Staple-N0") + resp = await client.post("/admin/tenants", json={"name": "Kunde Rec"}) + assert resp.status_code == 201, resp.text + tenant_id = resp.json()["id"] + tenant_admin_id = await _create_user(conn, "rec_tenant_admin", "Correct-Horse-Battery-Staple-N1") + resp = await client.post(f"/admin/tenants/{tenant_id}/admins", json={"user_id": tenant_admin_id}) + assert resp.status_code == 201, resp.text + client.cookies.clear() + + hg_id, host_id = await _setup_hostgroup_and_host(conn, group_name="rec-group", hostname="rec-host") + user_id = await _create_user(conn, "rec_user", "Correct-Horse-Battery-Staple-N2") + cursor = await conn.execute( + "INSERT INTO sessions (user_id, host_id, protocol, client_ip) VALUES (?, ?, 'ssh', '9.9.9.9')", + (user_id, host_id), + ) + session_id = cursor.lastrowid + await conn.commit() + + await _login_full(client, "rec_tenant_admin", "Correct-Horse-Battery-Staple-N1") + resp = await client.get(f"/admin/sessions/{session_id}/recording/entries") + assert resp.status_code == 403, resp.text + + +@pytest.mark.asyncio +async def test_session_recording_entries_ssh_playback_and_audit(client): + from app.db import get_db + from app.recordings.recorder import SessionRecorder + + conn = get_db() + await _create_user(conn, "rec_super", "Correct-Horse-Battery-Staple-N3", is_admin=True) + user_id = await _create_user(conn, "rec_user2", "Correct-Horse-Battery-Staple-N4") + hg_id, host_id = await _setup_hostgroup_and_host(conn, group_name="rec2-group", hostname="rec2-host") + cursor = await conn.execute( + "INSERT INTO sessions (user_id, host_id, protocol, client_ip) VALUES (?, ?, 'ssh', '9.9.9.9')", + (user_id, host_id), + ) + session_id = cursor.lastrowid + await conn.commit() + + # Echter SessionRecorder statt Fake -- schreibt dieselbe hash-verkettete + # JSONL-Datei, die terminal_ws.py/ws_tunnel.py im Betrieb erzeugen. + recorder = SessionRecorder(session_id) + recorder.record("input", "ls\n") + recorder.record("output", "total 0\n") + recorder.record("output", "$ ") + recorder.close() + await conn.execute("UPDATE sessions SET recording_path = ? WHERE id = ?", (str(recorder.path), session_id)) + await conn.commit() + + await _login_full(client, "rec_super", "Correct-Horse-Battery-Staple-N3") + resp = await client.get(f"/admin/sessions/{session_id}/recording/entries") + assert resp.status_code == 200, resp.text + body = resp.json() + assert body["session_id"] == session_id + assert body["protocol"] == "ssh" + assert body["verified"] is True + assert len(body["entries"]) == 3 + assert [e["dir"] for e in body["entries"]] == ["input", "output", "output"] + assert body["entries"][1]["data"] == "total 0\n" + + # Jeder Aufruf erzeugt einen eigenen, gesonderten Audit-Log-Eintrag. + resp = await client.get("/admin/audit-log") + assert resp.status_code == 200, resp.text + events = [e["event_type"] for e in resp.json()] + assert "session_recording_viewed" in events + + +@pytest.mark.asyncio +async def test_session_recording_entries_404_without_recording(client): + from app.db import get_db + + conn = get_db() + await _create_user(conn, "rec_super2", "Correct-Horse-Battery-Staple-N5", is_admin=True) + user_id = await _create_user(conn, "rec_user3", "Correct-Horse-Battery-Staple-N6") + hg_id, host_id = await _setup_hostgroup_and_host(conn, group_name="rec3-group", hostname="rec3-host") + cursor = await conn.execute( + "INSERT INTO sessions (user_id, host_id, protocol, client_ip) VALUES (?, ?, 'ssh', '9.9.9.9')", + (user_id, host_id), + ) + session_id = cursor.lastrowid + await conn.commit() + + await _login_full(client, "rec_super2", "Correct-Horse-Battery-Staple-N5") + resp = await client.get(f"/admin/sessions/{session_id}/recording/entries") + assert resp.status_code == 404, resp.text + + +# --------------------------------------------------------------------------- +# Testschluessel (nur fuer diese Datei -- kein echtes Geheimnis). +# --------------------------------------------------------------------------- + +_ED25519_TEST_KEY_PEM = None + + +def _generate_test_key_pem() -> str: + from cryptography.hazmat.primitives import serialization + from cryptography.hazmat.primitives.asymmetric import ed25519 + + return ed25519.Ed25519PrivateKey.generate().private_bytes( + serialization.Encoding.PEM, + serialization.PrivateFormat.OpenSSH, + serialization.NoEncryption(), + ).decode() + + +_ED25519_TEST_KEY_PEM = _generate_test_key_pem()
IDBenutzerHostProtokollStartStatusClient-IP