connect fix 3

This commit is contained in:
2026-08-20 23:35:42 +02:00
parent fc32b1e4ef
commit bf2a02679d
14 changed files with 956 additions and 49 deletions

View File

@ -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

BIN
_to_delete/jh.tgz Normal file

Binary file not shown.

View File

@ -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}

View File

@ -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;

View File

@ -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):

View File

@ -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",
}

View File

@ -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.<timestamp>;". Diese gehoert dem
# Tunnel, NICHT guacd -- bisher wurde sie unbesehen an guacd
# weitergereicht, das damit nichts anfangen kann. Ein ping wird
# gespiegelt: der Browser bricht den Tunnel nach
# tunnel.receiveTimeout (Standard 15 s) ohne empfangene Daten mit
# "Server timeout" ab, eine stille Sitzung liefe also in einen
# Fehler.
if len(instr) > 1 and instr[1] == "ping":
await websocket.send_text(encode_instruction(*instr))
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,6 +285,11 @@ async def rdp_tunnel(
)
await conn.commit()
try:
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

View File

@ -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:

View File

@ -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]

View File

@ -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).

View File

@ -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);

View File

@ -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());

View File

@ -250,6 +250,8 @@
<div class="checkbox-row"><input type="checkbox" id="hc-clipboard" checked><label for="hc-clipboard">Zwischenablage erlaubt</label></div>
<div class="checkbox-row"><input type="checkbox" id="hc-filetransfer" checked><label for="hc-filetransfer">Dateitransfer erlaubt</label></div>
<div class="checkbox-row hidden" id="hc-nla-box"><input type="checkbox" id="hc-nla" checked><label for="hc-nla">RDP: NLA erforderlich</label></div>
<div class="checkbox-row hidden" id="hc-ignorecert-box"><input type="checkbox" id="hc-ignorecert" checked><label for="hc-ignorecert">RDP: Serverzertifikat nicht pruefen</label></div>
<p class="hint full-width hidden" id="hc-ignorecert-hint">Windows-Ziele ohne eigene PKI weisen sich mit einem selbstsignierten Zertifikat aus; guacd bricht die Verbindung sonst ab, bevor ein Bild entsteht. Nur deaktivieren, wenn das Ziel ein Zertifikat aus einer dem Jumphost bekannten CA verwendet.</p>
<button type="submit">Verbindung anlegen</button>
</form>
</div>
@ -299,6 +301,7 @@
<div class="checkbox-row"><input type="checkbox" id="he-clipboard"><label for="he-clipboard">Zwischenablage erlaubt</label></div>
<div class="checkbox-row"><input type="checkbox" id="he-filetransfer"><label for="he-filetransfer">Dateitransfer erlaubt</label></div>
<div class="checkbox-row hidden" id="he-nla-box"><input type="checkbox" id="he-nla"><label for="he-nla">RDP: NLA erforderlich</label></div>
<div class="checkbox-row hidden" id="he-ignorecert-box"><input type="checkbox" id="he-ignorecert"><label for="he-ignorecert">RDP: Serverzertifikat nicht pruefen</label></div>
<div class="checkbox-row"><input type="checkbox" id="he-active"><label for="he-active">Aktiv (verbindbar)</label></div>
<button type="submit">Speichern</button>
</form>
@ -372,12 +375,17 @@
<label for="skc-public">Oeffentlicher Schluessel</label>
<textarea id="skc-public" required></textarea>
</div>
<div class="full-width">
<label for="skc-passphrase">Passphrase (nur bei verschluesseltem Schluessel)</label>
<input type="password" id="skc-passphrase" maxlength="1024" autocomplete="new-password">
<p class="hint">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.</p>
</div>
<button type="submit">Anlegen</button>
</form>
<h3>Vorhandene SSH-Keys</h3>
<div class="table-wrap">
<table class="data-table" id="ssh-keys-table">
<thead><tr><th>ID</th><th>Label</th><th>Typ</th><th>Besitzer</th><th>Mandant</th><th>Erstellt</th><th>Rotiert</th><th></th></tr></thead>
<thead><tr><th>ID</th><th>Label</th><th>Typ</th><th>Besitzer</th><th>Mandant</th><th>Passphrase</th><th>Erstellt</th><th>Rotiert</th><th></th></tr></thead>
<tbody></tbody>
</table>
</div>
@ -396,6 +404,17 @@
</div>
<button type="submit">Speichern (nur Label/Besitzer)</button>
</form>
<h3>Passphrase</h3>
<p class="hint" id="ske-passphrase-state"></p>
<p class="hint">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).</p>
<form id="ssh-key-passphrase-form" class="form-grid">
<div class="full-width">
<label for="skp-passphrase">Passphrase</label>
<input type="password" id="skp-passphrase" maxlength="1024" autocomplete="new-password">
</div>
<button type="submit">Passphrase speichern</button>
<button type="button" id="skp-clear-btn" class="btn-secondary">Passphrase entfernen</button>
</form>
<h3>Schluessel rotieren</h3>
<p class="hint">Ersetzt das Schluesselmaterial vollstaendig (neuer privater + oeffentlicher Schluessel). Bereits zugeordnete Hosts nutzen danach automatisch den neuen Schluessel.</p>
<form id="ssh-key-rotate-form" class="form-grid">
@ -416,6 +435,10 @@
<label for="skr-public">Neuer oeffentlicher Schluessel</label>
<textarea id="skr-public" required></textarea>
</div>
<div class="full-width">
<label for="skr-passphrase">Passphrase des neuen Schluessels (optional)</label>
<input type="password" id="skr-passphrase" maxlength="1024" autocomplete="new-password">
</div>
<button type="submit" class="btn-secondary">Rotieren</button>
</form>
</div>

313
tests/test_phase10.py Normal file
View File

@ -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"