connect fix 4
This commit is contained in:
@ -44,6 +44,23 @@ class HostKeyDiscoveryError(Exception):
|
||||
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.
|
||||
|
||||
@ -62,6 +79,35 @@ class PrivateKeyUnusableError(Exception):
|
||||
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:
|
||||
@ -96,6 +142,18 @@ def import_private_key_material(
|
||||
# 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 "
|
||||
@ -123,9 +181,41 @@ def import_private_key_material(
|
||||
) from exc
|
||||
|
||||
|
||||
#: 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):
|
||||
"""Erzwingt Strict Host Key Checking gegen einen fest hinterlegten
|
||||
SHA-256-Fingerprint. Kein automatisches Trust-on-First-Use (TOFU)."""
|
||||
"""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
|
||||
@ -147,7 +237,8 @@ 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 "
|
||||
"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,),
|
||||
)
|
||||
@ -166,6 +257,11 @@ async def load_host(conn: aiosqlite.Connection, host_id: int) -> dict:
|
||||
# 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"):
|
||||
@ -173,17 +269,20 @@ async def load_host(conn: aiosqlite.Connection, host_id: int) -> dict:
|
||||
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.
|
||||
async def load_ssh_credential_for_host(
|
||||
conn: aiosqlite.Connection, host_id: int
|
||||
) -> tuple[asyncssh.SSHKey, str | None]:
|
||||
"""Laedt den dem Host zugeordneten Schluessel, entschluesselt ihn und gibt
|
||||
ihn zusammen mit dem am Schluessel hinterlegten Benutzernamen zurueck.
|
||||
|
||||
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.
|
||||
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 FROM ssh_keys sk "
|
||||
"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,),
|
||||
@ -195,8 +294,9 @@ async def load_private_key_for_host(conn: aiosqlite.Connection, host_id: int) ->
|
||||
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)
|
||||
return import_private_key_material(pem, passphrase), username
|
||||
finally:
|
||||
# Bestpraxis: Referenzen auf das Klartextmaterial so schnell wie
|
||||
# moeglich loslassen (Konzept 6.4).
|
||||
@ -204,78 +304,173 @@ async def load_private_key_for_host(conn: aiosqlite.Connection, host_id: int) ->
|
||||
del passphrase
|
||||
|
||||
|
||||
async def load_private_key_for_host(conn: aiosqlite.Connection, host_id: int) -> asyncssh.SSHKey:
|
||||
"""Rueckwaertskompatible Fassung ohne Benutzername (siehe
|
||||
load_ssh_credential_for_host)."""
|
||||
key, _username = await load_ssh_credential_for_host(conn, host_id)
|
||||
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 zum SSH-Key (Adminbereich -> Zugangsdaten -> SSH-Key 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 (asyncssh.Error, OSError) as exc:
|
||||
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")
|
||||
|
||||
private_key = await load_private_key_for_host(conn, host_id)
|
||||
# Reihenfolge mit Absicht: erst das Ziel identifizieren, dann erst
|
||||
# Schluesselmaterial entschluesseln und eine Anmeldung versuchen.
|
||||
trusted_key = await _verified_host_key(conn, host)
|
||||
private_key, credential_username = await load_ssh_credential_for_host(conn, host_id)
|
||||
username = resolve_ssh_username(host, credential_username)
|
||||
client_factory = lambda: _PinnedHostKeyClient(host["ssh_host_key_fingerprint"])
|
||||
|
||||
try:
|
||||
connection = await asyncssh.connect(
|
||||
host["address"],
|
||||
port=host["port"],
|
||||
username=host["ssh_username"],
|
||||
username=username,
|
||||
client_keys=[private_key],
|
||||
known_hosts=None, # Validierung erfolgt ausschliesslich ueber validate_host_public_key
|
||||
known_hosts=None,
|
||||
client_factory=client_factory,
|
||||
connect_timeout=10,
|
||||
)
|
||||
except asyncssh.Error as exc:
|
||||
logger.warning("SSH-Verbindungsfehler zu Host %s: %s", host_id, exc)
|
||||
raise
|
||||
|
||||
# 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:
|
||||
"""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).
|
||||
"""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 (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)."""
|
||||
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."""
|
||||
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,
|
||||
key = await asyncssh.get_server_host_key(
|
||||
host["address"], port=host["port"], 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()
|
||||
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 = client.observed_fingerprint
|
||||
fingerprint = key.get_fingerprint("sha256")
|
||||
public_key = key.export_public_key("openssh").decode().strip()
|
||||
await conn.execute(
|
||||
"UPDATE hosts SET ssh_host_key_fingerprint = ? WHERE id = ?", (fingerprint, host_id)
|
||||
"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
|
||||
|
||||
Reference in New Issue
Block a user