connect fix 4

This commit is contained in:
2026-08-21 06:14:10 +02:00
parent bf2a02679d
commit 5e506c9921
17 changed files with 1266 additions and 162 deletions

View File

@ -44,6 +44,23 @@ class HostKeyDiscoveryError(Exception):
super().__init__(f"Host-Key-Ermittlung fuer Host {host_id} fehlgeschlagen: {reason}")
class HostKeyNotPinnedError(Exception):
"""Fuer den Host ist kein Host-Key hinterlegt.
Bewusst ein harter Abbruch statt Trust-on-First-Use: ohne gepinnten
Schluessel laesst sich nicht feststellen, ob am anderen Ende das
erwartete System antwortet (Konzept 4.2/6.4).
"""
def __init__(self, host_id: int) -> None:
self.host_id = host_id
super().__init__(
"Fuer diesen Host ist kein SSH-Host-Key hinterlegt. Im Adminbereich unter "
"'Server' -> Host-Key ermitteln einmalig uebernehmen (bewusste "
"Vertrauensentscheidung), danach ist die Verbindung moeglich."
)
class PrivateKeyUnusableError(Exception):
"""Der hinterlegte private Schluessel laesst sich nicht laden.
@ -62,6 +79,35 @@ class PrivateKeyUnusableError(Exception):
super().__init__(message)
BCRYPT_MISSING_MESSAGE = (
"Der private Schluessel ist passphrasegeschuetzt im OpenSSH-Format, auf dem "
"Server fehlt aber das Python-Modul 'bcrypt' -- asyncssh kann die "
"Schluesseldatei deshalb gar nicht erst entschluesseln. Weder der Schluessel "
"noch die hinterlegte Passphrase sind schuld. Abhilfe: 'bcrypt' im "
"Virtualenv der Anwendung nachinstallieren (pip install -r requirements.txt "
"bzw. Ansible-Rolle python_runtime erneut ausrollen) und den Dienst "
"jumphost-app neu starten."
)
def bcrypt_kdf_available() -> bool:
"""Ist das (formal optionale) bcrypt-Modul mit KDF-Unterstuetzung da?
asyncssh und cryptography benoetigen es fuer die bcrypt_pbkdf-Ableitung,
mit der OpenSSH verschluesselte Schluesseldateien schuetzt -- also fuer
genau das Format, das 'ssh-keygen' standardmaessig erzeugt. Fehlt das
Modul, scheitert JEDER passphrasegeschuetzte Schluessel mit
"OpenSSH private key encryption requires bcrypt with KDF support", obwohl
Schluessel und Passphrase korrekt sind. Es stand bis Phase 11 nicht in
requirements.txt und fehlte deshalb im ausgerollten Virtualenv.
"""
try:
import bcrypt # noqa: PLC0415 - bewusst lazy, das Modul ist optional
except ImportError:
return False
return hasattr(bcrypt, "kdf")
def import_private_key_material(
pem: bytes | str, passphrase: bytes | str | None = None
) -> asyncssh.SSHKey:
@ -96,6 +142,18 @@ def import_private_key_material(
# wird hier korrekterweise nicht mitgefangen.
text = str(exc)
if "bcrypt" in text.lower():
# Umgebungsfehler, kein Benutzerfehler: ohne bcrypt kann asyncssh
# OpenSSH-verschluesselte Schluessel grundsaetzlich nicht lesen.
# Ohne diese Uebersetzung landete die englische asyncssh-Meldung
# ungefiltert im Terminalfenster und sah nach einem kaputten
# Schluessel aus.
logger.error(
"Import eines privaten Schluessels fehlgeschlagen: bcrypt fehlt im "
"Virtualenv (asyncssh-Meldung: %s)", text,
)
raise PrivateKeyUnusableError(BCRYPT_MISSING_MESSAGE) from exc
if "Passphrase must be specified" in text:
raise PrivateKeyUnusableError(
"Der private Schluessel ist passphrasegeschuetzt, es ist aber keine "
@ -123,9 +181,41 @@ def import_private_key_material(
) from exc
#: Fehler, die den Aufbau einer SSH-Sitzung verhindern und dem angemeldeten
#: Benutzer im Klartext gezeigt werden duerfen (keine Geheimnisse, nur
#: Konfigurations-/Erreichbarkeitsaussagen). Wird von terminal_ws.py und
#: sftp.py gemeinsam benutzt, damit beide Wege dieselbe Meldung zeigen.
SSH_SETUP_ERRORS: tuple[type[Exception], ...] = (
HostNotConfiguredError,
HostKeyNotPinnedError,
HostKeyMismatchError,
HostKeyDiscoveryError,
PrivateKeyUnusableError,
)
def describe_connection_error(exc: Exception) -> str:
"""Deutsche Klartextmeldung zu einem Verbindungsfehler."""
if isinstance(exc, HostKeyMismatchError):
return (
"Der SSH-Host-Key des Ziels weicht vom hinterlegten ab -- die Verbindung wurde "
"abgebrochen, bevor Zugangsdaten gesendet wurden. "
f"Erwartet: {exc.expected}; angeboten: {exc.observed}. Wurde das Zielsystem neu "
"aufgesetzt, den Host-Key im Adminbereich erneut ermitteln."
)
if isinstance(exc, HostKeyDiscoveryError):
return f"Das Ziel ist nicht erreichbar: {exc.reason}"
return str(exc)
class _PinnedHostKeyClient(asyncssh.SSHClient):
"""Erzwingt Strict Host Key Checking gegen einen fest hinterlegten
SHA-256-Fingerprint. Kein automatisches Trust-on-First-Use (TOFU)."""
"""Zweite Verteidigungslinie fuer Strict Host Key Checking.
ACHTUNG: asyncssh ruft validate_host_public_key() nur auf, wenn
known_hosts NICHT None ist -- diese Klasse allein hat das Pinning also
nicht durchgesetzt (siehe _verified_host_key(), das die eigentliche
Pruefung vor der Anmeldung uebernimmt). Sie bleibt erhalten, weil sie in
Konfigurationen mit gesetztem known_hosts greift und nie schadet."""
def __init__(self, expected_fingerprint: str | None, *, discovery_mode: bool = False) -> None:
self.expected_fingerprint = expected_fingerprint
@ -147,7 +237,8 @@ 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, "
"rdp_username, rdp_domain, rdp_require_nla, clipboard_enabled, rdp_ignore_cert "
"rdp_username, rdp_domain, rdp_require_nla, clipboard_enabled, rdp_ignore_cert, "
"ssh_host_key "
"FROM hosts WHERE id = ? AND is_active = 1",
(host_id,),
)
@ -166,6 +257,11 @@ async def load_host(conn: aiosqlite.Connection, host_id: int) -> dict:
# dauerhaft gesperrt, unabhaengig von der Hostkonfiguration).
"rdp_username", "rdp_domain", "rdp_require_nla", "clipboard_enabled",
"rdp_ignore_cert",
# Vollstaendiger Host-Key (Migration 0010): erlaubt es, den vom Ziel
# angebotenen Schluessel VOR der Anmeldung byteweise zu vergleichen --
# mit einem Fingerprint allein geht das nicht, den sieht man erst,
# wenn der Server seinen Schluessel bereits praesentiert hat.
"ssh_host_key",
)
host = dict(zip(keys, row))
for flag in ("file_transfer_enabled", "rdp_require_nla", "clipboard_enabled", "rdp_ignore_cert"):
@ -173,17 +269,20 @@ async def load_host(conn: aiosqlite.Connection, host_id: int) -> dict:
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.
async def load_ssh_credential_for_host(
conn: aiosqlite.Connection, host_id: int
) -> tuple[asyncssh.SSHKey, str | None]:
"""Laedt den dem Host zugeordneten Schluessel, entschluesselt ihn und gibt
ihn zusammen mit dem am Schluessel hinterlegten Benutzernamen zurueck.
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.
Der Benutzername gehoert seit Migration 0010 zu den Zugangsdaten
(ssh_keys.username) und nicht mehr zum Host: er ist Teil der Anmeldung,
nicht der Beschreibung des Zielsystems. Ist zu dem Schluessel eine
Passphrase hinterlegt (ssh_keys.passphrase_enc, Migration 0009), wird sie
ebenfalls entschluesselt und an asyncssh uebergeben.
"""
cursor = await conn.execute(
"SELECT sk.private_key_enc, sk.passphrase_enc FROM ssh_keys sk "
"SELECT sk.private_key_enc, sk.passphrase_enc, sk.username FROM ssh_keys sk "
"JOIN host_ssh_key_map m ON m.ssh_key_id = sk.id "
"WHERE m.host_id = ? LIMIT 1",
(host_id,),
@ -195,8 +294,9 @@ async def load_private_key_for_host(conn: aiosqlite.Connection, host_id: int) ->
passphrase = (
decrypt_secret(row[1], associated_data=b"ssh_key_passphrase") if row[1] else None
)
username = (row[2] or "").strip() or None
try:
return import_private_key_material(pem, passphrase)
return import_private_key_material(pem, passphrase), username
finally:
# Bestpraxis: Referenzen auf das Klartextmaterial so schnell wie
# moeglich loslassen (Konzept 6.4).
@ -204,78 +304,173 @@ async def load_private_key_for_host(conn: aiosqlite.Connection, host_id: int) ->
del passphrase
async def load_private_key_for_host(conn: aiosqlite.Connection, host_id: int) -> asyncssh.SSHKey:
"""Rueckwaertskompatible Fassung ohne Benutzername (siehe
load_ssh_credential_for_host)."""
key, _username = await load_ssh_credential_for_host(conn, host_id)
return key
def resolve_ssh_username(host: dict, credential_username: str | None) -> str:
"""Ermittelt den Anmeldenamen fuer eine SSH-Sitzung.
Vorrang hat der Benutzername der Zugangsdaten (ssh_keys.username). Der
alte Wert am Host (hosts.ssh_username) wird nur noch als Fallback fuer
Datensaetze gelesen, die vor Migration 0010 angelegt wurden -- neue
Schreibpfade und die Oberflaeche pflegen ihn nicht mehr.
"""
username = (credential_username or "").strip() or (host.get("ssh_username") or "").strip()
if not username:
raise HostNotConfiguredError(
"Fuer diesen Host ist kein SSH-Benutzername hinterlegt. Der Benutzername "
"gehoert zum SSH-Key (Adminbereich -> Zugangsdaten -> SSH-Key bearbeiten)."
)
return username
async def _verified_host_key(
conn: aiosqlite.Connection, host: dict
) -> asyncssh.SSHKey:
"""Holt den Host-Key des Ziels OHNE Anmeldung und prueft ihn gegen den
hinterlegten Schluessel bzw. Fingerprint.
Warum ueberhaupt ein eigener Schritt: asyncssh ruft
SSHClient.validate_host_public_key() NUR auf, wenn known_hosts nicht None
ist -- mit known_hosts=None ist die Host-Key-Pruefung komplett abgeschaltet
und der Callback wird nie erreicht. Genau diese Kombination war hier im
Einsatz, das Pinning also faktisch wirkungslos (aufgefallen, weil aus dem
gleichen Grund auch die Host-Key-Ermittlung keinen Fingerprint mehr sah).
asyncssh.get_server_host_key() fuehrt nur den Key-Exchange durch und
liefert den Schluessel zurueck -- damit laesst sich VOR jeder Anmeldung
entscheiden, ob das Gegenueber das erwartete System ist.
"""
expected_fingerprint = (host.get("ssh_host_key_fingerprint") or "").strip()
if not expected_fingerprint:
raise HostKeyNotPinnedError(host["id"])
try:
observed_key = await asyncssh.get_server_host_key(
host["address"], port=host["port"], connect_timeout=10
)
except (asyncssh.Error, OSError) as exc:
raise HostKeyDiscoveryError(host["id"], str(exc)) from exc
if observed_key is None:
raise HostKeyDiscoveryError(host["id"], "Das Ziel hat keinen Host-Key gesendet")
observed_fingerprint = observed_key.get_fingerprint("sha256")
if observed_fingerprint != expected_fingerprint:
raise HostKeyMismatchError(expected_fingerprint, observed_fingerprint)
stored = (host.get("ssh_host_key") or "").strip()
observed_pub = observed_key.export_public_key("openssh").decode().strip()
if stored:
# Schaerfer als der Fingerprintvergleich: byteweise Gleichheit des
# gesamten Schluessels.
if _public_key_body(stored) != _public_key_body(observed_pub):
raise HostKeyMismatchError(expected_fingerprint, observed_fingerprint)
else:
# Altbestand (vor Migration 0010 ermittelt): Schluessel nachtragen,
# nachdem er gegen den gepinnten Fingerprint geprueft wurde.
await conn.execute(
"UPDATE hosts SET ssh_host_key = ? WHERE id = ?", (observed_pub, host["id"])
)
await conn.commit()
return observed_key
def _public_key_body(openssh_line: str) -> tuple[str, str]:
"""(Algorithmus, Base64-Material) einer OpenSSH-Zeile -- ohne Kommentar,
der sich zwischen Exporten unterscheiden darf."""
parts = openssh_line.split()
if len(parts) < 2:
return ("", openssh_line.strip())
return (parts[0], parts[1])
async def connect_to_host(conn: aiosqlite.Connection, host_id: int) -> asyncssh.SSHClientConnection:
host = await load_host(conn, host_id)
if host["protocol"] != "ssh":
raise HostNotConfiguredError("Host ist kein SSH-Ziel")
private_key = await load_private_key_for_host(conn, host_id)
# Reihenfolge mit Absicht: erst das Ziel identifizieren, dann erst
# Schluesselmaterial entschluesseln und eine Anmeldung versuchen.
trusted_key = await _verified_host_key(conn, host)
private_key, credential_username = await load_ssh_credential_for_host(conn, host_id)
username = resolve_ssh_username(host, credential_username)
client_factory = lambda: _PinnedHostKeyClient(host["ssh_host_key_fingerprint"])
try:
connection = await asyncssh.connect(
host["address"],
port=host["port"],
username=host["ssh_username"],
username=username,
client_keys=[private_key],
known_hosts=None, # Validierung erfolgt ausschliesslich ueber validate_host_public_key
known_hosts=None,
client_factory=client_factory,
connect_timeout=10,
)
except asyncssh.Error as exc:
logger.warning("SSH-Verbindungsfehler zu Host %s: %s", host_id, exc)
raise
# Zweite Haelfte des Pinnings: der Schluessel dieser Sitzung muss derselbe
# sein wie der eben gepruefte. Faengt den (sehr schmalen) Fall ab, dass
# zwischen Pruefung und Sitzungsaufbau ein anderes System antwortet.
session_key = connection.get_server_host_key()
session_fingerprint = session_key.get_fingerprint("sha256") if session_key else None
if session_fingerprint != trusted_key.get_fingerprint("sha256"):
connection.abort()
logger.error(
"Host-Key-Wechsel zwischen Pruefung und Sitzungsaufbau bei Host %s", host_id
)
raise HostKeyMismatchError(host["ssh_host_key_fingerprint"], session_fingerprint)
return connection
async def discover_and_store_host_key(
conn: aiosqlite.Connection, host_id: int, *, admin_user_id: int
) -> str:
"""Verbindet EINMALIG ohne Pinning, um den Host-Key-Fingerprint zu erfassen
und in der DB zu hinterlegen. Nur ueber einen dedizierten, admin-only
Endpunkt aufrufbar -- jeder Aufruf ist eine bewusste Vertrauensentscheidung
und wird im Audit-Log als solche vermerkt (siehe admin/routes.py).
"""Holt den Host-Key des Ziels EINMALIG und hinterlegt ihn (Fingerprint und
vollstaendigen Schluessel) in der DB. Nur ueber einen dedizierten,
admin-only Endpunkt aufrufbar -- jeder Aufruf ist eine bewusste
Vertrauensentscheidung und wird im Audit-Log als solche vermerkt (siehe
admin/routes.py).
Bugfix (Fehler 500 bei 'Host-Key ermitteln'): asyncssh.connect() fuehrt
nach dem Key-Exchange (bei dem validate_host_public_key() den Fingerprint
bereits erfasst) automatisch die Authentifizierung durch -- ohne
hinterlegten Client-Key/Passwort schlaegt die IMMER fehl (asyncssh.Error,
z.B. PermissionDenied), obwohl der Fingerprint laengst bekannt ist. Diese
fuer die reine Discovery irrelevante Auth-Fehlermeldung wurde bisher NICHT
abgefangen und riss als unbehandelte Exception bis zu FastAPI durch ->
500 Internal Server Error bei praktisch jedem Aufruf. Fix: Verbindungs-
fehler abfangen; wenn dabei bereits ein Fingerprint beobachtet wurde, gilt
die Discovery als erfolgreich. Nur wenn wirklich KEIN Fingerprint erfasst
wurde (Host nicht erreichbar, DNS-Fehler, Timeout -- also ein Fehler VOR
dem Key-Exchange), ist es ein echter Fehler (HostKeyDiscoveryError)."""
Bugfix (Meldung "Ziel nicht erreichbar: Permission denied for user ..."):
Die Ermittlung lief bisher ueber einen vollstaendigen
asyncssh.connect()-Versuch und las den Fingerprint aus dem Callback
SSHClient.validate_host_public_key(). Diesen Callback ruft asyncssh aber
NUR auf, wenn known_hosts nicht None ist -- mit known_hosts=None ist die
Host-Key-Pruefung abgeschaltet und der Callback wird nie erreicht. Der
Fingerprint blieb deshalb leer, und der voellig erwartbare Auth-Fehler der
anschliessenden Anmeldung ("Permission denied") wurde als "Ziel nicht
erreichbar" gemeldet, obwohl der Key-Exchange laengst erfolgreich war.
Jetzt wird asyncssh.get_server_host_key() verwendet: das fuehrt nur den
Key-Exchange durch, versucht gar keine Anmeldung und liefert den
Schluessel direkt zurueck. Ein Auth-Fehler kann die Ermittlung damit
nicht mehr verhindern."""
host = await load_host(conn, host_id)
if host["protocol"] != "ssh":
raise HostNotConfiguredError("Host-Key-Ermittlung ist nur fuer SSH-Ziele moeglich")
client = _PinnedHostKeyClient(None, discovery_mode=True)
connection = None
try:
connection = await asyncssh.connect(
host["address"], port=host["port"], username=host["ssh_username"],
known_hosts=None, client_factory=lambda: client, connect_timeout=10,
key = await asyncssh.get_server_host_key(
host["address"], port=host["port"], connect_timeout=10
)
except (asyncssh.Error, OSError) as exc:
if client.observed_fingerprint is None:
logger.warning("Host-Key-Ermittlung fuer Host %s fehlgeschlagen: %s", host_id, exc)
raise HostKeyDiscoveryError(host_id, str(exc)) from exc
# Fingerprint wurde bereits waehrend des Key-Exchange erfasst -- der
# anschliessende Auth-Fehler ist fuer die Discovery unschaedlich.
logger.info(
"Host-Key fuer Host %s erfasst (Auth-Phase erwartungsgemaess fehlgeschlagen: %s)",
host_id, exc,
)
finally:
if connection is not None:
connection.close()
logger.warning("Host-Key-Ermittlung fuer Host %s fehlgeschlagen: %s", host_id, exc)
raise HostKeyDiscoveryError(host_id, str(exc)) from exc
if key is None:
raise HostKeyDiscoveryError(host_id, "Das Ziel hat keinen Host-Key gesendet")
fingerprint = client.observed_fingerprint
fingerprint = key.get_fingerprint("sha256")
public_key = key.export_public_key("openssh").decode().strip()
await conn.execute(
"UPDATE hosts SET ssh_host_key_fingerprint = ? WHERE id = ?", (fingerprint, host_id)
"UPDATE hosts SET ssh_host_key_fingerprint = ?, ssh_host_key = ? WHERE id = ?",
(fingerprint, public_key, host_id),
)
await conn.commit()
logger.info(
"Host-Key fuer Host %s durch Admin %s uebernommen: %s", host_id, admin_user_id, fingerprint
)
return fingerprint

View File

@ -17,9 +17,11 @@ 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 (
SSH_SETUP_ERRORS,
HostNotConfiguredError,
PrivateKeyUnusableError,
connect_to_host,
describe_connection_error,
load_host,
)
@ -102,15 +104,13 @@ async def upload_file(
await remote_file.write(data)
finally:
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.
except SSH_SETUP_ERRORS as exc:
# Alles, was den Verbindungsaufbau verhindert und in der Konfiguration
# begruendet ist (fehlender Benutzername/Schluessel, unbrauchbarer
# Schluessel, nicht gepinnter oder abweichender Host-Key, Ziel nicht
# erreichbar): 400 mit Klartext statt eines unbehandelten 500.
logger.warning("Dateitransfer fuer Host %s nicht moeglich: %s", host_id, exc)
raise HTTPException(status.HTTP_400_BAD_REQUEST, str(exc))
raise HTTPException(status.HTTP_400_BAD_REQUEST, describe_connection_error(exc))
await _log_transfer(
conn, user=user, host_id=host_id, client_ip=_client_ip(request), direction="upload",
@ -139,15 +139,13 @@ async def download_file(
data = await remote_file.read()
finally:
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.
except SSH_SETUP_ERRORS as exc:
# Alles, was den Verbindungsaufbau verhindert und in der Konfiguration
# begruendet ist (fehlender Benutzername/Schluessel, unbrauchbarer
# Schluessel, nicht gepinnter oder abweichender Host-Key, Ziel nicht
# erreichbar): 400 mit Klartext statt eines unbehandelten 500.
logger.warning("Dateitransfer fuer Host %s nicht moeglich: %s", host_id, exc)
raise HTTPException(status.HTTP_400_BAD_REQUEST, str(exc))
raise HTTPException(status.HTTP_400_BAD_REQUEST, describe_connection_error(exc))
sha256 = hashlib.sha256(data).hexdigest()
filename = remote_path.rsplit("/", 1)[-1]

View File

@ -24,9 +24,11 @@ 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 (
SSH_SETUP_ERRORS,
HostNotConfiguredError,
PrivateKeyUnusableError,
connect_to_host,
describe_connection_error,
load_host,
)
@ -153,11 +155,18 @@ async def ssh_terminal(websocket: WebSocket, host_id: int):
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:
except SSH_SETUP_ERRORS as exc:
# Alles, was den Sitzungsaufbau verhindert und der Benutzer selbst
# einordnen kann: fehlender Benutzername/Schluessel, nicht gepinnter
# oder abweichender Host-Key, unerreichbares Ziel. Bewusst mit
# Klartextmeldung statt "interner Fehler" -- der Grund steht in der
# Konfiguration, nicht im Code.
logger.warning("SSH-Sitzung %s abgebrochen: %s", session_id, exc)
end_reason = "error"
try:
await websocket.send_json({"type": "error", "message": str(exc)})
await websocket.send_json(
{"type": "error", "message": describe_connection_error(exc)}
)
except Exception:
logger.debug("Fehlermeldung konnte nicht mehr an Client gesendet werden", exc_info=True)
except asyncio.CancelledError: