umbau 1.0
This commit is contained in:
@ -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:
|
||||
|
||||
Reference in New Issue
Block a user