282 lines
12 KiB
Python
282 lines
12 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 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)."""
|
|
|
|
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 "
|
|
"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",
|
|
)
|
|
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, 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,),
|
|
)
|
|
row = await cursor.fetchone()
|
|
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 import_private_key_material(pem, passphrase)
|
|
finally:
|
|
# 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:
|
|
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)
|
|
client_factory = lambda: _PinnedHostKeyClient(host["ssh_host_key_fingerprint"])
|
|
|
|
try:
|
|
connection = await asyncssh.connect(
|
|
host["address"],
|
|
port=host["port"],
|
|
username=host["ssh_username"],
|
|
client_keys=[private_key],
|
|
known_hosts=None, # Validierung erfolgt ausschliesslich ueber validate_host_public_key
|
|
client_factory=client_factory,
|
|
connect_timeout=10,
|
|
)
|
|
except asyncssh.Error as exc:
|
|
logger.warning("SSH-Verbindungsfehler zu Host %s: %s", host_id, exc)
|
|
raise
|
|
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).
|
|
|
|
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)."""
|
|
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,
|
|
)
|
|
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()
|
|
|
|
fingerprint = client.observed_fingerprint
|
|
await conn.execute(
|
|
"UPDATE hosts SET ssh_host_key_fingerprint = ? WHERE id = ?", (fingerprint, host_id)
|
|
)
|
|
await conn.commit()
|
|
return fingerprint
|