connect fix 4
This commit is contained in:
167
tests/test_phase11.py
Normal file
167
tests/test_phase11.py
Normal file
@ -0,0 +1,167 @@
|
||||
"""
|
||||
Tests fuer Phase 11: fehlendes bcrypt-Modul im Virtualenv.
|
||||
|
||||
Symptom im Betrieb: "Der private Schluessel konnte nicht gelesen werden:
|
||||
OpenSSH private key encryption requires bcrypt with KDF support". Der
|
||||
Schluessel und die (seit Phase 10 speicherbare) Passphrase waren dabei
|
||||
korrekt -- es fehlte schlicht das Modul 'bcrypt', ohne das asyncssh die
|
||||
bcrypt_pbkdf-Ableitung von OpenSSH nicht durchfuehren kann. 'bcrypt' stand
|
||||
nicht in requirements.txt und war deshalb im ausgerollten Virtualenv nicht
|
||||
vorhanden.
|
||||
|
||||
Geprueft wird hier:
|
||||
* die Abhaengigkeit ist deklariert (sonst kommt der Fehler nach dem
|
||||
naechsten Redeploy wieder),
|
||||
* die englische asyncssh-Meldung wird in einen deutschen Klartext
|
||||
uebersetzt, der die Ursache (Serverumgebung, nicht Schluessel/Passphrase)
|
||||
benennt,
|
||||
* beim Anwendungsstart wird gewarnt, statt den Fehler bis zur ersten
|
||||
Benutzersitzung zu verschleppen,
|
||||
* mit installiertem bcrypt laesst sich ein echter, OpenSSH-verschluesselter
|
||||
Schluessel tatsaechlich importieren (End-to-End-Gegenprobe).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import pathlib
|
||||
|
||||
import pytest
|
||||
|
||||
from app.ssh_proxy.proxy import (
|
||||
BCRYPT_MISSING_MESSAGE,
|
||||
PrivateKeyUnusableError,
|
||||
bcrypt_kdf_available,
|
||||
import_private_key_material,
|
||||
)
|
||||
|
||||
# Wortlaut von asyncssh (asyncssh/public_key.py) bzw. cryptography.
|
||||
ASYNCSSH_MESSAGE = "OpenSSH private key encryption requires bcrypt with KDF support"
|
||||
CRYPTOGRAPHY_MESSAGE = "Need bcrypt module"
|
||||
|
||||
REPO_ROOT = pathlib.Path(__file__).resolve().parent.parent
|
||||
|
||||
|
||||
def test_bcrypt_ist_in_requirements_deklariert():
|
||||
"""Kernursache: ohne diesen Eintrag fehlt bcrypt nach jedem Redeploy."""
|
||||
requirements = (REPO_ROOT / "requirements.txt").read_text(encoding="utf-8")
|
||||
zeilen = [
|
||||
z.strip() for z in requirements.splitlines()
|
||||
if z.strip() and not z.strip().startswith("#")
|
||||
]
|
||||
assert any(z.split("==")[0].split("[")[0].strip() == "bcrypt" for z in zeilen), (
|
||||
"bcrypt muss in requirements.txt stehen -- asyncssh kann sonst keinen "
|
||||
"passphrasegeschuetzten OpenSSH-Schluessel lesen."
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("meldung", [ASYNCSSH_MESSAGE, CRYPTOGRAPHY_MESSAGE])
|
||||
@pytest.mark.parametrize("passphrase", [None, "Testpassphrase-2026"])
|
||||
def test_fehlendes_bcrypt_wird_uebersetzt(monkeypatch, meldung, passphrase):
|
||||
"""Die englische Bibliotheksmeldung darf nicht mehr durchschlagen."""
|
||||
import asyncssh
|
||||
|
||||
aufrufe = []
|
||||
|
||||
def _fake_import(pem, pw=None):
|
||||
aufrufe.append(pw)
|
||||
raise ValueError(meldung)
|
||||
|
||||
monkeypatch.setattr(asyncssh, "import_private_key", _fake_import)
|
||||
|
||||
with pytest.raises(PrivateKeyUnusableError) as excinfo:
|
||||
import_private_key_material("egal", passphrase)
|
||||
|
||||
text = str(excinfo.value)
|
||||
assert text == BCRYPT_MISSING_MESSAGE
|
||||
assert "bcrypt" in text
|
||||
assert "requirements.txt" in text
|
||||
# Der Benutzer soll NICHT bei Schluessel/Passphrase suchen:
|
||||
assert "Passphrase" in text and "nicht" in text
|
||||
assert isinstance(excinfo.value.__cause__, ValueError)
|
||||
# Kein zweiter Importversuch ohne Passphrase: die Ursache liegt in der
|
||||
# Umgebung, ein Retry wuerde nur dieselbe Meldung erzeugen.
|
||||
assert len(aufrufe) == 1
|
||||
|
||||
|
||||
def test_andere_importfehler_bleiben_unveraendert(monkeypatch):
|
||||
"""Gegenprobe: die Phase-10-Uebersetzungen duerfen nicht kapern."""
|
||||
import asyncssh
|
||||
|
||||
def _fake_import(pem, pw=None):
|
||||
raise ValueError("Passphrase must be specified to import encrypted private keys")
|
||||
|
||||
monkeypatch.setattr(asyncssh, "import_private_key", _fake_import)
|
||||
|
||||
with pytest.raises(PrivateKeyUnusableError) as excinfo:
|
||||
import_private_key_material("egal", None)
|
||||
assert "keine Passphrase hinterlegt" in str(excinfo.value)
|
||||
assert str(excinfo.value) != BCRYPT_MISSING_MESSAGE
|
||||
|
||||
|
||||
def test_bcrypt_kdf_available_erkennt_modul_ohne_kdf(monkeypatch):
|
||||
"""bcrypt < 3.1.3 bringt kein kdf() mit -- das zaehlt als 'nicht da'."""
|
||||
import sys
|
||||
import types
|
||||
|
||||
attrappe = types.ModuleType("bcrypt")
|
||||
monkeypatch.setitem(sys.modules, "bcrypt", attrappe)
|
||||
assert bcrypt_kdf_available() is False
|
||||
|
||||
attrappe.kdf = lambda **_: b""
|
||||
assert bcrypt_kdf_available() is True
|
||||
|
||||
|
||||
def test_bcrypt_kdf_available_bei_fehlendem_modul(monkeypatch):
|
||||
import builtins
|
||||
|
||||
original = builtins.__import__
|
||||
|
||||
def _import(name, *args, **kwargs):
|
||||
if name == "bcrypt":
|
||||
raise ImportError("No module named 'bcrypt'")
|
||||
return original(name, *args, **kwargs)
|
||||
|
||||
monkeypatch.setattr(builtins, "__import__", _import)
|
||||
assert bcrypt_kdf_available() is False
|
||||
|
||||
|
||||
def test_startup_warnt_bei_fehlendem_bcrypt(monkeypatch, caplog):
|
||||
"""Der Fehler soll beim Start auffallen, nicht erst in der Sitzung."""
|
||||
import app.main as main
|
||||
|
||||
monkeypatch.setattr(main, "bcrypt_kdf_available", lambda: False)
|
||||
with caplog.at_level(logging.ERROR, logger="jumphost.main"):
|
||||
main._check_optional_dependencies()
|
||||
assert any("bcrypt" in r.message for r in caplog.records)
|
||||
|
||||
caplog.clear()
|
||||
monkeypatch.setattr(main, "bcrypt_kdf_available", lambda: True)
|
||||
with caplog.at_level(logging.ERROR, logger="jumphost.main"):
|
||||
main._check_optional_dependencies()
|
||||
assert caplog.records == []
|
||||
|
||||
|
||||
def test_echter_openssh_schluessel_mit_passphrase_ist_importierbar():
|
||||
"""End-to-End-Gegenprobe mit installiertem bcrypt.
|
||||
|
||||
Erzeugt genau das Format, an dem der Import gescheitert ist (OpenSSH,
|
||||
bcrypt_pbkdf-verschluesselt), und laedt es ueber den regulaeren Codepfad.
|
||||
"""
|
||||
pytest.importorskip("bcrypt", reason="ohne bcrypt ist genau dieser Pfad kaputt")
|
||||
|
||||
from cryptography.hazmat.primitives import serialization
|
||||
from cryptography.hazmat.primitives.asymmetric import ed25519
|
||||
|
||||
passphrase = "Testpassphrase-2026"
|
||||
pem = ed25519.Ed25519PrivateKey.generate().private_bytes(
|
||||
serialization.Encoding.PEM,
|
||||
serialization.PrivateFormat.OpenSSH,
|
||||
serialization.BestAvailableEncryption(passphrase.encode()),
|
||||
).decode()
|
||||
assert "OPENSSH PRIVATE KEY" in pem
|
||||
|
||||
schluessel = import_private_key_material(pem, passphrase)
|
||||
assert schluessel is not None
|
||||
|
||||
with pytest.raises(PrivateKeyUnusableError):
|
||||
import_private_key_material(pem, None)
|
||||
427
tests/test_phase12.py
Normal file
427
tests/test_phase12.py
Normal 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
|
||||
@ -219,12 +219,18 @@ async def test_credentials_manage_role_grants_non_admin_write_access(client):
|
||||
assert resp.status_code == 200, resp.text
|
||||
assert resp.json()["rdp_credentials_set"] is False
|
||||
|
||||
resp = await client.put(f"/admin/hosts/{host_id}/rdp-credentials", json={"password": "s3hr-geheim!!"})
|
||||
# Benutzername gehoert seit Migration 0010 zu den Zugangsdaten und ist
|
||||
# beim Setzen Pflicht (siehe tests/test_phase12.py).
|
||||
resp = await client.put(
|
||||
f"/admin/hosts/{host_id}/rdp-credentials",
|
||||
json={"password": "s3hr-geheim!!", "username": "Administrator"},
|
||||
)
|
||||
assert resp.status_code == 200, resp.text
|
||||
|
||||
resp = await client.get(f"/admin/hosts/{host_id}/credentials")
|
||||
assert resp.status_code == 200, resp.text
|
||||
assert resp.json()["rdp_credentials_set"] is True
|
||||
assert resp.json()["rdp_credentials_username"] == "Administrator"
|
||||
|
||||
# Ein User OHNE diese Rolle bleibt weiterhin ausgesperrt.
|
||||
client.cookies.clear()
|
||||
|
||||
@ -25,6 +25,25 @@ from __future__ import annotations
|
||||
import pyotp
|
||||
import pytest
|
||||
|
||||
from cryptography.hazmat.primitives import serialization
|
||||
from cryptography.hazmat.primitives.asymmetric import ed25519
|
||||
|
||||
|
||||
def _key_pem() -> str:
|
||||
"""Echtes, unverschluesseltes Schluesselmaterial.
|
||||
|
||||
Seit Phase 10 prueft POST/PUT /admin/ssh-keys das Material sofort gegen
|
||||
asyncssh (damit ein unbrauchbarer Schluessel nicht erst beim ersten
|
||||
Verbindungsversuch auffaellt). Platzhalter wie "PEM" werden deshalb --
|
||||
voellig korrekt -- mit HTTP 400 abgelehnt; diese Tests hier pruefen aber
|
||||
Mandantenisolation und CRUD und brauchen einen gueltigen Schluessel.
|
||||
"""
|
||||
return ed25519.Ed25519PrivateKey.generate().private_bytes(
|
||||
serialization.Encoding.PEM,
|
||||
serialization.PrivateFormat.OpenSSH,
|
||||
serialization.NoEncryption(),
|
||||
).decode()
|
||||
|
||||
|
||||
async def _create_user(conn, username: str, password: str, *, is_admin: bool = False, home_tenant_id=None) -> int:
|
||||
from app.security.passwords import hash_password
|
||||
@ -244,7 +263,7 @@ async def test_tenant_admin_isolated_from_other_tenants_users_and_ssh_keys(clien
|
||||
resp = await client.post(
|
||||
"/admin/ssh-keys",
|
||||
json={
|
||||
"label": "std-key", "private_key_pem": "PEM", "public_key": "PUB",
|
||||
"label": "std-key", "private_key_pem": _key_pem(), "public_key": "PUB",
|
||||
"key_type": "ed25519", "tenant_id": 1,
|
||||
},
|
||||
)
|
||||
@ -273,7 +292,7 @@ async def test_tenant_admin_isolated_from_other_tenants_users_and_ssh_keys(clien
|
||||
# Eigenen SSH-Key anlegen -- ohne tenant_id automatisch auf E erzwungen.
|
||||
resp = await client.post(
|
||||
"/admin/ssh-keys",
|
||||
json={"label": "e-key", "private_key_pem": "PEM2", "public_key": "PUB2", "key_type": "ed25519"},
|
||||
json={"label": "e-key", "private_key_pem": _key_pem(), "public_key": "PUB2", "key_type": "ed25519"},
|
||||
)
|
||||
assert resp.status_code == 201, resp.text
|
||||
e_key_id = resp.json()["id"]
|
||||
@ -513,19 +532,20 @@ async def test_ssh_key_update_rotate_and_delete(client):
|
||||
|
||||
resp = await client.post(
|
||||
"/admin/ssh-keys",
|
||||
json={"label": "orig-key", "private_key_pem": "PEM", "public_key": "PUB", "key_type": "ed25519"},
|
||||
json={"label": "orig-key", "private_key_pem": _key_pem(), "public_key": "PUB", "key_type": "ed25519"},
|
||||
)
|
||||
key_id = resp.json()["id"]
|
||||
|
||||
resp = await client.put(f"/admin/ssh-keys/{key_id}", json={"label": "renamed-key"})
|
||||
assert resp.status_code == 200, resp.text
|
||||
|
||||
# Rotation erfordert alle drei Felder gemeinsam.
|
||||
# Rotation erfordert alle drei Felder gemeinsam (400 noch vor jeder
|
||||
# Materialpruefung -- der Platzhalter hier ist deshalb Absicht).
|
||||
resp = await client.put(f"/admin/ssh-keys/{key_id}", json={"private_key_pem": "NEWPEM"})
|
||||
assert resp.status_code == 400, resp.text
|
||||
resp = await client.put(
|
||||
f"/admin/ssh-keys/{key_id}",
|
||||
json={"private_key_pem": "NEWPEM", "public_key": "NEWPUB", "key_type": "rsa-4096"},
|
||||
json={"private_key_pem": _key_pem(), "public_key": "NEWPUB", "key_type": "rsa-4096"},
|
||||
)
|
||||
assert resp.status_code == 200, resp.text
|
||||
|
||||
@ -579,13 +599,16 @@ async def test_host_detail_endpoint_includes_ssh_keys_and_rdp_flag(client):
|
||||
|
||||
resp = await client.post(
|
||||
"/admin/ssh-keys",
|
||||
json={"label": "detail-key", "private_key_pem": "PEM", "public_key": "PUB", "key_type": "ed25519"},
|
||||
json={"label": "detail-key", "private_key_pem": _key_pem(), "public_key": "PUB", "key_type": "ed25519"},
|
||||
)
|
||||
key_id = resp.json()["id"]
|
||||
resp = await client.post(f"/admin/hosts/{host_id}/ssh-keys/{key_id}")
|
||||
assert resp.status_code == 200, resp.text
|
||||
|
||||
resp = await client.put(f"/admin/hosts/{host_id}/rdp-credentials", json={"password": "Correct-Horse-Battery-Staple-O2"})
|
||||
resp = await client.put(
|
||||
f"/admin/hosts/{host_id}/rdp-credentials",
|
||||
json={"password": "Correct-Horse-Battery-Staple-O2", "username": "Administrator"},
|
||||
)
|
||||
assert resp.status_code == 200, resp.text
|
||||
|
||||
resp = await client.get(f"/admin/hosts/{host_id}")
|
||||
|
||||
Reference in New Issue
Block a user