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

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