diff --git a/README.md b/README.md index 41174a2..a6c6c87 100644 --- a/README.md +++ b/README.md @@ -252,13 +252,89 @@ Mandanten-Admin sein zu muessen (neue Dependency `GET /admin/hosts/{id}/credentials`, Dashboard zeigt einen "Zugangsdaten"- Knopf bei Hosts mit dieser Rolle). +## Phase 10: SSH-Key-Passphrasen und funktionsfaehige RDP-Sitzungen + +Behebt die Ursachen dafuer, dass weder SSH- noch RDP-Sitzungen zustande kamen. + +**SSH -- passphrasegeschuetzte Schluessel (Migration 0009, +`ssh_keys.passphrase_enc`).** `asyncssh.import_private_key()` wurde ohne +Passphrase aufgerufen; ein mit `ssh-keygen` ueblich erzeugter, verschluesselter +Schluessel liess sich damit prinzipiell nicht laden +(`KeyImportError: Passphrase must be specified to import encrypted private +keys`). Weil dieser Fehler ein `ValueError` und *kein* `asyncssh.Error` ist, +lief er an allen Fehlerbehandlungen der WebSocket-Route vorbei -- der Browser +sah nur ein wortloses Verbindungsende. Neu: + +* Beim Anlegen und Rotieren eines Schluessels kann eine Passphrase angegeben + werden. Sie wird wie das Schluesselmaterial selbst mit dem KEK + (AES-256-GCM) verschluesselt gespeichert und ueber keinen Endpunkt jemals + zurueckgegeben -- die Liste zeigt nur `has_passphrase`. +* Zu einem **bereits hinterlegten** Schluessel laesst sich die Passphrase + nachtragen (Adminbereich -> Zugangsdaten -> SSH-Key bearbeiten). Sie wird + dabei sofort gegen das gespeicherte Schluesselmaterial geprueft; passt sie + nicht, wird nichts gespeichert. +* Jeder Upload wird sofort gegen asyncssh validiert: ein unbrauchbarer + Schluessel wird mit HTTP 400 und Klartextbegruendung abgelehnt, statt erst + beim ersten Verbindungsversuch eines Benutzers aufzufallen. +* Scheitert das Laden trotzdem, meldet die Sitzung jetzt `PrivateKeyUnusableError` + mit verstaendlichem Text an Terminal und Dateitransfer statt kommentarlos + abzubrechen. + +**RDP -- vier unabhaengige Fehler, die jede Sitzung verhinderten.** + +1. `static/js/rdp.js` haengte die Verbindungsparameter an die Tunnel-URL an -- + `Guacamole.WebSocketTunnel.connect(data)` haengt aber selbst noch + `"?" + data` an. Daraus wurde `...&dpi=96?undefined`; FastAPI wies den + WebSocket wegen der ungueltigen Query noch vor dem Routenhandler ab, und + im Verbindungslog erschien **kein einziger Eintrag**. Die Parameter werden + jetzt an `connect()` uebergeben. +2. guacamole-common-js oeffnet den Socket immer mit dem Subprotokoll + `guacamole`. Der Server bestaetigte es nicht, woraufhin der Browser die + Verbindung nach RFC 6455 sofort wieder verwarf. `websocket.accept()` setzt + es jetzt. +3. `load_host()` selektierte `rdp_username`, `rdp_domain`, `rdp_require_nla` + und `clipboard_enabled` nicht -- `build_rdp_params()` baute daraus eine + `connect`-Instruktion **ohne Benutzernamen** (Anmeldung am Ziel scheitert, + der Client haengt in "Warte auf Server ..."), und die Zwischenablage war + unabhaengig von der Hostkonfiguration immer gesperrt. Fehlt jetzt eine der + Angaben, meldet der Server das im Klartext statt still eine kaputte + Verbindung aufzubauen. +4. `ignore-cert` war hart auf `false` verdrahtet. Windows-Ziele ohne eigene + PKI weisen sich mit einem selbstsignierten Zertifikat aus, guacd/FreeRDP + bricht dann vor dem ersten Bild ab. Neu: `hosts.rdp_ignore_cert` + (Migration 0009, Default: ignorieren) mit Checkbox im Host-Formular. + +Ausserdem im Guacamole-Protokoll behoben: die Laengenangaben zaehlen +**Zeichen**, nicht Bytes (so ist das Protokoll definiert, und so zaehlen guacd +und guacamole-common-js). Zuvor stand dort die UTF-8-Bytelaenge -- ein +einziger Umlaut, etwa in einem RDP-Passwort oder in der Zwischenablage, +verschob den gesamten nachfolgenden Datenstrom. Tunnelinterne Instruktionen +(leerer Opcode: `ping`, Tunnel-UUID) werden nicht mehr an guacd +durchgereicht, ein `ping` wird gespiegelt, und der Server sendet die +Tunnel-UUID als erste Instruktion -- sonst blieb der Client bis zum ersten +Bild in "Warte auf Server ..." und lief bei einem langsamen RDP-Handshake in +den 15-Sekunden-Timeout. + +**Sichtbarkeit.** Saemtliche Abbruchpfade *vor* dem eigentlichen +Sitzungsbeginn (fehlende Berechtigung, unbekannter Host, falsches Protokoll, +fehlendes RDP-Passwort) schlossen den Socket bisher kommentarlos. Sie +protokollieren jetzt und geben den Grund als WebSocket-Close-Reason mit, den +`rdp.js` direkt anzeigt. + +### Nach dem Deployment zu tun + +1. Migration 0009 laeuft beim Start automatisch. +2. Zu jedem bereits hinterlegten, passphrasegeschuetzten SSH-Key die + Passphrase nachtragen (Adminbereich -> Zugangsdaten -> SSH-Key bearbeiten). +3. Bei RDP-Hosts pruefen, dass ein RDP-Benutzername gesetzt ist. + ## Tests ```bash pytest -q ``` -68 Tests (vorher 61) decken ab: Argon2id/TOTP-Grundfunktionen, Audit-Hash-Chain +78 Tests (vorher 68; `tests/test_phase10.py` kam hinzu) decken ab: Argon2id/TOTP-Grundfunktionen, Audit-Hash-Chain (inkl. Manipulationserkennung und Trigger-Durchsetzung), RBAC-Logik inkl. Ablaufdaten, den vollstaendigen Login-Flow (Passwort -> TOTP-Enrollment -> Session-Cookie -> geschuetzte Endpunkte) gegen die echte FastAPI-App, 17 diff --git a/_to_delete/jh.tgz b/_to_delete/jh.tgz new file mode 100644 index 0000000..18d3e23 Binary files /dev/null and b/_to_delete/jh.tgz differ diff --git a/app/admin/routes.py b/app/admin/routes.py index 948c2b1..ee315e2 100644 --- a/app/admin/routes.py +++ b/app/admin/routes.py @@ -66,10 +66,16 @@ from app.security.api_tokens import ( validate_scopes, ) from app.security.audit import verify_chain, write_audit_event -from app.security.crypto import encrypt_secret +from app.security.crypto import decrypt_secret, encrypt_secret from app.security.passwords import hash_password from app.recordings.recorder import verify_recording -from app.ssh_proxy.proxy import HostKeyDiscoveryError, HostNotConfiguredError, discover_and_store_host_key +from app.ssh_proxy.proxy import ( + HostKeyDiscoveryError, + HostNotConfiguredError, + PrivateKeyUnusableError, + discover_and_store_host_key, + import_private_key_material, +) from app.tenancy import TenantScope, resolve_host_group_tenant, resolve_host_tenant, tenant_user_ids logger = logging.getLogger("jumphost.admin") @@ -725,15 +731,15 @@ async def create_host( INSERT INTO hosts ( host_group_id, hostname, address, protocol, port, os_type, ssh_host_key_fingerprint, ssh_username, rdp_username, rdp_domain, - rdp_require_nla, clipboard_enabled, file_transfer_enabled - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + rdp_require_nla, clipboard_enabled, file_transfer_enabled, rdp_ignore_cert + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) """, ( payload.host_group_id, payload.hostname, payload.address, payload.protocol, payload.port, payload.os_type, payload.ssh_host_key_fingerprint, payload.ssh_username, payload.rdp_username, payload.rdp_domain, int(payload.rdp_require_nla), int(payload.clipboard_enabled), - int(payload.file_transfer_enabled), + int(payload.file_transfer_enabled), int(payload.rdp_ignore_cert), ), ) new_id = cursor.lastrowid @@ -797,7 +803,7 @@ async def get_host_detail( "SELECT h.id, h.hostname, h.address, h.protocol, h.port, h.os_type, h.host_group_id, " "h.ssh_host_key_fingerprint, h.ssh_username, h.rdp_username, h.rdp_domain, " "h.rdp_require_nla, h.clipboard_enabled, h.file_transfer_enabled, h.is_active, " - "hg.tenant_id, t.name " + "hg.tenant_id, t.name, h.rdp_ignore_cert " "FROM hosts h JOIN host_groups hg ON hg.id = h.host_group_id JOIN tenants t ON t.id = hg.tenant_id " "WHERE h.id = ?", (host_id,), @@ -819,7 +825,8 @@ async def get_host_detail( "ssh_username": row[8], "rdp_username": row[9], "rdp_domain": row[10], "rdp_require_nla": bool(row[11]), "clipboard_enabled": bool(row[12]), "file_transfer_enabled": bool(row[13]), "is_active": bool(row[14]), - "tenant_id": row[15], "tenant_name": row[16], "ssh_keys": ssh_keys, + "tenant_id": row[15], "tenant_name": row[16], "rdp_ignore_cert": bool(row[17]), + "ssh_keys": ssh_keys, "rdp_credentials_set": rdp_row is not None, "rdp_credentials_updated_at": rdp_row[0] if rdp_row else None, } @@ -844,6 +851,7 @@ async def update_host( bool_field_map = { "rdp_require_nla": "rdp_require_nla", "clipboard_enabled": "clipboard_enabled", "file_transfer_enabled": "file_transfer_enabled", "is_active": "is_active", + "rdp_ignore_cert": "rdp_ignore_cert", } fields, values = [], [] payload_dict = payload.model_dump(exclude_unset=True) @@ -1227,6 +1235,20 @@ async def list_role_names(admin: CurrentUser = Depends(require_admin_or_scope("r # --- SSH-Keyverwaltung --------------------------------------------------------- +def _validate_private_key(pem: str, passphrase: str | None) -> None: + """Prueft, ob asyncssh das Material mit der angegebenen Passphrase laden + kann, und uebersetzt einen Fehlschlag in HTTP 400 mit Klartextmeldung. + + Der importierte Schluessel wird bewusst NICHT zurueckgegeben und nicht + weiterverwendet -- er dient nur der Pruefung und wird sofort wieder + verworfen (Konzept 6.4: Schluesselmaterial so kurz wie moeglich im + Prozessspeicher).""" + try: + import_private_key_material(pem, passphrase) + except PrivateKeyUnusableError as exc: + raise HTTPException(status.HTTP_400_BAD_REQUEST, str(exc)) from exc + + @router.post("/ssh-keys", status_code=status.HTTP_201_CREATED) async def create_ssh_key( payload: SshKeyCreateRequest, request: Request, @@ -1238,16 +1260,31 @@ async def create_ssh_key( conn = get_db() scope = _scope(admin) tenant_id = await _resolve_write_tenant(conn, scope, payload.tenant_id) + # Schluessel SOFORT gegen asyncssh pruefen (inkl. Passphrase), statt einen + # unbrauchbaren Schluessel entgegenzunehmen und den Fehler erst beim + # ersten Verbindungsversuch eines Benutzers auftauchen zu lassen -- genau + # so blieb ein passphrasegeschuetzter Schluessel bisher unbemerkt, bis + # die Sitzung im Betrieb abbrach. + _validate_private_key(payload.private_key_pem, payload.passphrase) encrypted = encrypt_secret(payload.private_key_pem.encode(), associated_data=b"ssh_private_key") + passphrase_enc = ( + encrypt_secret(payload.passphrase.encode(), associated_data=b"ssh_key_passphrase") + if payload.passphrase + else None + ) cursor = await conn.execute( - "INSERT INTO ssh_keys (label, owner_user_id, private_key_enc, public_key, key_type, tenant_id) " - "VALUES (?, ?, ?, ?, ?, ?)", - (payload.label, payload.owner_user_id, encrypted, payload.public_key, payload.key_type, tenant_id), + "INSERT INTO ssh_keys (label, owner_user_id, private_key_enc, public_key, key_type, " + "tenant_id, passphrase_enc) VALUES (?, ?, ?, ?, ?, ?, ?)", + (payload.label, payload.owner_user_id, encrypted, payload.public_key, payload.key_type, + tenant_id, passphrase_enc), ) new_id = cursor.lastrowid await write_audit_event( conn, event_type="ssh_key_created", user_id=admin.id, client_ip=_client_ip(request), - details={"id": new_id, "label": payload.label, "key_type": payload.key_type}, + details={ + "id": new_id, "label": payload.label, "key_type": payload.key_type, + "has_passphrase": bool(payload.passphrase), + }, ) await conn.commit() return {"id": new_id, "label": payload.label} @@ -1260,7 +1297,7 @@ async def list_ssh_keys(admin: CurrentUser = Depends(require_admin_or_scope("ssh 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, " - "sk.expires_at, sk.tenant_id, t.name " + "sk.expires_at, sk.tenant_id, t.name, (sk.passphrase_enc IS NOT NULL) " "FROM ssh_keys sk JOIN tenants t ON t.id = sk.tenant_id " f"WHERE 1=1{tenant_filter} ORDER BY sk.id", params, @@ -1271,6 +1308,9 @@ async def list_ssh_keys(admin: CurrentUser = Depends(require_admin_or_scope("ssh "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], + # Nur die Tatsache, NIE die Passphrase selbst -- kein Endpunkt + # dieser Anwendung gibt jemals Klartext-Geheimnisse zurueck. + "has_passphrase": bool(r[9]), } for r in rows ] @@ -1298,6 +1338,34 @@ async def update_ssh_key( "Fuer eine Rotation muessen private_key_pem, public_key UND key_type gemeinsam angegeben werden", ) + # "passphrase" wird nur angefasst, wenn das Feld im Request tatsaechlich + # vorkommt -- ein Update von z.B. nur dem Label darf eine hinterlegte + # Passphrase nicht stillschweigend loeschen. Explizit uebergebenes null + # oder "" entfernt sie dagegen bewusst. + passphrase_given = "passphrase" in payload.model_fields_set + passphrase = payload.passphrase or None + + if rotating: + # Neues Material immer sofort pruefen (inkl. der -- ggf. neuen -- + # Passphrase), damit ein unbrauchbarer Schluessel gar nicht erst in + # die Datenbank kommt. + _validate_private_key(payload.private_key_pem, passphrase) + elif passphrase_given: + # Nur die Passphrase wird nachgetragen/geaendert: gegen das BEREITS + # gespeicherte Schluesselmaterial pruefen. Das ist der Weg, einen + # bereits hinterlegten, passphrasegeschuetzten Schluessel wieder + # benutzbar zu machen, ohne ihn neu hochzuladen. + row = await ( + await conn.execute("SELECT private_key_enc FROM ssh_keys WHERE id = ?", (key_id,)) + ).fetchone() + if row is None: + raise HTTPException(status.HTTP_404_NOT_FOUND, "SSH-Key nicht gefunden") + stored_pem = decrypt_secret(row[0], associated_data=b"ssh_private_key") + try: + _validate_private_key(stored_pem.decode(), passphrase) + finally: + del stored_pem + fields, values = [], [] if payload.label is not None: fields.append("label = ?"); values.append(payload.label) @@ -1308,13 +1376,27 @@ async def update_ssh_key( fields += ["private_key_enc = ?", "public_key = ?", "key_type = ?", "rotated_at = strftime('%Y-%m-%dT%H:%M:%fZ','now')"] values += [encrypted, payload.public_key, payload.key_type] + if rotating or passphrase_given: + # Bei einer Rotation wird die Passphrase in JEDEM Fall neu gesetzt -- + # eine zum alten Schluessel gehoerende Passphrase darf nicht am neuen + # Schluessel haengenbleiben. + fields.append("passphrase_enc = ?") + values.append( + encrypt_secret(passphrase.encode(), associated_data=b"ssh_key_passphrase") + if passphrase + else None + ) if not fields: return {"status": "ok", "changed": False} values.append(key_id) await conn.execute(f"UPDATE ssh_keys SET {', '.join(fields)} WHERE id = ?", values) await write_audit_event( conn, event_type="ssh_key_updated", user_id=admin.id, client_ip=_client_ip(request), - details={"id": key_id, "rotated": rotating}, + details={ + "id": key_id, "rotated": rotating, + "passphrase_changed": bool(rotating or passphrase_given), + "has_passphrase": bool(passphrase), + }, ) await conn.commit() return {"status": "ok", "changed": True} diff --git a/app/db/migrations/0009_key_passphrase_rdp_cert.sql b/app/db/migrations/0009_key_passphrase_rdp_cert.sql new file mode 100644 index 0000000..c7c3f56 --- /dev/null +++ b/app/db/migrations/0009_key_passphrase_rdp_cert.sql @@ -0,0 +1,27 @@ +-- Zwei Ursachen fuer nicht zustande kommende Sitzungen, siehe +-- app/ssh_proxy/proxy.py bzw. app/rdp_proxy/guacd_client.py. +-- +-- 1) ssh_keys.passphrase_enc +-- asyncssh.import_private_key() wurde bisher IMMER ohne Passphrase +-- aufgerufen. Ein passphrasegeschuetzter Private Key (der Normalfall, wenn +-- ein Admin einen bestehenden Schluessel aus ~/.ssh einfuegt) laesst sich so +-- prinzipiell nicht laden -- jeder Verbindungsversuch endete in +-- asyncssh.public_key.KeyImportError: +-- "Passphrase must be specified to import encrypted private keys" +-- und damit in einer sofort wieder beendeten Sitzung. Die Passphrase wird +-- deshalb ab jetzt beim Anlegen/Rotieren des Schluessels mit erfasst und -- +-- exakt wie das Schluesselmaterial selbst und die RDP-Passwoerter -- mit dem +-- KEK (AES-256-GCM, app/security/crypto.py, associated_data=b"ssh_key_passphrase") +-- verschluesselt abgelegt. NULL = Schluessel ist unverschluesselt (bisheriges +-- Verhalten, bleibt unveraendert gueltig). +ALTER TABLE ssh_keys ADD COLUMN passphrase_enc BLOB; + +-- 2) hosts.rdp_ignore_cert +-- build_rdp_params() setzte "ignore-cert" hart auf "false". Windows-Ziele +-- praesentieren im Regelfall ein selbstsigniertes RDP-Zertifikat; guacd/ +-- FreeRDP bricht den Verbindungsaufbau dann ab, bevor ueberhaupt ein Bild +-- entsteht. Die Entscheidung gehoert pro Host getroffen und nicht ins +-- Codeliteral: Default 1 (ignorieren, entspricht dem Verhalten jedes +-- gaengigen RDP-Clients); wer eine interne PKI betreibt, setzt den Wert pro +-- Host auf 0 und erzwingt damit die Zertifikatspruefung. +ALTER TABLE hosts ADD COLUMN rdp_ignore_cert INTEGER NOT NULL DEFAULT 1; diff --git a/app/models/schemas.py b/app/models/schemas.py index 5351686..7c3924d 100644 --- a/app/models/schemas.py +++ b/app/models/schemas.py @@ -120,6 +120,11 @@ class HostCreateRequest(BaseModel): rdp_require_nla: bool = True clipboard_enabled: bool = True file_transfer_enabled: bool = True + # Standard: selbstsigniertes RDP-Zertifikat des Ziels akzeptieren. Windows + # praesentiert ohne interne PKI genau so eines; guacd/FreeRDP bricht sonst + # vor dem ersten Bild ab. Wer eine PKI betreibt, setzt den Wert pro Host + # auf False und erzwingt damit die Pruefung. + rdp_ignore_cert: bool = True @field_validator("hostname") @classmethod @@ -140,6 +145,7 @@ class HostUpdateRequest(BaseModel): rdp_require_nla: bool | None = None clipboard_enabled: bool | None = None file_transfer_enabled: bool | None = None + rdp_ignore_cert: bool | None = None is_active: bool | None = None @field_validator("hostname") @@ -208,6 +214,11 @@ class SshKeyCreateRequest(BaseModel): public_key: str = Field(min_length=1, max_length=8192) key_type: Literal["ed25519", "rsa-3072", "rsa-4096", "ca-cert"] tenant_id: int | None = None + # Passphrase eines verschluesselten Private Keys. Wird -- wie das + # Schluesselmaterial selbst -- mit dem KEK verschluesselt gespeichert + # (ssh_keys.passphrase_enc, Migration 0009) und beim Verbindungsaufbau an + # asyncssh uebergeben. None/"" = Schluessel ist unverschluesselt. + passphrase: str | None = Field(default=None, max_length=1024) class SshKeyUpdateRequest(BaseModel): @@ -219,6 +230,12 @@ class SshKeyUpdateRequest(BaseModel): private_key_pem: str | None = Field(default=None, min_length=1, max_length=32_768) public_key: str | None = Field(default=None, min_length=1, max_length=8192) key_type: Literal["ed25519", "rsa-3072", "rsa-4096", "ca-cert"] | None = None + # Wird nur beachtet, wenn das Feld im Request vorkommt (siehe + # update_ssh_key in app/admin/routes.py): explizites null/"" entfernt eine + # hinterlegte Passphrase, Weglassen laesst sie unveraendert. Ohne + # gleichzeitige Rotation wird die neue Passphrase gegen das bereits + # gespeicherte Schluesselmaterial geprueft. + passphrase: str | None = Field(default=None, max_length=1024) class ConnectRequest(BaseModel): diff --git a/app/rdp_proxy/guacd_client.py b/app/rdp_proxy/guacd_client.py index 3a00136..6c49998 100644 --- a/app/rdp_proxy/guacd_client.py +++ b/app/rdp_proxy/guacd_client.py @@ -16,6 +16,7 @@ sollte vor Produktivbetrieb dennoch gegen die Ziel-guacd-Version getestet werden from __future__ import annotations import asyncio +import codecs import logging logger = logging.getLogger("jumphost.rdp_proxy.guacd") @@ -26,11 +27,18 @@ class GuacamoleProtocolError(Exception): def encode_instruction(*elements: str) -> str: - parts = [] - for element in elements: - encoded = element.encode("utf-8") - parts.append(f"{len(encoded)}.{element}") - return ",".join(parts) + ";" + """Kodiert eine Guacamole-Instruktion. + + Die Laengenangabe zaehlt ZEICHEN, nicht Bytes -- so ist das Guacamole- + Protokoll definiert und so zaehlen sowohl guacd als auch + guacamole-common-js. Hier stand frueher die Laenge der UTF-8-Bytes: + fuer reines ASCII identisch, bei jedem Zeichen ausserhalb davon aber zu + gross. Ein einziger Umlaut -- etwa in einem RDP-Passwort, einem + Benutzernamen oder einem Text aus der Zwischenablage -- verschob damit + den gesamten nachfolgenden Datenstrom, und guacd brach die Verbindung + unmittelbar nach der connect-Instruktion wieder ab. + """ + return ",".join(f"{len(element)}.{element}" for element in elements) + ";" async def _read_until(reader: asyncio.StreamReader, delimiter: bytes) -> bytes: @@ -42,6 +50,21 @@ async def _read_until(reader: asyncio.StreamReader, delimiter: bytes) -> bytes: buf += b +async def _read_chars(reader: asyncio.StreamReader, count: int) -> str: + """Liest genau `count` ZEICHEN (nicht Bytes) UTF-8-kodiert vom Stream. + + Gegenstueck zur Laengenzaehlung in encode_instruction(): ein + `readexactly(count)` haette bei Mehrbyte-Zeichen zu wenig Bytes gelesen + und den Parser mitten in einem Zeichen stehen lassen.""" + decoder = codecs.getincrementaldecoder("utf-8")() + chars: list[str] = [] + while len(chars) < count: + chunk = decoder.decode(await reader.readexactly(1)) + if chunk: + chars.extend(chunk) + return "".join(chars) + + async def read_instruction(reader: asyncio.StreamReader) -> list[str]: elements: list[str] = [] while True: @@ -50,7 +73,7 @@ async def read_instruction(reader: asyncio.StreamReader) -> list[str]: length = int(length_bytes) except ValueError as exc: raise GuacamoleProtocolError(f"Ungueltige Laengenangabe: {length_bytes!r}") from exc - content = (await reader.readexactly(length)).decode("utf-8") + content = await _read_chars(reader, length) elements.append(content) sep = await reader.readexactly(1) if sep == b";": @@ -143,24 +166,50 @@ def build_rdp_params(host: dict, password: str) -> dict[str, str]: """Baut die Parameter-Map fuer die connect-Instruktion aus dem Host-Datensatz. Sicherheitsdefaults (siehe Konzept 6.3/6.7): NLA wird erzwungen sofern - rdp_require_nla gesetzt ist (Standard), Zertifikatspruefung ist standardmaessig - AKTIV (ignore-cert=false) -- bei selbstsignierten Zertifikaten auf den - Zielsystemen muss dies bewusst pro Host ueberschrieben werden, kein stiller - Bypass. + rdp_require_nla gesetzt ist (Standard). Die Zertifikatspruefung ist pro Host + ueber hosts.rdp_ignore_cert schaltbar (Migration 0009, Default: ignorieren) -- + ohne interne PKI praesentiert jedes Windows-Ziel ein selbstsigniertes + Zertifikat, guacd/FreeRDP bricht dann bereits vor dem ersten Bild ab. + + WICHTIG: `host` muss aus app.ssh_proxy.proxy.load_host() stammen. Diese + Funktion liest rdp_username/rdp_domain/rdp_require_nla/clipboard_enabled/ + rdp_ignore_cert -- genau diese Spalten hat load_host() frueher NICHT + selektiert, wodurch hier still ein leerer Benutzername in die + connect-Instruktion ging und die Anmeldung am Zielsystem zwangslaeufig + scheiterte. Fehlt eine der Spalten jetzt trotzdem, ist das ein + Programmierfehler und soll auffallen statt still zu einer kaputten + Verbindung zu fuehren. """ + missing = [ + key for key in ("rdp_username", "rdp_require_nla", "clipboard_enabled", "rdp_ignore_cert") + if key not in host + ] + if missing: + raise GuacamoleProtocolError( + f"Hostdatensatz unvollstaendig, fehlende Felder: {', '.join(missing)}" + ) + + username = (host.get("rdp_username") or "").strip() + if not username: + raise GuacamoleProtocolError( + "Fuer diesen Host ist kein RDP-Benutzername hinterlegt " + "(Adminbereich -> Server -> RDP-Benutzer)." + ) + + clipboard_enabled = bool(host.get("clipboard_enabled")) return { "hostname": host["address"], "port": str(host["port"]), - "username": host.get("rdp_username") or "", + "username": username, "password": password, "domain": host.get("rdp_domain") or "", "security": "nla" if host.get("rdp_require_nla", True) else "any", - "ignore-cert": "false", + "ignore-cert": "true" if host.get("rdp_ignore_cert", True) else "false", "disable-audio": "true", "enable-drive": "true" if host.get("file_transfer_enabled") else "false", "drive-path": f"/var/lib/jumphost/rdp-drives/{host['id']}", "create-drive-path": "true", - "disable-copy": "false" if host.get("clipboard_enabled") else "true", - "disable-paste": "false" if host.get("clipboard_enabled") else "true", + "disable-copy": "false" if clipboard_enabled else "true", + "disable-paste": "false" if clipboard_enabled else "true", "resize-method": "display-update", } diff --git a/app/rdp_proxy/ws_tunnel.py b/app/rdp_proxy/ws_tunnel.py index 6b2c905..52a40af 100644 --- a/app/rdp_proxy/ws_tunnel.py +++ b/app/rdp_proxy/ws_tunnel.py @@ -11,6 +11,7 @@ from __future__ import annotations import asyncio import logging +import uuid as uuid_mod from fastapi import APIRouter, Query, WebSocket, WebSocketDisconnect @@ -35,6 +36,50 @@ from app.ssh_proxy.proxy import HostNotConfiguredError, load_host logger = logging.getLogger("jumphost.rdp_proxy.ws") router = APIRouter() +# Leerer Opcode = tunnelinterne Instruktion des Guacamole-Protokolls +# (Guacamole.Tunnel.INTERNAL_DATA_OPCODE in guacamole-common-js): Tunnel-UUID +# beim Verbindungsaufbau und ping/pong waehrend der Sitzung. +INTERNAL_DATA_OPCODE = "" + +# Das vom Browser angeforderte WebSocket-Subprotokoll. guacamole-common-js +# oeffnet den Socket IMMER als `new WebSocket(url, "guacamole")`. Bestaetigt +# der Server das Subprotokoll nicht im Handshake, verwirft der Browser die +# Verbindung nach RFC 6455 sofort wieder -- die Sitzung kam dadurch nie +# zustande und es erreichte kein einziger Frame die Anwendung. +GUACAMOLE_SUBPROTOCOL = "guacamole" + + +async def _accept(websocket: WebSocket) -> None: + """Nimmt die Verbindung an und bestaetigt dabei das Subprotokoll, sofern + der Client eines angeboten hat (siehe GUACAMOLE_SUBPROTOCOL).""" + offered = websocket.scope.get("subprotocols") or [] + if GUACAMOLE_SUBPROTOCOL in offered: + await websocket.accept(subprotocol=GUACAMOLE_SUBPROTOCOL) + else: + await websocket.accept() + + +async def _reject(websocket: WebSocket, code: int, reason: str, *, accepted: bool) -> None: + """Beendet eine Sitzung vor ihrem eigentlichen Beginn -- mit Logeintrag + und mit einem fuer den Benutzer lesbaren Grund. + + Bisher endeten alle diese Pfade in einem nackten `websocket.close(code=...)` + ohne jede Protokollierung. Im Verbindungslog war deshalb ueberhaupt nichts + zu sehen, wenn eine RDP-Sitzung an einer dieser Vorbedingungen scheiterte. + + Der Grundtext wird als WebSocket-Close-Reason mitgegeben: + guacamole-common-js reicht ihn (siehe close_tunnel()) als + Guacamole.Status.message an client.onerror weiter, wo static/js/rdp.js ihn + direkt anzeigt. Voraussetzung dafuer ist ein zustande gekommener + Handshake -- vor `accept()` sieht der Browser nur einen HTTP-Fehler. + """ + logger.warning("RDP-Verbindung abgelehnt (code=%s): %s", code, reason) + # Close-Reason ist auf 123 Byte begrenzt (RFC 6455). + reason_bytes = reason.encode("utf-8")[:123] + if not accepted: + await _accept(websocket) + await websocket.close(code=code, reason=reason_bytes.decode("utf-8", errors="ignore")) + async def _guacd_to_ws(tunnel, websocket: WebSocket, recorder: SessionRecorder) -> None: while True: @@ -49,9 +94,22 @@ async def _ws_to_guacd(tunnel, websocket: WebSocket, recorder: SessionRecorder, message = await websocket.receive_text() try: instr = parse_instruction_text(message) - except GuacamoleProtocolError: + except (GuacamoleProtocolError, ValueError, IndexError): continue # ungueltige Clientnachricht ignorieren statt die Verbindung zu killen + if instr and instr[0] == INTERNAL_DATA_OPCODE: + # Tunnelinterne Instruktion von guacamole-common-js (leerer + # Opcode), z.B. "0.,4.ping,13.;". Diese gehoert dem + # Tunnel, NICHT guacd -- bisher wurde sie unbesehen an guacd + # weitergereicht, das damit nichts anfangen kann. Ein ping wird + # gespiegelt: der Browser bricht den Tunnel nach + # tunnel.receiveTimeout (Standard 15 s) ohne empfangene Daten mit + # "Server timeout" ab, eine stille Sitzung liefe also in einen + # Fehler. + if len(instr) > 1 and instr[1] == "ping": + await websocket.send_text(encode_instruction(*instr)) + continue + if not clipboard_enabled and instr and instr[0] == "clipboard": continue # Defense-in-Depth: Clipboard serverseitig blocken @@ -70,27 +128,50 @@ async def rdp_tunnel( ): user = await get_current_user_ws(websocket) if user is None: + # Einziger Pfad, der bewusst OHNE vorheriges accept() schliesst: eine + # nicht authentifizierte Verbindung soll gar nicht erst zustande + # kommen. Alle folgenden Ablehnungen laufen ueber _reject(), damit der + # Benutzer im Browser den tatsaechlichen Grund zu sehen bekommt. + logger.warning("RDP-Verbindung abgelehnt: keine gueltige Sitzung (host_id=%s)", host_id) await websocket.close(code=4401) return conn = get_db() + client_ip = websocket.client.host if websocket.client else "unknown" + if not user.is_admin and not await user_has_role_for_host( conn, user_id=user.id, host_id=host_id, role_name="rdp_connect" ): - await websocket.close(code=4403) + await _reject( + websocket, 4403, + f"Keine Berechtigung 'rdp_connect' fuer Host {host_id}", + accepted=False, + ) return - await websocket.accept() - client_ip = websocket.client.host if websocket.client else "unknown" + await _accept(websocket) + + # Tunnel-UUID als erste Instruktion senden (leerer Opcode, siehe + # INTERNAL_DATA_OPCODE). guacamole-common-js setzt den Tunnel erst beim + # Empfang der ersten Instruktion auf OPEN und startet damit seine + # Timeoutueberwachung neu -- ohne diesen Frame blieb der Client bis zum + # ersten Bild von guacd in "Warte auf Server ..." haengen und lief bei + # einem langsamen RDP-Handshake in den 15-Sekunden-Timeout. + tunnel_uuid = str(uuid_mod.uuid4()) + await websocket.send_text(encode_instruction(INTERNAL_DATA_OPCODE, tunnel_uuid)) try: host = await load_host(conn, host_id) except HostNotConfiguredError as exc: - await websocket.close(code=4404) + await _reject(websocket, 4404, str(exc), accepted=True) return if host["protocol"] != "rdp": - await websocket.close(code=4400) + await _reject( + websocket, 4400, + f"Host {host['hostname']} ist kein RDP-Ziel (protocol={host['protocol']})", + accepted=True, + ) return cred_cursor = await conn.execute( @@ -98,7 +179,12 @@ async def rdp_tunnel( ) cred_row = await cred_cursor.fetchone() if cred_row is None: - await websocket.close(code=4404) + await _reject( + websocket, 4404, + f"Fuer Host {host['hostname']} ist kein RDP-Passwort hinterlegt " + "(Adminbereich -> Zugangsdaten).", + accepted=True, + ) return cursor = await conn.execute( @@ -122,6 +208,7 @@ async def rdp_tunnel( active_sessions.register(session_id, asyncio.current_task()) end_reason = "logout" + error_text: str | None = None tunnel = None tasks: list[asyncio.Task] = [] password = None @@ -159,6 +246,11 @@ async def rdp_tunnel( except (GuacamoleProtocolError, ConnectionError, OSError) as exc: logger.warning("RDP-Sessionfehler (session_id=%s): %s", session_id, exc) end_reason = "error" + # Grund an den Browser durchreichen (guacamole-common-js zeigt die + # Close-Reason als Guacamole.Status.message an, siehe _reject). + # Betrifft u.a. den haeufigsten Konfigurationsfehler: kein + # RDP-Benutzername am Host, siehe build_rdp_params(). + error_text = str(exc) or exc.__class__.__name__ except asyncio.CancelledError: # Zwangs-Beendigung durch einen Superadmin ueber die Sessionview # (POST /admin/sessions/{id}/terminate, siehe app/security/active_sessions.py). @@ -172,6 +264,7 @@ async def rdp_tunnel( # jede Fehlermeldung/Protokollierung sofort beendete. logger.exception("Unerwarteter Fehler in RDP-Sitzung %s: %s", session_id, exc) end_reason = "error" + error_text = f"Interner Fehler: {exc.__class__.__name__}" finally: active_sessions.unregister(session_id) del password # Klartext-Passwort so schnell wie moeglich freigeben @@ -192,7 +285,12 @@ async def rdp_tunnel( ) await conn.commit() try: - await websocket.close() + if error_text: + await websocket.close( + code=4500, reason=error_text.encode("utf-8")[:123].decode("utf-8", errors="ignore") + ) + else: + await websocket.close() except Exception: # Cleanup-Pfad: der Session-Datensatz und Audit-Log-Eintrag sind # zu diesem Zeitpunkt bereits geschrieben; ein bereits vom Client diff --git a/app/ssh_proxy/proxy.py b/app/ssh_proxy/proxy.py index 0051f83..1038f00 100644 --- a/app/ssh_proxy/proxy.py +++ b/app/ssh_proxy/proxy.py @@ -44,6 +44,85 @@ class HostKeyDiscoveryError(Exception): super().__init__(f"Host-Key-Ermittlung fuer Host {host_id} fehlgeschlagen: {reason}") +class PrivateKeyUnusableError(Exception): + """Der hinterlegte private Schluessel laesst sich nicht laden. + + Haeufigster Fall (und der Grund, warum es diese Klasse gibt): der + Schluessel ist passphrasegeschuetzt, aber es ist keine -- oder die + falsche -- Passphrase hinterlegt. asyncssh wirft dann + KeyImportError("Passphrase must be specified to import encrypted + private keys"), was ein ValueError und damit KEIN asyncssh.Error ist -- + es rauschte deshalb an saemtlichen Fehlerbehandlungen der WS-Routen + vorbei und die Sitzung brach ohne verwertbare Meldung ab. Diese + Exception traegt stattdessen einen fuer den Benutzer verstaendlichen + deutschen Text, den die WS-Routen direkt an den Client durchreichen. + """ + + def __init__(self, message: str) -> None: + super().__init__(message) + + +def import_private_key_material( + pem: bytes | str, passphrase: bytes | str | None = None +) -> asyncssh.SSHKey: + """Importiert Schluesselmaterial und uebersetzt asyncssh-Importfehler in + eine PrivateKeyUnusableError mit klarer Ursachenbeschreibung. + + Wird an ZWEI Stellen verwendet, bewusst mit identischer Semantik: + * beim Verbindungsaufbau (load_private_key_for_host), + * bereits beim Anlegen/Rotieren eines Schluessels im Adminbereich + (app/admin/routes.py), damit ein unbrauchbarer Schluessel sofort + mit HTTP 400 abgelehnt wird, statt erst beim ersten Verbindungs- + versuch eines Benutzers aufzufallen. + """ + if isinstance(pem, str): + pem = pem.encode() + if isinstance(passphrase, str): + passphrase = passphrase.encode() + if passphrase == b"": + passphrase = None + + try: + return asyncssh.import_private_key(pem, passphrase) + except ValueError as exc: + # Bewusst ValueError statt der konkreten asyncssh-Klassen: + # KeyImportError, KeyEncryptionError und KeyGenerationError sind in + # asyncssh allesamt ValueError-Unterklassen, ihre Namen und die + # genaue Aufteilung unterscheiden sich aber zwischen asyncssh- + # Versionen. Ein Zugriff auf einen in der installierten Version + # nicht vorhandenen Klassennamen wuerde hier beim Import des Moduls + # knallen -- diese Fassung ist gegen solche Versionsunterschiede + # immun. asyncssh.Error (Protokollfehler) ist KEIN ValueError und + # wird hier korrekterweise nicht mitgefangen. + text = str(exc) + + if "Passphrase must be specified" in text: + raise PrivateKeyUnusableError( + "Der private Schluessel ist passphrasegeschuetzt, es ist aber keine " + "Passphrase hinterlegt. Die Passphrase im Adminbereich unter " + "'SSH-Keys' nachtragen (Schluessel bearbeiten) oder einen " + "unverschluesselten Schluessel hinterlegen." + ) from exc + + if passphrase is not None: + if "Incorrect passphrase" in text or "MAC" in text or "decrypt" in text.lower(): + raise PrivateKeyUnusableError( + "Die hinterlegte Passphrase passt nicht zum privaten Schluessel." + ) from exc + # Gegenprobe: manche asyncssh-Versionen quittieren eine Passphrase + # zu einem UNverschluesselten Schluessel mit einem Importfehler. + # In dem Fall ist nicht der Schluessel kaputt, sondern die + # Passphrase ueberfluessig -- also ohne sie erneut versuchen. + try: + return asyncssh.import_private_key(pem, None) + except ValueError: + pass + + raise PrivateKeyUnusableError( + f"Der private Schluessel konnte nicht gelesen werden: {text}" + ) from exc + + class _PinnedHostKeyClient(asyncssh.SSHClient): """Erzwingt Strict Host Key Checking gegen einen fest hinterlegten SHA-256-Fingerprint. Kein automatisches Trust-on-First-Use (TOFU).""" @@ -67,7 +146,9 @@ class _PinnedHostKeyClient(asyncssh.SSHClient): async def load_host(conn: aiosqlite.Connection, host_id: int) -> dict: cursor = await conn.execute( "SELECT id, hostname, address, port, os_type, protocol, ssh_host_key_fingerprint, " - "ssh_username, file_transfer_enabled, host_group_id FROM hosts WHERE id = ? AND is_active = 1", + "ssh_username, file_transfer_enabled, host_group_id, " + "rdp_username, rdp_domain, rdp_require_nla, clipboard_enabled, rdp_ignore_cert " + "FROM hosts WHERE id = ? AND is_active = 1", (host_id,), ) row = await cursor.fetchone() @@ -76,13 +157,33 @@ async def load_host(conn: aiosqlite.Connection, host_id: int) -> dict: keys = ( "id", "hostname", "address", "port", "os_type", "protocol", "ssh_host_key_fingerprint", "ssh_username", "file_transfer_enabled", "host_group_id", + # Bugfix: die folgenden fuenf Spalten wurden bisher NICHT geladen, + # obwohl build_rdp_params() (app/rdp_proxy/guacd_client.py) sie per + # host.get(...) ausliest. Ergebnis war eine RDP-connect-Instruktion + # ohne Benutzernamen und ohne Domaene -- das Zielsystem lehnt die + # Anmeldung dann ab bzw. der Client haengt in "Warte auf Server ...". + # Zusaetzlich war clipboard_enabled dadurch immer None (= Clipboard + # dauerhaft gesperrt, unabhaengig von der Hostkonfiguration). + "rdp_username", "rdp_domain", "rdp_require_nla", "clipboard_enabled", + "rdp_ignore_cert", ) - return dict(zip(keys, row)) + host = dict(zip(keys, row)) + for flag in ("file_transfer_enabled", "rdp_require_nla", "clipboard_enabled", "rdp_ignore_cert"): + host[flag] = bool(host[flag]) + return host async def load_private_key_for_host(conn: aiosqlite.Connection, host_id: int) -> asyncssh.SSHKey: + """Laedt den dem Host zugeordneten Schluessel und entschluesselt ihn. + + Ist zu dem Schluessel eine Passphrase hinterlegt (ssh_keys.passphrase_enc, + Migration 0009), wird sie ebenfalls entschluesselt und an asyncssh + uebergeben. Ohne diesen Schritt scheiterte der Import jedes + passphrasegeschuetzten Schluessels grundsaetzlich -- siehe + PrivateKeyUnusableError. + """ cursor = await conn.execute( - "SELECT sk.private_key_enc FROM ssh_keys sk " + "SELECT sk.private_key_enc, sk.passphrase_enc FROM ssh_keys sk " "JOIN host_ssh_key_map m ON m.ssh_key_id = sk.id " "WHERE m.host_id = ? LIMIT 1", (host_id,), @@ -91,11 +192,16 @@ async def load_private_key_for_host(conn: aiosqlite.Connection, host_id: int) -> if row is None: raise HostNotConfiguredError(f"Kein SSH-Schluessel fuer Host {host_id} hinterlegt") 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 + ) try: - return asyncssh.import_private_key(pem) + return import_private_key_material(pem, passphrase) finally: - # Bestpraxis: Referenz auf den Klartext-PEM-Bytes so schnell wie moeglich loslassen. + # Bestpraxis: Referenzen auf das Klartextmaterial so schnell wie + # moeglich loslassen (Konzept 6.4). del pem + del passphrase async def connect_to_host(conn: aiosqlite.Connection, host_id: int) -> asyncssh.SSHClientConnection: diff --git a/app/ssh_proxy/sftp.py b/app/ssh_proxy/sftp.py index d2167ab..d830ecd 100644 --- a/app/ssh_proxy/sftp.py +++ b/app/ssh_proxy/sftp.py @@ -6,6 +6,7 @@ Groessenlimit, Sha256-Hashing und optionaler AV-Scan sind Pflicht (Konzept from __future__ import annotations import hashlib +import logging from fastapi import APIRouter, Depends, HTTPException, Query, Request, UploadFile, status from fastapi.responses import StreamingResponse @@ -15,8 +16,14 @@ from app.db import get_db from app.rbac import user_has_role_for_host from app.security.audit import write_audit_event from app.security.av_scan import scan_bytes -from app.ssh_proxy.proxy import HostNotConfiguredError, connect_to_host, load_host +from app.ssh_proxy.proxy import ( + HostNotConfiguredError, + PrivateKeyUnusableError, + connect_to_host, + load_host, +) +logger = logging.getLogger("jumphost.ssh_proxy.sftp") router = APIRouter(prefix="/ssh", tags=["file-transfer"]) MAX_UPLOAD_BYTES = 200 * 1024 * 1024 # 200 MiB, ueber Ansible-Variable konfigurierbar (siehe Konzept) @@ -97,6 +104,13 @@ async def upload_file( ssh_conn.close() except HostNotConfiguredError as exc: raise HTTPException(status.HTTP_400_BAD_REQUEST, str(exc)) + except PrivateKeyUnusableError as exc: + # Gleiche Ursache wie bei einer scheiternden Terminalsitzung (siehe + # app/ssh_proxy/terminal_ws.py): passphrasegeschuetzter Schluessel + # ohne hinterlegte Passphrase. Hier als 400 mit Klartext statt als + # unbehandelter 500. + logger.warning("Dateitransfer fuer Host %s nicht moeglich: %s", host_id, exc) + raise HTTPException(status.HTTP_400_BAD_REQUEST, str(exc)) await _log_transfer( conn, user=user, host_id=host_id, client_ip=_client_ip(request), direction="upload", @@ -127,6 +141,13 @@ async def download_file( ssh_conn.close() except HostNotConfiguredError as exc: raise HTTPException(status.HTTP_400_BAD_REQUEST, str(exc)) + except PrivateKeyUnusableError as exc: + # Gleiche Ursache wie bei einer scheiternden Terminalsitzung (siehe + # app/ssh_proxy/terminal_ws.py): passphrasegeschuetzter Schluessel + # ohne hinterlegte Passphrase. Hier als 400 mit Klartext statt als + # unbehandelter 500. + logger.warning("Dateitransfer fuer Host %s nicht moeglich: %s", host_id, exc) + raise HTTPException(status.HTTP_400_BAD_REQUEST, str(exc)) sha256 = hashlib.sha256(data).hexdigest() filename = remote_path.rsplit("/", 1)[-1] diff --git a/app/ssh_proxy/terminal_ws.py b/app/ssh_proxy/terminal_ws.py index b629ca7..d881ed3 100644 --- a/app/ssh_proxy/terminal_ws.py +++ b/app/ssh_proxy/terminal_ws.py @@ -23,7 +23,12 @@ 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 +from app.ssh_proxy.proxy import ( + HostNotConfiguredError, + PrivateKeyUnusableError, + connect_to_host, + load_host, +) logger = logging.getLogger("jumphost.ssh_proxy.ws") router = APIRouter() @@ -50,6 +55,10 @@ async def _pump_ssh_to_ws(process: asyncssh.SSHClientProcess, websocket: WebSock async def ssh_terminal(websocket: WebSocket, host_id: int): user = await get_current_user_ws(websocket) if user is None: + # Auch die Ablehnungen VOR dem Sitzungsbeginn gehoeren protokolliert: + # bisher schloss dieser Pfad das Socket kommentarlos, im + # Verbindungslog war der Fehlversuch dadurch unsichtbar. + logger.warning("SSH-Verbindung abgelehnt: keine gueltige Sitzung (host_id=%s)", host_id) await websocket.close(code=4401) return @@ -57,6 +66,10 @@ 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 websocket.close(code=4403) return @@ -66,6 +79,7 @@ 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) await websocket.send_json({"type": "error", "message": str(exc)}) await websocket.close(code=4404) return @@ -125,8 +139,27 @@ async def ssh_terminal(websocket: WebSocket, host_id: int): # Best-Effort-Fehlermeldung an einen ggf. bereits getrennten Client; # der eigentliche Fehler ist bereits oben geloggt (logger.warning). logger.debug("Fehlermeldung konnte nicht mehr an Client gesendet werden", exc_info=True) - except HostNotConfiguredError: + except PrivateKeyUnusableError as exc: + # Der haeufigste Grund, warum eine SSH-Sitzung nie zustande kam: der + # hinterlegte Private Key ist passphrasegeschuetzt und die Passphrase + # fehlt oder passt nicht. asyncssh wirft dafuer einen KeyImportError + # (ein ValueError, KEIN asyncssh.Error), der frueher an allen + # Handlern vorbei bis aus der Route hinauslief -- der Browser sah nur + # ein wortloses Verbindungsende. Der Text ist bewusst konkret und + # nennt die Stelle im Adminbereich, an der es zu beheben ist. + logger.warning("SSH-Sitzung %s: Schluessel unbrauchbar: %s", session_id, exc) end_reason = "error" + try: + await websocket.send_json({"type": "error", "message": str(exc)}) + except Exception: + logger.debug("Fehlermeldung konnte nicht mehr an Client gesendet werden", exc_info=True) + except HostNotConfiguredError as exc: + logger.warning("SSH-Sitzung %s abgebrochen: %s", session_id, exc) + end_reason = "error" + try: + await websocket.send_json({"type": "error", "message": str(exc)}) + except Exception: + logger.debug("Fehlermeldung konnte nicht mehr an Client gesendet werden", exc_info=True) except asyncio.CancelledError: # Zwangs-Beendigung durch einen Superadmin ueber die Sessionview # (POST /admin/sessions/{id}/terminate, siehe app/security/active_sessions.py). diff --git a/static/js/admin.js b/static/js/admin.js index c9303af..db8b117 100644 --- a/static/js/admin.js +++ b/static/js/admin.js @@ -522,6 +522,7 @@ document.getElementById("he-clipboard").checked = host.clipboard_enabled; document.getElementById("he-filetransfer").checked = host.file_transfer_enabled; document.getElementById("he-nla").checked = host.rdp_require_nla; + document.getElementById("he-ignorecert").checked = host.rdp_ignore_cert; document.getElementById("he-active").checked = host.is_active; const isRdp = host.protocol === "rdp"; @@ -529,6 +530,7 @@ document.getElementById("he-rdp-username-box").classList.toggle("hidden", !isRdp); document.getElementById("he-rdp-domain-box").classList.toggle("hidden", !isRdp); document.getElementById("he-nla-box").classList.toggle("hidden", !isRdp); + document.getElementById("he-ignorecert-box").classList.toggle("hidden", !isRdp); document.getElementById("host-detail-ssh").classList.toggle("hidden", isRdp); document.getElementById("host-detail-rdp").classList.toggle("hidden", !isRdp); @@ -579,6 +581,7 @@ clipboard_enabled: document.getElementById("he-clipboard").checked, file_transfer_enabled: document.getElementById("he-filetransfer").checked, rdp_require_nla: document.getElementById("he-nla").checked, + rdp_ignore_cert: document.getElementById("he-ignorecert").checked, is_active: document.getElementById("he-active").checked, }); showBanner("Host aktualisiert.", "ok"); @@ -595,6 +598,8 @@ document.getElementById("hc-rdp-username-box").classList.toggle("hidden", !isRdp); document.getElementById("hc-rdp-domain-box").classList.toggle("hidden", !isRdp); document.getElementById("hc-nla-box").classList.toggle("hidden", !isRdp); + document.getElementById("hc-ignorecert-box").classList.toggle("hidden", !isRdp); + document.getElementById("hc-ignorecert-hint").classList.toggle("hidden", !isRdp); document.getElementById("hc-port").value = isRdp ? "3389" : "22"; document.getElementById("hc-os-type").value = isRdp ? "windows" : "linux"; }); @@ -631,6 +636,7 @@ rdp_username: document.getElementById("hc-rdp-username").value.trim() || null, rdp_domain: document.getElementById("hc-rdp-domain").value.trim() || null, rdp_require_nla: document.getElementById("hc-nla").checked, + rdp_ignore_cert: document.getElementById("hc-ignorecert").checked, clipboard_enabled: document.getElementById("hc-clipboard").checked, file_transfer_enabled: document.getElementById("hc-filetransfer").checked, }; @@ -706,6 +712,7 @@ 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 }), el("td", { textContent: k.rotated_at || "-" }), el("td", {}, [ @@ -731,8 +738,46 @@ document.getElementById("ske-label").value = k.label; 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 + ? "Fuer diesen Schluessel ist eine Passphrase hinterlegt." + : "Fuer diesen Schluessel ist KEINE Passphrase hinterlegt. Ist der Schluessel verschluesselt, schlaegt jeder Verbindungsversuch fehl."; } + async function saveSshKeyPassphrase(value) { + // Wird gegen das bereits gespeicherte Schluesselmaterial geprueft + // (update_ssh_key in app/admin/routes.py); passt sie nicht, antwortet der + // Server mit 400 und es wird nichts gespeichert. + await sendJson(`/admin/ssh-keys/${editingSshKeyId}`, "PUT", { passphrase: value }); + await refreshSshKeys(); + const updated = cachedSshKeys.find((k) => k.id === editingSshKeyId); + if (updated) showSshKeyEdit(updated); + } + + document.getElementById("ssh-key-passphrase-form").addEventListener("submit", async (ev) => { + ev.preventDefault(); + try { + const value = document.getElementById("skp-passphrase").value; + if (!value) { + showBanner("Bitte eine Passphrase eingeben oder 'Passphrase entfernen' verwenden.", "error"); + return; + } + await saveSshKeyPassphrase(value); + showBanner("Passphrase gespeichert und gegen den Schluessel geprueft.", "ok"); + } catch (err) { + showBanner(err.message, "error"); + } + }); + + document.getElementById("skp-clear-btn").addEventListener("click", async () => { + try { + await saveSshKeyPassphrase(null); + showBanner("Passphrase entfernt.", "ok"); + } catch (err) { + showBanner(err.message, "error"); + } + }); + document.getElementById("ssh-key-edit-form").addEventListener("submit", async (ev) => { ev.preventDefault(); try { @@ -755,6 +800,7 @@ key_type: document.getElementById("skr-type").value, private_key_pem: document.getElementById("skr-private").value, public_key: document.getElementById("skr-public").value, + passphrase: document.getElementById("skr-passphrase").value || null, }); showBanner("SSH-Key rotiert.", "ok"); ev.target.reset(); @@ -775,6 +821,8 @@ key_type: document.getElementById("skc-type").value, private_key_pem: document.getElementById("skc-private").value, public_key: document.getElementById("skc-public").value, + // Leeres Feld -> null: der Schluessel gilt dann als unverschluesselt. + passphrase: document.getElementById("skc-passphrase").value || null, tenant_id: tenantSelect.value ? Number(tenantSelect.value) : null, }; await sendJson("/admin/ssh-keys", "POST", payload); diff --git a/static/js/rdp.js b/static/js/rdp.js index ed9576e..b7df584 100644 --- a/static/js/rdp.js +++ b/static/js/rdp.js @@ -21,7 +21,15 @@ const height = Math.round(window.innerHeight - 40); const dpi = Math.round(window.devicePixelRatio * 96) || 96; - const tunnelUrl = `${proto}//${window.location.host}/ws/rdp/${hostId}?width=${width}&height=${height}&dpi=${dpi}`; + // WICHTIG: die Verbindungsparameter gehoeren NICHT in die Tunnel-URL. + // Guacamole.WebSocketTunnel.connect(data) baut die Socket-URL selbst als + // `tunnelURL + "?" + data` zusammen. Standen die Parameter schon in der + // URL, entstand daraus `...?width=1280&height=800&dpi=96?undefined` -- der + // letzte Query-Parameter war damit kein gueltiger Integer mehr, FastAPI + // wies den WebSocket noch vor dem Routenhandler ab (HTTP 422) und im + // Verbindungslog des Servers tauchte kein einziger Eintrag auf. + const tunnelUrl = `${proto}//${window.location.host}/ws/rdp/${hostId}`; + const connectParams = `width=${width}&height=${height}&dpi=${dpi}`; const tunnel = new Guacamole.WebSocketTunnel(tunnelUrl); const client = new Guacamole.Client(tunnel); @@ -32,9 +40,15 @@ const labels = ["Idle", "Verbinde ...", "Warte auf Server ...", "Verbunden", "Trenne ...", "Getrennt"]; statusEl.textContent = labels[state] || `Status ${state}`; }; - client.onerror = (err) => { - statusEl.textContent = "Fehler: " + (err.message || "unbekannt"); + const showError = (err) => { + // Der Server gibt den Abbruchgrund als WebSocket-Close-Reason mit + // (siehe _reject() in app/rdp_proxy/ws_tunnel.py); guacamole-common-js + // reicht ihn als Guacamole.Status.message hierher durch. + statusEl.textContent = "Fehler: " + ((err && err.message) || "unbekannt"); + statusEl.classList.add("error"); }; + client.onerror = showError; + tunnel.onerror = showError; client.onclipboard = (stream, mimetype) => { if (!mimetype.startsWith("text/")) return; @@ -48,7 +62,7 @@ }; }; - client.connect(); + client.connect(connectParams); window.addEventListener("beforeunload", () => client.disconnect()); diff --git a/templates/admin.html b/templates/admin.html index b93cbe5..e7b19eb 100644 --- a/templates/admin.html +++ b/templates/admin.html @@ -250,6 +250,8 @@
+ + @@ -299,6 +301,7 @@
+
@@ -372,12 +375,17 @@ +
+ + +

Beginnt der private Schluessel mit "-----BEGIN OPENSSH PRIVATE KEY-----" und wurde er mit einer Passphrase erzeugt, muss sie hier hinterlegt werden -- sonst laesst sich der Schluessel beim Verbindungsaufbau nicht laden. Sie wird wie das Schluesselmaterial selbst AES-256-GCM-verschluesselt gespeichert und niemals wieder ausgegeben. Leer lassen, wenn der Schluessel unverschluesselt ist.

+

Vorhandene SSH-Keys

- +
IDLabelTypBesitzerMandantErstelltRotiert
IDLabelTypBesitzerMandantPassphraseErstelltRotiert
@@ -396,6 +404,17 @@ +

Passphrase

+

+

Traegt die Passphrase zu dem bereits gespeicherten Schluessel nach, ohne ihn neu hochzuladen. Sie wird sofort gegen das hinterlegte Schluesselmaterial geprueft -- passt sie nicht, wird nichts gespeichert. Leeres Feld + "Passphrase entfernen" loescht eine hinterlegte Passphrase (nur sinnvoll bei unverschluesseltem Schluessel).

+
+
+ + +
+ + +

Schluessel rotieren

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

@@ -416,6 +435,10 @@ +
+ + +
diff --git a/tests/test_phase10.py b/tests/test_phase10.py new file mode 100644 index 0000000..2b43e52 --- /dev/null +++ b/tests/test_phase10.py @@ -0,0 +1,313 @@ +""" +Tests fuer die in "Phase 10" behobenen Ursachen dafuer, dass weder SSH- noch +RDP-Sitzungen zustande kamen: + + 1) SSH: der hinterlegte private Schluessel war passphrasegeschuetzt. + asyncssh.import_private_key() wurde ohne Passphrase aufgerufen und warf + KeyImportError("Passphrase must be specified to import encrypted private + keys") -- ein ValueError, KEIN asyncssh.Error, der deshalb an allen + Fehlerbehandlungen vorbei aus der WS-Route hinauslief. Jetzt: Passphrase + wird KEK-verschluesselt mitgespeichert (Migration 0009), beim Laden + uebergeben und beim Anlegen/Rotieren/Nachtragen sofort geprueft (400). + 2) RDP: load_host() selektierte rdp_username/rdp_domain/rdp_require_nla/ + clipboard_enabled gar nicht -- build_rdp_params() baute daraus eine + connect-Instruktion ohne Benutzernamen. Zusaetzlich war ignore-cert hart + auf "false" verdrahtet (jetzt hosts.rdp_ignore_cert, Default an). + 3) Guacamole-Protokoll: Laengenangaben zaehlen ZEICHEN, nicht Bytes -- ein + Umlaut (etwa im RDP-Passwort) verschob sonst den gesamten Datenstrom. + +Die WebSocket-Routen selbst lassen sich mit diesem Testsetup (httpx +ASGITransport, kein WS-Support) nicht end-to-end fahren; geprueft werden hier +die Admin-Endpunkte, die DB-Schicht und die reine Protokoll-/Parameterlogik. +""" +from __future__ import annotations + +import pytest + +from cryptography.hazmat.primitives import serialization +from cryptography.hazmat.primitives.asymmetric import ed25519 + +PASSPHRASE = "Testpassphrase-2026" + + +def _plain_key_pem() -> str: + key = ed25519.Ed25519PrivateKey.generate() + return key.private_bytes( + serialization.Encoding.PEM, serialization.PrivateFormat.OpenSSH, + serialization.NoEncryption(), + ).decode() + + +def _encrypted_key_pem(passphrase: str = PASSPHRASE) -> str: + """Passphrasegeschuetzter Schluessel. + + Bevorzugt das OpenSSH-Format (das, was `ssh-keygen` erzeugt und was der + gemeldete Fehler betraf). Dessen Verschluesselung setzt das optionale + bcrypt-Modul voraus; fehlt es, wird auf PKCS#8 ausgewichen -- asyncssh + liest beide, und der hier gepruefte Codepfad (fehlende bzw. falsche + Passphrase) ist fuer beide identisch.""" + from cryptography.exceptions import UnsupportedAlgorithm + + key = ed25519.Ed25519PrivateKey.generate() + enc = serialization.BestAvailableEncryption(passphrase.encode()) + try: + return key.private_bytes( + serialization.Encoding.PEM, serialization.PrivateFormat.OpenSSH, enc + ).decode() + except UnsupportedAlgorithm: # pragma: no cover - haengt an der Umgebung + return key.private_bytes( + serialization.Encoding.PEM, serialization.PrivateFormat.PKCS8, enc + ).decode() + + +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") + + +# --------------------------------------------------------------------------- +# 1) SSH-Schluessel mit Passphrase +# --------------------------------------------------------------------------- + +@pytest.mark.asyncio +async def test_create_ssh_key_rejects_encrypted_key_without_passphrase(client): + from app.db import get_db + + conn = get_db() + await _create_user(conn, "p10_admin1", "Correct-Horse-Battery-Staple-A1", is_admin=True) + await _login_full(client, "p10_admin1", "Correct-Horse-Battery-Staple-A1") + + resp = await client.post("/admin/ssh-keys", json={ + "label": "verschluesselt-ohne-passphrase", + "private_key_pem": _encrypted_key_pem(), + "public_key": "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5 test", + "key_type": "ed25519", + }) + # Vorher: 201 -- der Fehler fiel erst beim ersten Verbindungsversuch auf. + assert resp.status_code == 400, resp.text + assert "passphrasegeschuetzt" in resp.json()["detail"].lower() + + +@pytest.mark.asyncio +async def test_create_ssh_key_with_correct_passphrase_succeeds_and_is_stored_encrypted(client): + from app.db import get_db + from app.security.crypto import decrypt_secret + + conn = get_db() + await _create_user(conn, "p10_admin2", "Correct-Horse-Battery-Staple-A2", is_admin=True) + await _login_full(client, "p10_admin2", "Correct-Horse-Battery-Staple-A2") + + resp = await client.post("/admin/ssh-keys", json={ + "label": "verschluesselt-mit-passphrase", + "private_key_pem": _encrypted_key_pem(), + "public_key": "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5 test", + "key_type": "ed25519", + "passphrase": PASSPHRASE, + }) + assert resp.status_code == 201, resp.text + key_id = resp.json()["id"] + + row = await (await conn.execute( + "SELECT passphrase_enc FROM ssh_keys WHERE id = ?", (key_id,) + )).fetchone() + assert row[0] is not None, "Passphrase wurde nicht gespeichert" + assert PASSPHRASE.encode() not in row[0], "Passphrase liegt im Klartext in der DB" + assert decrypt_secret(row[0], associated_data=b"ssh_key_passphrase") == PASSPHRASE.encode() + + # Die Liste verraet nur, DASS eine Passphrase hinterlegt ist. + listing = (await client.get("/admin/ssh-keys")).json() + entry = next(k for k in listing if k["id"] == key_id) + assert entry["has_passphrase"] is True + assert "passphrase" not in entry, "Endpunkt darf die Passphrase selbst nie zurueckgeben" + + +@pytest.mark.asyncio +async def test_wrong_passphrase_is_rejected_and_correct_one_can_be_added_later(client): + from app.db import get_db + + conn = get_db() + await _create_user(conn, "p10_admin3", "Correct-Horse-Battery-Staple-A3", is_admin=True) + await _login_full(client, "p10_admin3", "Correct-Horse-Battery-Staple-A3") + + pem = _encrypted_key_pem() + resp = await client.post("/admin/ssh-keys", json={ + "label": "nachtragen", "private_key_pem": pem, + "public_key": "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5 test", + "key_type": "ed25519", "passphrase": PASSPHRASE, + }) + assert resp.status_code == 201, resp.text + key_id = resp.json()["id"] + + # Falsche Passphrase nachtragen -> abgelehnt, nichts geaendert + resp = await client.put(f"/admin/ssh-keys/{key_id}", json={"passphrase": "falsch"}) + assert resp.status_code == 400, resp.text + + # Korrekte Passphrase nachtragen -> akzeptiert + resp = await client.put(f"/admin/ssh-keys/{key_id}", json={"passphrase": PASSPHRASE}) + assert resp.status_code == 200, resp.text + + # Ein Update ohne das Feld darf die Passphrase NICHT loeschen + resp = await client.put(f"/admin/ssh-keys/{key_id}", json={"label": "umbenannt"}) + assert resp.status_code == 200, resp.text + row = await (await conn.execute( + "SELECT passphrase_enc FROM ssh_keys WHERE id = ?", (key_id,) + )).fetchone() + assert row[0] is not None, "Label-Update hat die Passphrase geloescht" + + +@pytest.mark.asyncio +async def test_load_private_key_for_host_uses_stored_passphrase(client): + """Der konkret gemeldete Fehlerfall, komplett ueber die DB-Schicht.""" + from app.db import get_db + from app.ssh_proxy.proxy import PrivateKeyUnusableError, load_private_key_for_host + from app.security.crypto import encrypt_secret + + conn = get_db() + cursor = await conn.execute("INSERT INTO host_groups (name) VALUES ('p10-group')") + hg_id = cursor.lastrowid + cursor = await conn.execute( + "INSERT INTO hosts (host_group_id, hostname, address, protocol, port, os_type, ssh_username) " + "VALUES (?, 'p10-host', '10.10.0.1', 'ssh', 22, 'linux', 'root')", (hg_id,), + ) + host_id = cursor.lastrowid + + pem = _encrypted_key_pem() + cursor = await conn.execute( + "INSERT INTO ssh_keys (label, private_key_enc, public_key, key_type, passphrase_enc) " + "VALUES ('k', ?, 'pub', 'ed25519', ?)", + (encrypt_secret(pem.encode(), associated_data=b"ssh_private_key"), + encrypt_secret(PASSPHRASE.encode(), associated_data=b"ssh_key_passphrase")), + ) + key_id = cursor.lastrowid + await conn.execute( + "INSERT INTO host_ssh_key_map (host_id, ssh_key_id) VALUES (?, ?)", (host_id, key_id) + ) + await conn.commit() + + # Mit hinterlegter Passphrase laedt der Schluessel. + assert await load_private_key_for_host(conn, host_id) is not None + + # Ohne sie: klare Meldung statt eines nach aussen durchschlagenden + # KeyImportError (das war der gemeldete Abbruch ohne Fehlermeldung). + await conn.execute("UPDATE ssh_keys SET passphrase_enc = NULL WHERE id = ?", (key_id,)) + await conn.commit() + with pytest.raises(PrivateKeyUnusableError) as excinfo: + await load_private_key_for_host(conn, host_id) + assert "passphrasegeschuetzt" in str(excinfo.value).lower() + + +# --------------------------------------------------------------------------- +# 2) RDP-Verbindungsparameter +# --------------------------------------------------------------------------- + +@pytest.mark.asyncio +async def test_load_host_returns_rdp_columns(client): + """load_host() muss genau die Felder liefern, die build_rdp_params() liest.""" + from app.db import get_db + from app.ssh_proxy.proxy import load_host + + conn = get_db() + cursor = await conn.execute("INSERT INTO host_groups (name) VALUES ('p10-rdp-group')") + hg_id = cursor.lastrowid + cursor = await conn.execute( + "INSERT INTO hosts (host_group_id, hostname, address, protocol, port, os_type, " + "rdp_username, rdp_domain) VALUES (?, 'win-ts01', '10.10.0.5', 'rdp', 3389, 'windows', " + "'Administrator', 'CORP')", (hg_id,), + ) + await conn.commit() + host = await load_host(conn, cursor.lastrowid) + + for field in ("rdp_username", "rdp_domain", "rdp_require_nla", "clipboard_enabled", + "rdp_ignore_cert"): + assert field in host, f"load_host() liefert '{field}' nicht" + assert host["rdp_username"] == "Administrator" + assert host["rdp_domain"] == "CORP" + assert host["rdp_ignore_cert"] is True # Default aus Migration 0009 + + +def test_build_rdp_params_passes_username_and_cert_policy(): + from app.rdp_proxy.guacd_client import GuacamoleProtocolError, build_rdp_params + + host = { + "id": 1, "hostname": "win-ts01", "address": "10.10.0.5", "port": 3389, + "file_transfer_enabled": True, "rdp_username": "Administrator", + "rdp_domain": "CORP", "rdp_require_nla": True, "clipboard_enabled": True, + "rdp_ignore_cert": True, + } + params = build_rdp_params(host, "geheim") + assert params["username"] == "Administrator" + assert params["domain"] == "CORP" + assert params["security"] == "nla" + assert params["ignore-cert"] == "true" + assert params["disable-copy"] == "false" + + strict = build_rdp_params(dict(host, rdp_ignore_cert=False, clipboard_enabled=False), "geheim") + assert strict["ignore-cert"] == "false" + assert strict["disable-copy"] == "true" and strict["disable-paste"] == "true" + + # Kein Benutzername -> klare Meldung statt stiller Fehlanmeldung am Ziel + with pytest.raises(GuacamoleProtocolError): + build_rdp_params(dict(host, rdp_username=""), "geheim") + + # Unvollstaendiger Hostdatensatz (der alte load_host()-Zustand) + with pytest.raises(GuacamoleProtocolError): + build_rdp_params({k: host[k] for k in ("id", "hostname", "address", "port", + "file_transfer_enabled")}, "geheim") + + +# --------------------------------------------------------------------------- +# 3) Guacamole-Protokoll: Laengen zaehlen Zeichen, nicht Bytes +# --------------------------------------------------------------------------- + +def test_instruction_length_counts_characters_not_bytes(): + from app.rdp_proxy.guacd_client import encode_instruction, parse_instruction_text + + # "Grüße" sind 5 Zeichen, aber 7 Bytes in UTF-8. + text = encode_instruction("clipboard", "Grüße") + assert text == "9.clipboard,5.Grüße;", text + assert parse_instruction_text(text) == ["clipboard", "Grüße"] + + +@pytest.mark.asyncio +async def test_read_instruction_reads_multibyte_characters(): + import asyncio + + from app.rdp_proxy.guacd_client import encode_instruction, read_instruction + + reader = asyncio.StreamReader() + reader.feed_data(encode_instruction("name", "Bürö-Süd").encode("utf-8")) + reader.feed_eof() + assert await read_instruction(reader) == ["name", "Bürö-Süd"] + + +def test_internal_opcode_instruction_is_parseable(): + """guacamole-common-js sendet ping/UUID mit leerem Opcode ('0.,...').""" + from app.rdp_proxy.guacd_client import parse_instruction_text + from app.rdp_proxy.ws_tunnel import INTERNAL_DATA_OPCODE + + parsed = parse_instruction_text("0.,4.ping,13.1755721410123;") + assert parsed[0] == INTERNAL_DATA_OPCODE + assert parsed[1] == "ping"