umbau 1.0

This commit is contained in:
2026-09-02 20:30:44 +02:00
parent afe6719f51
commit 5c95b21be7
77 changed files with 10733 additions and 1914 deletions

View File

@ -15,6 +15,7 @@ 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")
@ -215,6 +216,10 @@ SSH_SETUP_ERRORS: tuple[type[Exception], ...] = (
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,
)
@ -229,6 +234,13 @@ def describe_connection_error(exc: Exception) -> str:
)
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)
@ -294,13 +306,22 @@ async def load_host(conn: aiosqlite.Connection, host_id: int) -> dict:
async def load_ssh_key_credential_for_host(
conn: aiosqlite.Connection, host_id: int
conn: aiosqlite.Connection, host_id: int, *, user_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).
"""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,
@ -308,11 +329,14 @@ async def load_ssh_key_credential_for_host(
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 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,),
"SELECT private_key_enc, passphrase_enc, username FROM ssh_keys WHERE id = ?",
(ssh_key_id,),
)
row = await cursor.fetchone()
if row is None:
@ -332,14 +356,25 @@ async def load_ssh_key_credential_for_host(
async def load_ssh_password_credential_for_host(
conn: aiosqlite.Connection, host_id: int
conn: aiosqlite.Connection, host_id: int, *, user_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."""
"""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 host_id = ?",
(host_id,),
"SELECT password_enc, username FROM ssh_password_credentials WHERE id = ?",
(credential_id,),
)
row = await cursor.fetchone()
if row is None:
@ -351,12 +386,15 @@ async def load_ssh_password_credential_for_host(
del password
async def load_private_key_for_host(conn: aiosqlite.Connection, host_id: int) -> asyncssh.SSHKey:
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
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)
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
@ -484,7 +522,9 @@ def _public_key_body(openssh_line: str) -> tuple[str, str]:
return (parts[0], parts[1])
async def connect_to_host(conn: aiosqlite.Connection, host_id: int) -> asyncssh.SSHClientConnection:
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")
@ -496,13 +536,23 @@ async def connect_to_host(conn: aiosqlite.Connection, host_id: int) -> asyncssh.
# 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)
# 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"
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: