577 lines
26 KiB
Python
577 lines
26 KiB
Python
"""
|
|
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 logging
|
|
|
|
import asyncssh
|
|
import aiosqlite
|
|
|
|
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,
|
|
)
|
|
|
|
|
|
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):
|
|
"""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
|
|
) -> tuple[asyncssh.SSHKey, str | None] | None:
|
|
"""Laedt den 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 zugeordnet ist
|
|
-- der Aufrufer (connect_to_host) faellt dann auf ein SSH-Passwort
|
|
zurueck, falls eines hinterlegt ist (Migration 0011).
|
|
|
|
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, 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,),
|
|
)
|
|
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
|
|
) -> tuple[str, str] | None:
|
|
"""Laedt das (Passwort, Benutzername)-Paar fuer einen Host OHNE
|
|
zugeordneten SSH-Key (ssh_password_credentials, Migration 0011). Gibt
|
|
None zurueck, wenn kein SSH-Passwort hinterlegt ist."""
|
|
cursor = await conn.execute(
|
|
"SELECT password_enc, username FROM ssh_password_credentials WHERE host_id = ?",
|
|
(host_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) -> asyncssh.SSHKey:
|
|
"""Rueckwaertskompatible Fassung ohne Benutzername (siehe
|
|
load_ssh_key_credential_for_host). Wirft HostNotConfiguredError, wenn kein
|
|
Schluessel zugeordnet ist -- anders als connect_to_host beruecksichtigt
|
|
diese Fassung KEIN SSH-Passwort als Alternative."""
|
|
result = await load_ssh_key_credential_for_host(conn, host_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
|
|
|
|
|
|
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 Exception as exc:
|
|
# Bewusst dieselbe Verbreiterung wie in discover_and_store_host_key():
|
|
# (asyncssh.Error, OSError) allein fing nicht jede Art von Fehlschlag
|
|
# dieses externen Aufrufs ab (z.B. asyncio.TimeoutError vor Python
|
|
# 3.11, kein OSError). 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) -> 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
|
|
# Schluessel zugeordnet, wird er benutzt, unabhaengig davon, ob zusaetzlich
|
|
# ein Passwort hinterlegt ist).
|
|
key_credential = await load_ssh_key_credential_for_host(conn, host_id)
|
|
password_credential = None if key_credential is not None else await load_ssh_password_credential_for_host(conn, host_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"
|
|
)
|
|
|
|
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 asyncssh.get_server_host_key(
|
|
host["address"], port=host["port"], connect_timeout=10
|
|
)
|
|
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
|