""" Serverseitiger SSH-Verbindungsaufbau (asyncssh). Zentrales Sicherheitsprinzip (Konzept 4.2/4.4/6.4): der private Schluessel wird pro Verbindung aus der DB geladen, entschluesselt, an asyncssh uebergeben und danach nicht weiter referenziert -- er verlaesst den Serverprozess nie und wird nicht geloggt. Strict Host Key Checking ist Pflicht: ohne gepinnten Fingerprint wird die Verbindung abgelehnt. """ from __future__ import annotations import asyncio import logging import asyncssh import aiosqlite from app.rbac import AmbiguousCredentialError, resolve_credential_for_user_on_host from app.security.crypto import decrypt_secret logger = logging.getLogger("jumphost.ssh_proxy") class HostNotConfiguredError(Exception): pass class HostKeyMismatchError(Exception): def __init__(self, expected: str | None, observed: str | None) -> None: self.expected = expected self.observed = observed super().__init__(f"Host-Key-Mismatch: erwartet={expected!r} beobachtet={observed!r}") class HostKeyDiscoveryError(Exception): """Wird geworfen, wenn beim Host-Key-Discovery-Versuch (siehe discover_and_store_host_key) ueberhaupt KEIN Fingerprint beobachtet werden konnte (TCP/DNS/Timeout-Fehler VOR dem SSH-Key-Exchange) -- im Unterschied zu einem erwarteten Auth-Fehler NACH dem KEX (siehe dort). Der admin-only Endpunkt (admin/routes.py) faengt dies ab und liefert eine saubere 502 statt eines unbehandelten 500.""" def __init__(self, host_id: int, reason: str) -> None: self.host_id = host_id self.reason = reason 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. 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) 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: """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 "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 " "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 def generate_key_material(key_type: str) -> tuple[str, str]: """Erzeugt ein frisches Schluesselpaar serverseitig (asyncssh) fuer den 'Schluessel automatisch generieren'-Button im Adminbereich. Der private Schluessel verlaesst den Server genau EINMAL in der HTTP- Antwort dieses Aufrufs (analog zu einem frisch erstellten API-Token, siehe admin/routes.py::create_api_token) -- gespeichert wird er erst, wenn der Admin danach das SSH-Key-Formular tatsaechlich absendet, und ab dann nur noch AES-256-GCM-verschluesselt (Konzept 6.4). Diese Funktion selbst persistiert nichts.""" if key_type == "ed25519": key = asyncssh.generate_private_key("ssh-ed25519") elif key_type == "rsa-3072": key = asyncssh.generate_private_key("ssh-rsa", key_size=3072) elif key_type == "rsa-4096": key = asyncssh.generate_private_key("ssh-rsa", key_size=4096) else: raise ValueError(f"Automatische Erzeugung nicht unterstuetzt fuer key_type={key_type!r}") private_pem = key.export_private_key("openssh").decode() public_key = key.export_public_key("openssh").decode().strip() return private_pem, public_key #: Fehler, die den Aufbau einer SSH-Sitzung verhindern und dem angemeldeten #: Benutzer im Klartext gezeigt werden duerfen (keine Geheimnisse, nur #: Konfigurations-/Erreichbarkeitsaussagen). Wird von terminal_ws.py und #: sftp.py gemeinsam benutzt, damit beide Wege dieselbe Meldung zeigen. SSH_SETUP_ERRORS: tuple[type[Exception], ...] = ( HostNotConfiguredError, HostKeyNotPinnedError, HostKeyMismatchError, HostKeyDiscoveryError, PrivateKeyUnusableError, # Teil D Schritt 4 (Achse B): mehr als ein fuer den Benutzer nutzbarer # SSH-Key an einem Host -- eine Konfigurationsfrage, keine interne # Ausnahme, gehoert deshalb in dieselbe Klasse wie die Eintraege oben. AmbiguousCredentialError, ) 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}" if isinstance(exc, AmbiguousCredentialError): return ( f"Mehrdeutige Zugangsdaten fuer diesen Host: mehrere freigegebene SSH-Schluessel " f"(IDs {exc.credential_ids}) treffen fuer Sie zu, keine automatische Auswahl " "moeglich. Bitte im Adminbereich bereinigen (ueberzaehligen Schluessel entfernen " "oder Gruppenfreigabe einschraenken)." ) return str(exc) class _PinnedHostKeyClient(asyncssh.SSHClient): """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 self.discovery_mode = discovery_mode self.observed_fingerprint: str | None = None def validate_host_public_key(self, host, addr, port, key) -> bool: # noqa: D102 self.observed_fingerprint = key.get_fingerprint("sha256") if self.discovery_mode: # Nur ueber den expliziten Admin-Discovery-Endpunkt erreichbar, # niemals im regulaeren Verbindungspfad (siehe admin/routes.py). return True if not self.expected_fingerprint: return False return self.observed_fingerprint == self.expected_fingerprint 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, " "ssh_host_key " "FROM hosts WHERE id = ? AND is_active = 1", (host_id,), ) row = await cursor.fetchone() if row is None: raise HostNotConfiguredError(f"Host {host_id} nicht gefunden oder inaktiv") 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", # 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"): host[flag] = bool(host[flag]) return host async def load_ssh_key_credential_for_host( conn: aiosqlite.Connection, host_id: int, *, user_id: int ) -> tuple[asyncssh.SSHKey, str | None] | None: """Laedt den fuer DIESEN Benutzer geltenden, dem Host zugeordneten Schluessel, entschluesselt ihn und gibt ihn zusammen mit dem am Schluessel hinterlegten Benutzernamen zurueck. Gibt None zurueck (statt zu werfen), wenn kein Schluessel zutrifft -- der Aufrufer (connect_to_host) faellt dann auf ein SSH-Passwort zurueck, falls eines hinterlegt ist (Migration 0011). Seit Teil D Schritt 4 (Achse B) laeuft die Auswahl NICHT mehr blind ueber den Host allein: app.rbac.resolve_credential_for_user_on_host() beruecksichtigt zusaetzlich, welcher der dem Host zugeordneten Schluessel ueber group_ssh_key_grants mindestens einer Benutzergruppe dieses Users freigegeben ist. Wirft AmbiguousCredentialError, wenn mehr als ein Schluessel zutrifft (bewusst kein stilles LIMIT 1 mehr, siehe dortige Docstring). 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. """ ssh_key_id = await resolve_credential_for_user_on_host( conn, user_id=user_id, host_id=host_id, kind="ssh_key" ) if ssh_key_id is None: return None cursor = await conn.execute( "SELECT private_key_enc, passphrase_enc, username FROM ssh_keys WHERE id = ?", (ssh_key_id,), ) row = await cursor.fetchone() if row is None: return None pem = decrypt_secret(row[0], associated_data=b"ssh_private_key") passphrase = ( decrypt_secret(row[1], associated_data=b"ssh_key_passphrase") if row[1] else None ) username = (row[2] or "").strip() or None try: return import_private_key_material(pem, passphrase), username finally: # Bestpraxis: Referenzen auf das Klartextmaterial so schnell wie # moeglich loslassen (Konzept 6.4). del pem del passphrase async def load_ssh_password_credential_for_host( conn: aiosqlite.Connection, host_id: int, *, user_id: int ) -> tuple[str, str] | None: """Laedt das (Passwort, Benutzername)-Paar, das fuer DIESEN Benutzer auf einem Host OHNE (fuer ihn nutzbaren) SSH-Key gilt (ssh_password_credentials, Migration 0011, seit Migration 0016 ueber host_ssh_password_credential_map referenziert -- eigenstaendiges Objekt mit eigener ID, siehe dortiger Kommentar). Gibt None zurueck, wenn kein SSH-Passwort zutrifft. Wie beim SSH-Key laeuft die Auswahl seit Teil D Schritt 4 ueber app.rbac.resolve_credential_for_user_on_host() (Achse B); strukturell kann hier keine Mehrdeutigkeit auftreten (host_ssh_password_credential_map hat PK auf host_id, also hoechstens ein Treffer).""" credential_id = await resolve_credential_for_user_on_host( conn, user_id=user_id, host_id=host_id, kind="ssh_password_credential" ) if credential_id is None: return None cursor = await conn.execute( "SELECT password_enc, username FROM ssh_password_credentials WHERE id = ?", (credential_id,), ) row = await cursor.fetchone() if row is None: return None password = decrypt_secret(row[0], associated_data=b"ssh_password") try: return password.decode(), row[1] finally: del password async def load_private_key_for_host( conn: aiosqlite.Connection, host_id: int, *, user_id: int ) -> asyncssh.SSHKey: """Rueckwaertskompatible Fassung ohne Benutzername (siehe load_ssh_key_credential_for_host). Wirft HostNotConfiguredError, wenn kein fuer diesen Benutzer nutzbarer Schluessel zutrifft -- anders als connect_to_host beruecksichtigt diese Fassung KEIN SSH-Passwort als Alternative.""" result = await load_ssh_key_credential_for_host(conn, host_id, user_id=user_id) if result is None: raise HostNotConfiguredError(f"Kein SSH-Schluessel fuer Host {host_id} hinterlegt") key, _username = result 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 zu den Zugangsdaten (Adminbereich -> Server -> Host -> SSH-Key " "bzw. SSH-Passwort bearbeiten)." ) return username HOST_KEY_CONNECT_TIMEOUT = 10 async def _fetch_server_host_key(address: str, port: int) -> asyncssh.SSHKey | None: """Reiner SSH-Key-Exchange ohne Anmeldung, mit Zeitlimit. ACHTUNG, das war der Bug hinter drei Fehlersuchen (Phase 12 + Nachtraege): asyncssh.get_server_host_key() nimmt -- ANDERS als asyncssh.connect() -- KEIN **kwargs entgegen. Seine Parameterliste ist in asyncssh 2.18.0 abschliessend: get_server_host_key(host='', port=(), *, tunnel=(), proxy_command=(), family=(), flags=0, local_addr=(), sock=None, client_version=(), kex_algs=(), server_host_key_algs=(), config=(), options=None) 'connect_timeout' ist NICHT dabei (nur asyncssh.connect() reicht unbekannte Argumente ueber **kwargs an SSHClientConnectionOptions weiter). Ein Aufruf mit connect_timeout=... endete deshalb SOFORT -- vor jedem Socket, daher "in unter einer Sekunde" -- in einem TypeError. Das Zeitlimit wird hier darum von aussen per asyncio.wait_for() gesetzt; das ist zugleich unabhaengig davon, wie einzelne asyncssh-Versionen ihre Optionen benennen. """ return await asyncio.wait_for( asyncssh.get_server_host_key(address, port=port), timeout=HOST_KEY_CONNECT_TIMEOUT, ) 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 _fetch_server_host_key(host["address"], host["port"]) except asyncio.TimeoutError as exc: raise HostKeyDiscoveryError( host["id"], f"Zeitlimit von {HOST_KEY_CONNECT_TIMEOUT}s ueberschritten" ) from exc except TypeError: # NICHT maskieren: ein TypeError aus diesem Aufruf ist kein # Netzwerkproblem, sondern ein Aufruffehler im eigenen Code. Genau so # wurde der connect_timeout-Bug oben mehrfach als "Ziel nicht # erreichbar" fehlgedeutet, nachdem das except hier auf Exception # verbreitert worden war. raise except Exception as exc: # Bewusst breit: (asyncssh.Error, OSError) allein fing nicht jede Art # von Fehlschlag dieses externen Aufrufs ab. Ein hier unbehandelter # Fehler wuerde ungefiltert bis in terminal_ws.py/sftp.py # durchreichen -- SSH_SETUP_ERRORS erkennt HostKeyDiscoveryError dort # explizit und zeigt eine saubere deutsche Meldung statt eines # generischen Sitzungsabbruchs. 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, *, user_id: int ) -> asyncssh.SSHClientConnection: host = await load_host(conn, host_id) if host["protocol"] != "ssh": raise HostNotConfiguredError("Host ist kein SSH-Ziel") # Reihenfolge mit Absicht: erst das Ziel identifizieren, dann erst # Zugangsdaten entschluesseln und eine Anmeldung versuchen. trusted_key = await _verified_host_key(conn, host) # SSH-Key hat immer Vorrang vor einem SSH-Passwort (Migration 0011, # "Linux kann statt SSH-Key auch Passwort haben" -- explizit als # Alternative gewuenscht, nicht als gleichrangige zweite Option: ist ein # fuer DIESEN Benutzer nutzbarer Schluessel zugeordnet, wird er benutzt, # unabhaengig davon, ob zusaetzlich ein Passwort hinterlegt ist). Beide # Aufloesungen sind seit Teil D Schritt 4 benutzerabhaengig (Achse B) -- # siehe load_ssh_key_credential_for_host()/load_ssh_password_credential_for_host(). key_credential = await load_ssh_key_credential_for_host(conn, host_id, user_id=user_id) password_credential = ( None if key_credential is not None else await load_ssh_password_credential_for_host(conn, host_id, user_id=user_id) ) if key_credential is None and password_credential is None: raise HostNotConfiguredError( f"Weder ein SSH-Schluessel noch ein SSH-Passwort fuer Host {host_id} hinterlegt " "oder Ihrer Benutzergruppe freigegeben. Falls Sie ueber eine Gruppe " "'ssh_connect' fuer diesen Host haben, aber trotzdem diese Meldung sehen: " "die Zugangsdaten sind zwar zugeordnet, aber keiner Ihrer Gruppen ueber " "Achse B freigegeben (Adminbereich -> Benutzergruppen -> Zugangsdaten)." ) if key_credential is not None: private_key, credential_username = key_credential username = resolve_ssh_username(host, credential_username) auth_kwargs: dict = {"client_keys": [private_key]} else: password, credential_username = password_credential username = resolve_ssh_username(host, credential_username) # client_keys=[] deaktiviert bewusst jeden impliziten Rueckgriff auf # lokale Default-Schluessel (~/.ssh, Agent) -- asyncssh probiert die # sonst automatisch VOR der Passwort-Authentifizierung. auth_kwargs = {"client_keys": [], "password": password} client_factory = lambda: _PinnedHostKeyClient(host["ssh_host_key_fingerprint"]) try: connection = await asyncssh.connect( host["address"], port=host["port"], username=username, known_hosts=None, client_factory=client_factory, connect_timeout=10, **auth_kwargs, ) except asyncssh.Error as exc: logger.warning("SSH-Verbindungsfehler zu Host %s: %s", host_id, exc) raise finally: if key_credential is None and password_credential is not None: del password # Zweite Haelfte des Pinnings: der Schluessel dieser Sitzung muss derselbe # sein wie der eben gepruefte. Faengt den (sehr schmalen) Fall ab, dass # 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: """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 (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. Bugfix 2 (weiterhin gemeldeter 500er nach obigem Fix): das except-(asyncssh.Error, OSError) fing NICHT jede Art von Fehlschlag ab, die asyncssh.get_server_host_key() tatsaechlich werfen kann -- reproduziert mit einer eigenstaendigen Exception-Klasse, wie sie z.B. asyncio.TimeoutError vor Python 3.11 war (Exception-Unterklasse, KEIN OSError) oder wie sie bei einem Abbruch waehrend des Key-Exchange auftreten kann (z.B. ein ValueError aus der Paketdekodierung). So ein Fehler ritt bislang unbehandelt durch bis zu FastAPI -> HTTP 500 ohne jede Meldung, obwohl es sich in der Sache um exakt denselben Fall handelt wie ein OSError (Ziel nicht erreichbar/ Protokollfehler waehrend der Ermittlung). Jetzt wird deshalb bewusst JEDE Ausnahme aus diesem einzelnen, klar abgegrenzten externen Aufruf abgefangen (asyncio.CancelledError ist als BaseException davon ohnehin nicht betroffen -- dieselbe Ueberlegung wie beim Exception-Catch-all der SSH/RDP-WS-Routen, siehe terminal_ws.py/ws_tunnel.py).""" host = await load_host(conn, host_id) if host["protocol"] != "ssh": raise HostNotConfiguredError("Host-Key-Ermittlung ist nur fuer SSH-Ziele moeglich") try: key = await _fetch_server_host_key(host["address"], host["port"]) except asyncio.TimeoutError as exc: logger.warning("Host-Key-Ermittlung fuer Host %s: Zeitlimit ueberschritten", host_id) raise HostKeyDiscoveryError( host_id, f"Zeitlimit von {HOST_KEY_CONNECT_TIMEOUT}s ueberschritten" ) from exc except TypeError: # Siehe _fetch_server_host_key(): Aufruffehler im eigenen Code duerfen # NICHT als "Ziel nicht erreichbar" verkleidet werden. logger.exception("Host-Key-Ermittlung fuer Host %s: fehlerhafter asyncssh-Aufruf", host_id) raise except Exception as exc: 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 = key.get_fingerprint("sha256") public_key = key.export_public_key("openssh").decode().strip() await conn.execute( "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