connect fix 4

This commit is contained in:
2026-08-21 06:14:10 +02:00
parent bf2a02679d
commit 5e506c9921
17 changed files with 1266 additions and 162 deletions

427
tests/test_phase12.py Normal file
View File

@ -0,0 +1,427 @@
"""
Tests fuer Phase 12:
1) "Host-Key konnte nicht ermittelt werden -- Ziel nicht erreichbar:
Permission denied for user ... on host ...": die Ermittlung lief ueber
einen vollstaendigen asyncssh.connect()-Versuch und wartete darauf, dass
SSHClient.validate_host_public_key() den Fingerprint liefert. Diesen
Callback ruft asyncssh aber nur auf, wenn known_hosts NICHT None ist --
mit known_hosts=None ist die Host-Key-Pruefung abgeschaltet. Der
Fingerprint blieb leer, und der voellig erwartbare Auth-Fehler wurde als
"Ziel nicht erreichbar" gemeldet. Jetzt: asyncssh.get_server_host_key(),
also reiner Key-Exchange ohne Anmeldung.
2) Aus derselben Ursache folgt: das Pinning im REGULAEREN Verbindungspfad
war wirkungslos -- der Callback wurde dort ebenso nie aufgerufen. Die
Pruefung findet jetzt vor der Anmeldung statt und wird nach dem
Sitzungsaufbau noch einmal gegengeprueft.
3) Der Benutzername gehoert zu den ZUGANGSDATEN, nicht zum Host
(Migration 0010): ssh_keys.username bzw. rdp_credentials.username/.domain.
Die Tests kommen ohne laufenden SSH-Server aus: asyncssh wird an den beiden
Eintrittspunkten (get_server_host_key/connect) ersetzt, die Datenbank ist eine
echte SQLite-Datei, auf die die echten Migrationen angewendet werden.
"""
from __future__ import annotations
import pathlib
import sqlite3
import pytest
from cryptography.hazmat.primitives import serialization
from cryptography.hazmat.primitives.asymmetric import ed25519
from app.rdp_proxy.guacd_client import GuacamoleProtocolError, build_rdp_params
from app.security.crypto import encrypt_secret
from app.ssh_proxy.proxy import (
HostKeyDiscoveryError,
HostKeyMismatchError,
HostKeyNotPinnedError,
HostNotConfiguredError,
connect_to_host,
discover_and_store_host_key,
load_host,
resolve_ssh_username,
)
MIGRATIONS = pathlib.Path(__file__).resolve().parent.parent / "app" / "db" / "migrations"
FINGERPRINT = "SHA256:0123456789abcdefghijklmnopqrstuvwxyzABCDEFG"
PUBLIC_KEY = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAITESTKEYTESTKEYTESTKEYTESTKEYTESTKEY host"
# --------------------------------------------------------------------------
# Hilfsmittel: echte SQLite-DB mit den echten Migrationen + winziger
# aiosqlite-kompatibler Adapter (die Produktionsfunktionen erwarten await).
# --------------------------------------------------------------------------
class _Cursor:
def __init__(self, cursor: sqlite3.Cursor) -> None:
self._cursor = cursor
self.lastrowid = cursor.lastrowid
async def fetchone(self):
return self._cursor.fetchone()
async def fetchall(self):
return self._cursor.fetchall()
class FakeConnection:
"""Genau so viel aiosqlite, wie app/ssh_proxy/proxy.py benutzt."""
def __init__(self, path: str) -> None:
self.raw = sqlite3.connect(path)
async def execute(self, sql, params=()):
return _Cursor(self.raw.execute(sql, params))
async def commit(self):
self.raw.commit()
def _apply_migrations(db: sqlite3.Connection, upto: str | None = None) -> None:
for path in sorted(MIGRATIONS.glob("*.sql")):
db.executescript(path.read_text(encoding="utf-8"))
if upto and path.name.startswith(upto):
break
db.commit()
def _plain_key_pem() -> str:
return ed25519.Ed25519PrivateKey.generate().private_bytes(
serialization.Encoding.PEM,
serialization.PrivateFormat.OpenSSH,
serialization.NoEncryption(),
).decode()
def _make_db(tmp_path, *, host_username=None, key_username="l4u", fingerprint=FINGERPRINT,
host_key=PUBLIC_KEY, with_key=True) -> str:
path = str(tmp_path / "jumphost.sqlite3")
db = sqlite3.connect(path)
_apply_migrations(db)
db.execute("INSERT INTO host_groups (id, name, tenant_id) VALUES (1, 'gruppe', 1)")
db.execute(
"INSERT INTO hosts (id, host_group_id, hostname, address, protocol, port, os_type, "
"ssh_host_key_fingerprint, ssh_host_key, ssh_username) "
"VALUES (1, 1, 'ziel', '185.228.139.3', 'ssh', 22, 'linux', ?, ?, ?)",
(fingerprint, host_key, host_username),
)
if with_key:
db.execute(
"INSERT INTO ssh_keys (id, label, private_key_enc, public_key, key_type, tenant_id, username) "
"VALUES (1, 'testkey', ?, 'ssh-ed25519 AAAA', 'ed25519', 1, ?)",
(encrypt_secret(_plain_key_pem().encode(), associated_data=b"ssh_private_key"), key_username),
)
db.execute("INSERT INTO host_ssh_key_map (host_id, ssh_key_id) VALUES (1, 1)")
db.commit()
db.close()
return path
class FakeHostKey:
def __init__(self, fingerprint=FINGERPRINT, public=PUBLIC_KEY) -> None:
self._fingerprint = fingerprint
self._public = public
def get_fingerprint(self, kind="sha256"):
return self._fingerprint
def export_public_key(self, fmt="openssh"):
return self._public.encode()
class FakeConnectionResult:
"""Minimale asyncssh-Verbindung: liefert den Host-Key der Sitzung."""
def __init__(self, host_key=None) -> None:
self._host_key = host_key or FakeHostKey()
self.aborted = False
def get_server_host_key(self):
return self._host_key
def abort(self):
self.aborted = True
def close(self):
pass
# --------------------------------------------------------------------------
# 1) Host-Key-Ermittlung
# --------------------------------------------------------------------------
async def test_discovery_braucht_keine_anmeldung(tmp_path, monkeypatch):
"""Reproduktion des gemeldeten Fehlers: die Anmeldung scheitert (der Host
kennt den Benutzer nicht), der Host-Key ist trotzdem ermittelbar."""
import asyncssh
async def _fake_connect(*a, **kw): # pragma: no cover - darf nicht laufen
raise AssertionError("Die Ermittlung darf keine Anmeldung mehr versuchen")
async def _fake_get_host_key(host, port=22, **kw):
assert (host, port) == ("185.228.139.3", 22)
return FakeHostKey()
monkeypatch.setattr(asyncssh, "connect", _fake_connect)
monkeypatch.setattr(asyncssh, "get_server_host_key", _fake_get_host_key, raising=False)
conn = FakeConnection(_make_db(tmp_path, fingerprint=None, host_key=None))
fingerprint = await discover_and_store_host_key(conn, 1, admin_user_id=1)
assert fingerprint == FINGERPRINT
row = conn.raw.execute(
"SELECT ssh_host_key_fingerprint, ssh_host_key FROM hosts WHERE id = 1"
).fetchone()
# Nicht nur der Fingerprint, auch der vollstaendige Schluessel wird
# gespeichert -- nur damit laesst sich spaeter VOR der Anmeldung pinnen.
assert row == (FINGERPRINT, PUBLIC_KEY)
async def test_discovery_meldet_echte_unerreichbarkeit(tmp_path, monkeypatch):
import asyncssh
async def _fake_get_host_key(host, port=22, **kw):
raise OSError("Connection refused")
monkeypatch.setattr(asyncssh, "get_server_host_key", _fake_get_host_key, raising=False)
conn = FakeConnection(_make_db(tmp_path))
with pytest.raises(HostKeyDiscoveryError) as excinfo:
await discover_and_store_host_key(conn, 1, admin_user_id=1)
assert "Connection refused" in excinfo.value.reason
# --------------------------------------------------------------------------
# 2) Pinning
# --------------------------------------------------------------------------
async def test_verbindung_ohne_hinterlegten_hostkey_wird_abgelehnt(tmp_path, monkeypatch):
import asyncssh
async def _fake_connect(*a, **kw): # pragma: no cover - darf nicht laufen
raise AssertionError("Ohne gepinnten Host-Key darf nicht verbunden werden")
monkeypatch.setattr(asyncssh, "connect", _fake_connect)
conn = FakeConnection(_make_db(tmp_path, fingerprint=None, host_key=None))
with pytest.raises(HostKeyNotPinnedError):
await connect_to_host(conn, 1)
async def test_abweichender_hostkey_bricht_vor_der_anmeldung_ab(tmp_path, monkeypatch):
"""Der entscheidende Punkt: kein einziges Byte Zugangsdaten geht raus."""
import asyncssh
versuche = []
async def _fake_connect(*a, **kw): # pragma: no cover - darf nicht laufen
versuche.append(kw)
raise AssertionError("Bei abweichendem Host-Key darf keine Anmeldung erfolgen")
async def _fake_get_host_key(host, port=22, **kw):
return FakeHostKey(fingerprint="SHA256:einVoelligAndererSchluessel", public="ssh-ed25519 AAAAfremd x")
monkeypatch.setattr(asyncssh, "connect", _fake_connect)
monkeypatch.setattr(asyncssh, "get_server_host_key", _fake_get_host_key, raising=False)
conn = FakeConnection(_make_db(tmp_path))
with pytest.raises(HostKeyMismatchError) as excinfo:
await connect_to_host(conn, 1)
assert excinfo.value.expected == FINGERPRINT
assert versuche == []
async def test_verbindung_nutzt_benutzernamen_der_zugangsdaten(tmp_path, monkeypatch):
import asyncssh
aufrufe = {}
async def _fake_connect(address, **kw):
aufrufe["address"] = address
aufrufe.update(kw)
return FakeConnectionResult()
async def _fake_get_host_key(host, port=22, **kw):
return FakeHostKey()
monkeypatch.setattr(asyncssh, "connect", _fake_connect)
monkeypatch.setattr(asyncssh, "get_server_host_key", _fake_get_host_key, raising=False)
conn = FakeConnection(_make_db(tmp_path, host_username="alterWertAmHost", key_username="l4u"))
await connect_to_host(conn, 1)
# Der Name aus den Zugangsdaten gewinnt gegen den Altwert am Host.
assert aufrufe["username"] == "l4u"
async def test_hostkey_wechsel_nach_der_pruefung_beendet_die_sitzung(tmp_path, monkeypatch):
import asyncssh
verbindung = FakeConnectionResult(FakeHostKey(fingerprint="SHA256:plotzlichAnders"))
async def _fake_connect(address, **kw):
return verbindung
async def _fake_get_host_key(host, port=22, **kw):
return FakeHostKey()
monkeypatch.setattr(asyncssh, "connect", _fake_connect)
monkeypatch.setattr(asyncssh, "get_server_host_key", _fake_get_host_key, raising=False)
conn = FakeConnection(_make_db(tmp_path))
with pytest.raises(HostKeyMismatchError):
await connect_to_host(conn, 1)
assert verbindung.aborted is True
async def test_altbestand_ohne_gespeicherten_hostkey_wird_nachgetragen(tmp_path, monkeypatch):
"""Hosts, deren Key vor Migration 0010 ermittelt wurde, kennen nur den
Fingerprint. Der Schluessel wird beim naechsten Verbindungsaufbau
nachgetragen -- nachdem er gegen den Fingerprint geprueft wurde."""
import asyncssh
async def _fake_connect(address, **kw):
return FakeConnectionResult()
async def _fake_get_host_key(host, port=22, **kw):
return FakeHostKey()
monkeypatch.setattr(asyncssh, "connect", _fake_connect)
monkeypatch.setattr(asyncssh, "get_server_host_key", _fake_get_host_key, raising=False)
conn = FakeConnection(_make_db(tmp_path, host_key=None))
await connect_to_host(conn, 1)
(stored,) = conn.raw.execute("SELECT ssh_host_key FROM hosts WHERE id = 1").fetchone()
assert stored == PUBLIC_KEY
# --------------------------------------------------------------------------
# 3) Benutzername gehoert zu den Zugangsdaten
# --------------------------------------------------------------------------
def test_resolve_ssh_username_vorrang_und_fallback():
assert resolve_ssh_username({"ssh_username": "alt"}, "neu") == "neu"
# Altbestand: solange am Schluessel nichts steht, greift der Host-Wert.
assert resolve_ssh_username({"ssh_username": "alt"}, None) == "alt"
assert resolve_ssh_username({"ssh_username": "alt"}, " ") == "alt"
def test_resolve_ssh_username_ohne_jeden_wert_meldet_klartext():
with pytest.raises(HostNotConfiguredError) as excinfo:
resolve_ssh_username({"ssh_username": None}, None)
text = str(excinfo.value)
assert "Benutzername" in text and "SSH-Key" in text
def _rdp_host(**overrides):
host = {
"id": 7, "address": "10.0.0.5", "port": 3389, "rdp_require_nla": True,
"clipboard_enabled": True, "rdp_ignore_cert": True, "file_transfer_enabled": True,
}
host.update(overrides)
return host
def test_rdp_params_nehmen_benutzernamen_der_zugangsdaten():
params = build_rdp_params(
_rdp_host(rdp_username="alt", rdp_domain="ALTEDOMAENE"),
"geheim", username="Administrator", domain="CONTOSO",
)
assert params["username"] == "Administrator"
assert params["domain"] == "CONTOSO"
assert params["password"] == "geheim"
def test_rdp_params_fallback_auf_altwert_am_host():
params = build_rdp_params(_rdp_host(rdp_username="alt", rdp_domain="D"), "geheim")
assert (params["username"], params["domain"]) == ("alt", "D")
def test_rdp_params_ohne_benutzernamen_meldet_zugangsdaten():
with pytest.raises(GuacamoleProtocolError) as excinfo:
build_rdp_params(_rdp_host(), "geheim")
assert "Zugangsdaten" in str(excinfo.value)
def test_rdp_params_brauchen_die_hostspalte_nicht_mehr():
"""Ein Hostdatensatz ohne rdp_username ist kein Fehler mehr -- der Name
kommt jetzt von woanders."""
params = build_rdp_params(_rdp_host(), "geheim", username="svc")
assert params["username"] == "svc"
# --------------------------------------------------------------------------
# 4) Migration 0010: Uebernahme der Altwerte
# --------------------------------------------------------------------------
def _pre_0010_db(tmp_path) -> sqlite3.Connection:
db = sqlite3.connect(str(tmp_path / "alt.sqlite3"))
_apply_migrations(db, upto="0009")
return db
def test_migration_uebernimmt_eindeutige_ssh_benutzernamen(tmp_path):
db = _pre_0010_db(tmp_path)
db.execute("INSERT INTO host_groups (id, name, tenant_id) VALUES (1, 'g', 1)")
for host_id in (1, 2):
db.execute(
"INSERT INTO hosts (id, host_group_id, hostname, address, protocol, port, os_type, ssh_username) "
"VALUES (?, 1, 'h', '10.0.0.1', 'ssh', 22, 'linux', 'l4u')",
(host_id,),
)
db.execute(
"INSERT INTO ssh_keys (id, label, private_key_enc, public_key, key_type, tenant_id) "
"VALUES (1, 'k', X'00', 'pub', 'ed25519', 1)"
)
db.execute("INSERT INTO host_ssh_key_map (host_id, ssh_key_id) VALUES (1, 1), (2, 1)")
db.commit()
db.executescript((MIGRATIONS / "0010_credential_usernames.sql").read_text(encoding="utf-8"))
(username,) = db.execute("SELECT username FROM ssh_keys WHERE id = 1").fetchone()
assert username == "l4u"
def test_migration_raet_nicht_bei_mehrdeutigen_benutzernamen(tmp_path):
"""Zwei Hosts, zwei verschiedene Benutzernamen, ein Schluessel: hier waere
jede automatische Wahl geraten -- also bleibt das Feld leer und der
Fallback greift weiter."""
db = _pre_0010_db(tmp_path)
db.execute("INSERT INTO host_groups (id, name, tenant_id) VALUES (1, 'g', 1)")
for host_id, name in ((1, "root"), (2, "l4u")):
db.execute(
"INSERT INTO hosts (id, host_group_id, hostname, address, protocol, port, os_type, ssh_username) "
"VALUES (?, 1, 'h', '10.0.0.1', 'ssh', 22, 'linux', ?)",
(host_id, name),
)
db.execute(
"INSERT INTO ssh_keys (id, label, private_key_enc, public_key, key_type, tenant_id) "
"VALUES (1, 'k', X'00', 'pub', 'ed25519', 1)"
)
db.execute("INSERT INTO host_ssh_key_map (host_id, ssh_key_id) VALUES (1, 1), (2, 1)")
db.commit()
db.executescript((MIGRATIONS / "0010_credential_usernames.sql").read_text(encoding="utf-8"))
(username,) = db.execute("SELECT username FROM ssh_keys WHERE id = 1").fetchone()
assert username is None
def test_migration_uebernimmt_rdp_benutzer_und_domaene(tmp_path):
db = _pre_0010_db(tmp_path)
db.execute("INSERT INTO host_groups (id, name, tenant_id) VALUES (1, 'g', 1)")
db.execute(
"INSERT INTO hosts (id, host_group_id, hostname, address, protocol, port, os_type, "
"rdp_username, rdp_domain) "
"VALUES (1, 1, 'win', '10.0.0.9', 'rdp', 3389, 'windows', 'Administrator', 'CONTOSO')"
)
db.execute("INSERT INTO rdp_credentials (host_id, password_enc) VALUES (1, X'00')")
db.commit()
db.executescript((MIGRATIONS / "0010_credential_usernames.sql").read_text(encoding="utf-8"))
row = db.execute("SELECT username, domain FROM rdp_credentials WHERE host_id = 1").fetchone()
assert row == ("Administrator", "CONTOSO")
async def test_load_host_liefert_den_hostkey_mit(tmp_path):
conn = FakeConnection(_make_db(tmp_path))
host = await load_host(conn, 1)
assert host["ssh_host_key"] == PUBLIC_KEY