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

112
README.md
View File

@ -328,13 +328,123 @@ protokollieren jetzt und geben den Grund als WebSocket-Close-Reason mit, den
Passphrase nachtragen (Adminbereich -> Zugangsdaten -> SSH-Key bearbeiten).
3. Bei RDP-Hosts pruefen, dass ein RDP-Benutzername gesetzt ist.
## Phase 11: fehlendes `bcrypt` im Virtualenv
Symptom: `Der private Schluessel konnte nicht gelesen werden: OpenSSH private
key encryption requires bcrypt with KDF support`.
Ursache war weder der Schluessel noch die (seit Phase 10 speicherbare)
Passphrase, sondern die **Serverumgebung**: OpenSSH verschluesselt private
Schluessel mit `bcrypt_pbkdf`. asyncssh und `cryptography` fuehren diese
Ableitung nur aus, wenn das Python-Modul `bcrypt` installiert ist -- es ist
bei beiden ein *optionales* Extra (`asyncssh[bcrypt]`) und stand nicht in
`requirements.txt`. Im ausgerollten Virtualenv fehlte es deshalb, und damit
war jeder mit `ssh-keygen` erzeugte passphrasegeschuetzte Schluessel
prinzipiell unlesbar -- unabhaengig davon, ob die Passphrase stimmte.
Behoben:
* `bcrypt` steht jetzt als **Pflichtabhaengigkeit** in `requirements.txt`
(gepinnt wie alle anderen; entspricht dem Extra `asyncssh[bcrypt]`).
* `import_private_key_material()` uebersetzt die englische Bibliotheksmeldung
in einen deutschen Klartext, der die Ursache benennt (Serverumgebung, nicht
Schluessel/Passphrase) und den Behebungsweg nennt. Diese Meldung erscheint
im Terminal, im Dateitransfer und als HTTP 400 beim Key-Upload.
* Der Anwendungsstart prueft die Abhaengigkeit einmalig und schreibt eine
ERROR-Zeile ins Log (und damit ins Live-Verbindungslog des Adminbereichs),
statt den Fehler bis zur ersten Benutzersitzung zu verschleppen.
### Nach dem Deployment zu tun
```bash
# Ansible: die Rolle python_runtime installiert requirements.txt neu
ansible-playbook -i inventory/production.ini site.yml
# oder von Hand auf dem Jumphost:
sudo -u jumphost /opt/jumphost/venv/bin/pip install -r /opt/jumphost/requirements.txt
sudo systemctl restart jumphost-app
```
Kontrolle: `journalctl -u jumphost-app | grep bcrypt` bleibt nach dem Neustart
leer. Erscheint die ERROR-Zeile, laeuft der Dienst noch gegen ein Virtualenv
ohne `bcrypt`.
## Phase 12: Benutzername gehoert zu den Zugangsdaten -- und wirksames Host-Key-Pinning
**Symptom:** `Host-Key konnte nicht ermittelt werden -- Ziel nicht erreichbar:
Permission denied for user l4u on host <ip>`.
Das Ziel war sehr wohl erreichbar: der Key-Exchange war laengst durch, nur die
anschliessende Anmeldung scheiterte. 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 nur auf, wenn `known_hosts` nicht `None` ist** -- mit
`known_hosts=None` ist die Host-Key-Pruefung komplett abgeschaltet. Der
Fingerprint blieb deshalb leer, und der voellig erwartbare Auth-Fehler wurde
als "Ziel nicht erreichbar" gemeldet.
**Dieselbe Ursache hatte eine sicherheitsrelevante zweite Wirkung:** der
regulaere Verbindungspfad benutzte exakt dieselbe Kombination. Das
"Strict Host Key Checking" gegen den gepinnten Fingerprint lief also
faktisch nie -- der Server wurde ueberhaupt nicht geprueft.
Neu:
* Die Ermittlung benutzt `asyncssh.get_server_host_key()`: reiner
Key-Exchange, gar kein Anmeldeversuch. Ein Auth-Fehler kann sie damit nicht
mehr verhindern. Gespeichert wird jetzt neben dem Fingerprint auch der
**vollstaendige Host-Key** (`hosts.ssh_host_key`, Migration 0010).
* Vor jeder SSH-Sitzung wird der Host-Key des Ziels auf demselben Weg geholt
und gegen den hinterlegten geprueft -- **bevor** Schluesselmaterial
entschluesselt oder eine Anmeldung versucht wird. Weicht er ab, bricht die
Sitzung mit Klartextmeldung ab. Nach dem Sitzungsaufbau wird der Schluessel
der Sitzung noch einmal gegengeprueft (`connection.get_server_host_key()`),
damit auch ein Wechsel zwischen Pruefung und Sitzung auffaellt.
* Ist fuer einen Host **kein** Host-Key hinterlegt, wird die Verbindung
abgelehnt (`HostKeyNotPinnedError`) statt stillschweigend jedem Gegenueber
zu vertrauen. Kein Trust-on-First-Use.
* Fuer Hosts, deren Key vor Migration 0010 ermittelt wurde, wird der
vollstaendige Schluessel beim naechsten Verbindungsaufbau nachgetragen --
nachdem er gegen den gepinnten Fingerprint geprueft wurde.
**Benutzername gehoert zur Anmeldung, nicht zum Server (Migration 0010).**
`hosts.ssh_username` / `hosts.rdp_username` / `hosts.rdp_domain` waren am
falschen Objekt: der Anmeldename ist Teil der Zugangsdaten. Ab jetzt:
* **SSH:** `ssh_keys.username` -- ein Benutzername je Schluessel ("dieser Key
ist der Zugang von l4u"). Fuer ein zweites Konto wird ein zweiter
Schluesseleintrag angelegt.
* **RDP/Windows:** `rdp_credentials.username` und `.domain` -- zusammen mit
dem Passwort, das ohnehin schon dort liegt. Der Benutzername ist beim
Setzen der Zugangsdaten Pflicht.
* Die Felder verschwinden aus dem Host-Formular; das Hostdetail zeigt
stattdessen an, mit welchem Namen die Anmeldung erfolgen wuerde.
* Die Migration uebernimmt die alten Werte: bei RDP eindeutig, bei SSH nur
dann, wenn alle Hosts eines Schluessels denselben Benutzernamen hatten --
sonst bliebe es Raten. Solche Schluessel bleiben leer und greifen weiter auf
den Host-Wert zurueck, bis ein Admin den Namen am Schluessel setzt (die
Oberflaeche weist darauf hin).
* Die alten Spalten bleiben als Fallback lesbar (ein Spalten-Drop erzwaenge in
SQLite einen Tabellen-Rebuild, siehe Begruendung in `0006_tenants.sql`), die
API nimmt sie als `deprecated` weiterhin entgegen.
### Nach dem Deployment zu tun
1. Migration 0010 laeuft beim Start automatisch.
2. Fuer jeden SSH-Key pruefen, ob der Benutzername gesetzt ist
(Adminbereich -> Zugangsdaten -> Spalte "Benutzer").
3. Bei RDP-Hosts die Zugangsdaten einmal neu speichern (Benutzername +
Domaene + Passwort), falls die Uebernahme nichts gefunden hat.
4. Fuer SSH-Hosts ohne hinterlegten Host-Key einmal "Host-Key ermitteln"
ausfuehren -- ohne ihn wird die Verbindung jetzt abgelehnt.
## Tests
```bash
pytest -q
```
78 Tests (vorher 68; `tests/test_phase10.py` kam hinzu) decken ab: Argon2id/TOTP-Grundfunktionen, Audit-Hash-Chain
105 Tests (vorher 88; `tests/test_phase12.py` kam hinzu) decken ab: Argon2id/TOTP-Grundfunktionen, Audit-Hash-Chain
(inkl. Manipulationserkennung und Trigger-Durchsetzung), RBAC-Logik inkl.
Ablaufdaten, den vollstaendigen Login-Flow (Passwort -> TOTP-Enrollment ->
Session-Cookie -> geschuetzte Endpunkte) gegen die echte FastAPI-App, 17

View File

@ -811,13 +811,13 @@ async def get_host_detail(
if row is None:
raise HTTPException(status.HTTP_404_NOT_FOUND, "Host nicht gefunden")
keys_cursor = await conn.execute(
"SELECT sk.id, sk.label FROM host_ssh_key_map m JOIN ssh_keys sk ON sk.id = m.ssh_key_id "
"WHERE m.host_id = ?",
"SELECT sk.id, sk.label, sk.username FROM host_ssh_key_map m "
"JOIN ssh_keys sk ON sk.id = m.ssh_key_id WHERE m.host_id = ?",
(host_id,),
)
ssh_keys = [{"id": k[0], "label": k[1]} for k in await keys_cursor.fetchall()]
ssh_keys = [{"id": k[0], "label": k[1], "username": k[2]} for k in await keys_cursor.fetchall()]
rdp_row = await (await conn.execute(
"SELECT updated_at FROM rdp_credentials WHERE host_id = ?", (host_id,)
"SELECT updated_at, username, domain FROM rdp_credentials WHERE host_id = ?", (host_id,)
)).fetchone()
return {
"id": row[0], "hostname": row[1], "address": row[2], "protocol": row[3], "port": row[4],
@ -829,6 +829,10 @@ async def get_host_detail(
"ssh_keys": ssh_keys,
"rdp_credentials_set": rdp_row is not None,
"rdp_credentials_updated_at": rdp_row[0] if rdp_row else None,
# Benutzername/Domaene gehoeren seit Migration 0010 zu den
# Zugangsdaten; sie werden hier nur zur Anzeige mitgeliefert.
"rdp_credentials_username": rdp_row[1] if rdp_row else None,
"rdp_credentials_domain": rdp_row[2] if rdp_row else None,
}
@ -955,17 +959,28 @@ async def set_rdp_credentials(
conn = get_db()
if admin.is_any_admin:
await _assert_host_in_scope(conn, _scope(admin), host_id)
username = (payload.username or "").strip() or None
if username is None:
raise HTTPException(
status.HTTP_400_BAD_REQUEST,
"Bitte den Windows-Benutzernamen mit angeben -- er gehoert zu den "
"Zugangsdaten und wird fuer die Anmeldung am Ziel gebraucht.",
)
domain = (payload.domain or "").strip() or None
encrypted = encrypt_secret(payload.password.encode(), associated_data=b"rdp_password")
await conn.execute(
"INSERT INTO rdp_credentials (host_id, password_enc, updated_at) "
"VALUES (?, ?, strftime('%Y-%m-%dT%H:%M:%fZ','now')) "
"INSERT INTO rdp_credentials (host_id, password_enc, username, domain, updated_at) "
"VALUES (?, ?, ?, ?, strftime('%Y-%m-%dT%H:%M:%fZ','now')) "
"ON CONFLICT(host_id) DO UPDATE SET password_enc = excluded.password_enc, "
"username = excluded.username, domain = excluded.domain, "
"updated_at = excluded.updated_at",
(host_id, encrypted),
(host_id, encrypted, username, domain),
)
await write_audit_event(
conn, event_type="rdp_credentials_set", user_id=admin.id, client_ip=_client_ip(request),
details={"host_id": host_id},
# Benutzername/Domaene sind keine Geheimnisse (das Passwort schon) und
# gehoeren ins Audit-Log.
details={"host_id": host_id, "username": username, "domain": domain},
)
await conn.commit()
return {"status": "ok"}
@ -1011,18 +1026,20 @@ async def get_host_credentials(
if host_row is None:
raise HTTPException(status.HTTP_404_NOT_FOUND, "Host nicht gefunden")
keys_cursor = await conn.execute(
"SELECT sk.id, sk.label FROM host_ssh_key_map m JOIN ssh_keys sk ON sk.id = m.ssh_key_id "
"WHERE m.host_id = ?",
"SELECT sk.id, sk.label, sk.username FROM host_ssh_key_map m "
"JOIN ssh_keys sk ON sk.id = m.ssh_key_id WHERE m.host_id = ?",
(host_id,),
)
ssh_keys = [{"id": k[0], "label": k[1]} for k in await keys_cursor.fetchall()]
ssh_keys = [{"id": k[0], "label": k[1], "username": k[2]} for k in await keys_cursor.fetchall()]
rdp_row = await (await conn.execute(
"SELECT updated_at FROM rdp_credentials WHERE host_id = ?", (host_id,)
"SELECT updated_at, username, domain FROM rdp_credentials WHERE host_id = ?", (host_id,)
)).fetchone()
return {
"host_id": host_id, "ssh_keys": ssh_keys,
"rdp_credentials_set": rdp_row is not None,
"rdp_credentials_updated_at": rdp_row[0] if rdp_row else None,
"rdp_credentials_username": rdp_row[1] if rdp_row else None,
"rdp_credentials_domain": rdp_row[2] if rdp_row else None,
}
@ -1034,7 +1051,7 @@ async def list_rdp_credentials(admin: CurrentUser = Depends(require_admin_or_sco
scope = _scope(admin)
tenant_filter, params = scope.sql_filter("hg.tenant_id")
cursor = await conn.execute(
"SELECT h.id, h.hostname, h.address, hg.name, rc.updated_at "
"SELECT h.id, h.hostname, h.address, hg.name, rc.updated_at, rc.username, rc.domain "
"FROM hosts h JOIN host_groups hg ON hg.id = h.host_group_id "
"LEFT JOIN rdp_credentials rc ON rc.host_id = h.id "
f"WHERE h.protocol = 'rdp'{tenant_filter} ORDER BY h.hostname",
@ -1045,6 +1062,7 @@ async def list_rdp_credentials(admin: CurrentUser = Depends(require_admin_or_sco
{
"host_id": r[0], "hostname": r[1], "address": r[2], "host_group_name": r[3],
"credentials_set": r[4] is not None, "updated_at": r[4],
"username": r[5], "domain": r[6],
}
for r in rows
]
@ -1274,9 +1292,9 @@ async def create_ssh_key(
)
cursor = await conn.execute(
"INSERT INTO ssh_keys (label, owner_user_id, private_key_enc, public_key, key_type, "
"tenant_id, passphrase_enc) VALUES (?, ?, ?, ?, ?, ?, ?)",
"tenant_id, passphrase_enc, username) VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
(payload.label, payload.owner_user_id, encrypted, payload.public_key, payload.key_type,
tenant_id, passphrase_enc),
tenant_id, passphrase_enc, (payload.username or "").strip() or None),
)
new_id = cursor.lastrowid
await write_audit_event(
@ -1284,6 +1302,9 @@ async def create_ssh_key(
details={
"id": new_id, "label": payload.label, "key_type": payload.key_type,
"has_passphrase": bool(payload.passphrase),
# Der Anmeldename ist kein Geheimnis und im Audit-Log ausdruecklich
# erwuenscht: er beantwortet "wer hat sich womit angemeldet".
"username": (payload.username or "").strip() or None,
},
)
await conn.commit()
@ -1297,7 +1318,7 @@ async def list_ssh_keys(admin: CurrentUser = Depends(require_admin_or_scope("ssh
tenant_filter, params = scope.sql_filter("sk.tenant_id")
cursor = await conn.execute(
"SELECT sk.id, sk.label, sk.owner_user_id, sk.key_type, sk.created_at, sk.rotated_at, "
"sk.expires_at, sk.tenant_id, t.name, (sk.passphrase_enc IS NOT NULL) "
"sk.expires_at, sk.tenant_id, t.name, (sk.passphrase_enc IS NOT NULL), sk.username "
"FROM ssh_keys sk JOIN tenants t ON t.id = sk.tenant_id "
f"WHERE 1=1{tenant_filter} ORDER BY sk.id",
params,
@ -1311,6 +1332,9 @@ async def list_ssh_keys(admin: CurrentUser = Depends(require_admin_or_scope("ssh
# Nur die Tatsache, NIE die Passphrase selbst -- kein Endpunkt
# dieser Anwendung gibt jemals Klartext-Geheimnisse zurueck.
"has_passphrase": bool(r[9]),
# Anmeldename des Zielsystems (Migration 0010). Kein Geheimnis --
# er wird angezeigt, damit erkennbar ist, welcher Zugang das ist.
"username": r[10],
}
for r in rows
]
@ -1371,6 +1395,10 @@ async def update_ssh_key(
fields.append("label = ?"); values.append(payload.label)
if "owner_user_id" in payload.model_fields_set:
fields.append("owner_user_id = ?"); values.append(payload.owner_user_id)
if "username" in payload.model_fields_set:
# Gleiche Semantik wie bei passphrase: Weglassen = unveraendert,
# explizites null/"" = entfernen.
fields.append("username = ?"); values.append((payload.username or "").strip() or None)
if rotating:
encrypted = encrypt_secret(payload.private_key_pem.encode(), associated_data=b"ssh_private_key")
fields += ["private_key_enc = ?", "public_key = ?", "key_type = ?",
@ -1396,6 +1424,7 @@ async def update_ssh_key(
"id": key_id, "rotated": rotating,
"passphrase_changed": bool(rotating or passphrase_given),
"has_passphrase": bool(passphrase),
"username_changed": "username" in payload.model_fields_set,
},
)
await conn.commit()

View File

@ -0,0 +1,55 @@
-- Phase 12: Benutzernamen gehoeren zu den ZUGANGSDATEN, nicht zum Host.
--
-- Bisher standen ssh_username bzw. rdp_username/rdp_domain in der Tabelle
-- hosts -- also beim "Server". Fachlich falsch: der Benutzername ist Teil der
-- Anmeldung (er gehoert zum Schluessel bzw. zum Passwort), nicht zur
-- Beschreibung des Zielsystems. Ab jetzt:
-- * SSH: ssh_keys.username (ein Benutzername je Schluessel)
-- * RDP: rdp_credentials.username / .domain (je Host-Passwortsatz)
-- Die alten hosts-Spalten bleiben im Schema (SQLite-Spalten zu entfernen
-- erzwingt einen Tabellen-Rebuild, siehe die ausfuehrliche Begruendung in
-- 0006_tenants.sql) und werden nur noch als Fallback fuer Datensaetze
-- gelesen, die vor dieser Migration angelegt und noch nicht umgestellt
-- wurden. Neue Schreibpfade fassen sie nicht mehr an, die Admin-Oberflaeche
-- zeigt sie nicht mehr an.
ALTER TABLE ssh_keys ADD COLUMN username TEXT;
ALTER TABLE rdp_credentials ADD COLUMN username TEXT;
ALTER TABLE rdp_credentials ADD COLUMN domain TEXT;
-- Uebernahme der bestehenden Werte:
--
-- SSH: nur wenn ALLE Hosts, denen der Schluessel zugeordnet ist, denselben
-- Benutzernamen tragen (COUNT(DISTINCT ...) = 1). Waeren es mehrere, waere
-- jede automatische Wahl geraten -- solche Schluessel bleiben leer und
-- greifen weiter auf den Host-Fallback zurueck, bis ein Admin den
-- Benutzernamen am Schluessel setzt (die Oberflaeche weist darauf hin).
UPDATE ssh_keys
SET username = (
SELECT h.ssh_username
FROM host_ssh_key_map m JOIN hosts h ON h.id = m.host_id
WHERE m.ssh_key_id = ssh_keys.id
AND h.ssh_username IS NOT NULL AND TRIM(h.ssh_username) <> ''
ORDER BY h.id LIMIT 1)
WHERE username IS NULL
AND (SELECT COUNT(DISTINCT h.ssh_username)
FROM host_ssh_key_map m JOIN hosts h ON h.id = m.host_id
WHERE m.ssh_key_id = ssh_keys.id
AND h.ssh_username IS NOT NULL AND TRIM(h.ssh_username) <> '') = 1;
-- RDP: eindeutig, weil rdp_credentials ohnehin genau einen Datensatz pro Host
-- haelt (host_id ist Primaerschluessel).
UPDATE rdp_credentials
SET username = (SELECT h.rdp_username FROM hosts h WHERE h.id = rdp_credentials.host_id),
domain = (SELECT h.rdp_domain FROM hosts h WHERE h.id = rdp_credentials.host_id)
WHERE username IS NULL;
-- hosts.ssh_host_key: der VOLLSTAENDIGE Host-Key im OpenSSH-Format, nicht nur
-- sein Fingerprint. Grund: mit dem Fingerprint allein laesst sich eine
-- Verbindung nicht vorab pinnen -- man sieht den Schluessel erst, wenn der
-- Server ihn praesentiert hat. Mit dem gespeicherten Schluessel kann der
-- Jumphost den vom Ziel angebotenen Schluessel VOR der Anmeldung byteweise
-- vergleichen (siehe app/ssh_proxy/proxy.py). Wird beim "Host-Key ermitteln"
-- mitgeschrieben und fuer Altbestand beim naechsten erfolgreichen
-- Verbindungsaufbau nachgetragen.
ALTER TABLE hosts ADD COLUMN ssh_host_key TEXT;

View File

@ -21,10 +21,31 @@ from app.catalog.routes import router as catalog_router
from app.db import close_db, init_db
from app.rdp_proxy.ws_tunnel import router as rdp_ws_router
from app.security import log_stream
from app.ssh_proxy.proxy import bcrypt_kdf_available
from app.ssh_proxy.sftp import router as sftp_router
from app.ssh_proxy.terminal_ws import router as ssh_ws_router
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(name)s: %(message)s")
logger = logging.getLogger("jumphost.main")
def _check_optional_dependencies() -> None:
"""Meldet fehlende, formal optionale Laufzeitabhaengigkeiten beim Start.
Bislang fiel das Fehlen von 'bcrypt' erst auf, wenn ein Benutzer eine
SSH-Sitzung startete -- und dann als englische asyncssh-Meldung, die nach
einem defekten Schluessel aussah. Die Warnung laeuft absichtlich NACH
log_stream.install(), damit sie auch im Live-Verbindungslog des
Adminbereichs sichtbar ist.
"""
if not bcrypt_kdf_available():
logger.error(
"Das Python-Modul 'bcrypt' fehlt im Virtualenv. Passphrasegeschuetzte "
"SSH-Schluessel im OpenSSH-Format (ssh-keygen-Standard) koennen damit "
"NICHT geladen werden. Behebung: pip install -r requirements.txt "
"(bzw. Ansible-Rolle python_runtime erneut ausrollen) und Dienst "
"neu starten."
)
@asynccontextmanager
@ -33,6 +54,7 @@ async def lifespan(app: FastAPI):
# app/security/log_stream.py) -- MUSS vor init_db() installiert werden,
# damit auch fruehe Startmeldungen im Puffer landen.
log_stream.install()
_check_optional_dependencies()
await init_db()
yield
await close_db()

View File

@ -114,9 +114,15 @@ class HostCreateRequest(BaseModel):
port: int = Field(gt=0, le=65535)
os_type: Literal["linux", "windows"]
ssh_host_key_fingerprint: str | None = Field(default=None, max_length=512)
ssh_username: str | None = Field(default=None, max_length=128)
rdp_username: str | None = Field(default=None, max_length=128)
rdp_domain: str | None = Field(default=None, max_length=128)
# ---- Altlast (vor Migration 0010) -------------------------------------
# Benutzername und Domaene gehoeren zu den ZUGANGSDATEN (ssh_keys.username
# bzw. rdp_credentials.username/.domain) und nicht zum Host. Die Felder
# bleiben nur entgegennahmefaehig, damit bestehende Skripte/Tokens nicht
# brechen; die Admin-Oberflaeche sendet sie nicht mehr, und der
# Verbindungsaufbau liest sie nur noch als Fallback.
ssh_username: str | None = Field(default=None, max_length=128, deprecated=True)
rdp_username: str | None = Field(default=None, max_length=128, deprecated=True)
rdp_domain: str | None = Field(default=None, max_length=128, deprecated=True)
rdp_require_nla: bool = True
clipboard_enabled: bool = True
file_transfer_enabled: bool = True
@ -139,9 +145,10 @@ class HostUpdateRequest(BaseModel):
hostname: str | None = Field(default=None, min_length=1, max_length=128)
address: str | None = Field(default=None, min_length=1, max_length=255)
port: int | None = Field(default=None, gt=0, le=65535)
ssh_username: str | None = None
rdp_username: str | None = None
rdp_domain: str | None = None
# Siehe HostCreateRequest: Altlast, gehoert zu den Zugangsdaten.
ssh_username: str | None = Field(default=None, deprecated=True)
rdp_username: str | None = Field(default=None, deprecated=True)
rdp_domain: str | None = Field(default=None, deprecated=True)
rdp_require_nla: bool | None = None
clipboard_enabled: bool | None = None
file_transfer_enabled: bool | None = None
@ -214,6 +221,10 @@ class SshKeyCreateRequest(BaseModel):
public_key: str = Field(min_length=1, max_length=8192)
key_type: Literal["ed25519", "rsa-3072", "rsa-4096", "ca-cert"]
tenant_id: int | None = None
# Anmeldename am Zielsystem. Gehoert zum Schluessel, nicht zum Host
# (Migration 0010): ein Schluessel ist immer "der Zugang von Benutzer X".
# Fuer mehrere Konten wird je ein Schluesseleintrag angelegt.
username: str | None = Field(default=None, max_length=128)
# Passphrase eines verschluesselten Private Keys. Wird -- wie das
# Schluesselmaterial selbst -- mit dem KEK verschluesselt gespeichert
# (ssh_keys.passphrase_enc, Migration 0009) und beim Verbindungsaufbau an
@ -230,6 +241,10 @@ class SshKeyUpdateRequest(BaseModel):
private_key_pem: str | None = Field(default=None, min_length=1, max_length=32_768)
public_key: str | None = Field(default=None, min_length=1, max_length=8192)
key_type: Literal["ed25519", "rsa-3072", "rsa-4096", "ca-cert"] | None = None
# Wird -- wie passphrase -- nur beachtet, wenn das Feld im Request
# vorkommt: Weglassen laesst den Benutzernamen unveraendert, explizites
# null/"" entfernt ihn.
username: str | None = Field(default=None, max_length=128)
# Wird nur beachtet, wenn das Feld im Request vorkommt (siehe
# update_ssh_key in app/admin/routes.py): explizites null/"" entfernt eine
# hinterlegte Passphrase, Weglassen laesst sie unveraendert. Ohne
@ -244,3 +259,7 @@ class ConnectRequest(BaseModel):
class RdpCredentialsRequest(BaseModel):
password: str = Field(min_length=1, max_length=512)
# Benutzername und (optional) Domaene der Windows-Anmeldung. Seit
# Migration 0010 Teil der Zugangsdaten statt des Hosts.
username: str | None = Field(default=None, max_length=128)
domain: str | None = Field(default=None, max_length=128)

View File

@ -162,7 +162,13 @@ async def open_tunnel(
return GuacdTunnel(reader, writer, connection_id)
def build_rdp_params(host: dict, password: str) -> dict[str, str]:
def build_rdp_params(
host: dict,
password: str,
*,
username: str | None = None,
domain: str | None = None,
) -> dict[str, str]:
"""Baut die Parameter-Map fuer die connect-Instruktion aus dem Host-Datensatz.
Sicherheitsdefaults (siehe Konzept 6.3/6.7): NLA wird erzwungen sofern
@ -171,17 +177,19 @@ def build_rdp_params(host: dict, password: str) -> dict[str, str]:
ohne interne PKI praesentiert jedes Windows-Ziel ein selbstsigniertes
Zertifikat, guacd/FreeRDP bricht dann bereits vor dem ersten Bild ab.
`username`/`domain` kommen aus rdp_credentials (Migration 0010) -- der
Benutzername gehoert zur Anmeldung, nicht zum Zielsystem. Fehlen sie, wird
auf hosts.rdp_username/rdp_domain zurueckgegriffen (Altbestand).
WICHTIG: `host` muss aus app.ssh_proxy.proxy.load_host() stammen. Diese
Funktion liest rdp_username/rdp_domain/rdp_require_nla/clipboard_enabled/
rdp_ignore_cert -- genau diese Spalten hat load_host() frueher NICHT
selektiert, wodurch hier still ein leerer Benutzername in die
connect-Instruktion ging und die Anmeldung am Zielsystem zwangslaeufig
scheiterte. Fehlt eine der Spalten jetzt trotzdem, ist das ein
Programmierfehler und soll auffallen statt still zu einer kaputten
Verbindung zu fuehren.
Funktion liest rdp_require_nla/clipboard_enabled/rdp_ignore_cert -- genau
diese Spalten hat load_host() frueher NICHT selektiert, wodurch hier still
eine unbrauchbare connect-Instruktion entstand. Fehlt eine der Spalten
jetzt trotzdem, ist das ein Programmierfehler und soll auffallen statt
still zu einer kaputten Verbindung zu fuehren.
"""
missing = [
key for key in ("rdp_username", "rdp_require_nla", "clipboard_enabled", "rdp_ignore_cert")
key for key in ("rdp_require_nla", "clipboard_enabled", "rdp_ignore_cert")
if key not in host
]
if missing:
@ -189,20 +197,25 @@ def build_rdp_params(host: dict, password: str) -> dict[str, str]:
f"Hostdatensatz unvollstaendig, fehlende Felder: {', '.join(missing)}"
)
username = (host.get("rdp_username") or "").strip()
if not username:
# Benutzername/Domaene kommen seit Migration 0010 aus den Zugangsdaten
# (rdp_credentials). Der Wert am Host wird nur noch als Fallback fuer
# Datensaetze gelesen, die vor der Migration angelegt wurden.
effective_username = (username or "").strip() or (host.get("rdp_username") or "").strip()
if not effective_username:
raise GuacamoleProtocolError(
"Fuer diesen Host ist kein RDP-Benutzername hinterlegt "
"(Adminbereich -> Server -> RDP-Benutzer)."
"Fuer diesen Host ist kein RDP-Benutzername hinterlegt. Der Benutzername "
"gehoert zu den Zugangsdaten (Adminbereich -> Zugangsdaten -> "
"RDP/Windows-Passwoerter)."
)
effective_domain = (domain or "").strip() or (host.get("rdp_domain") or "").strip()
clipboard_enabled = bool(host.get("clipboard_enabled"))
return {
"hostname": host["address"],
"port": str(host["port"]),
"username": username,
"username": effective_username,
"password": password,
"domain": host.get("rdp_domain") or "",
"domain": effective_domain,
"security": "nla" if host.get("rdp_require_nla", True) else "any",
"ignore-cert": "true" if host.get("rdp_ignore_cert", True) else "false",
"disable-audio": "true",

View File

@ -174,8 +174,12 @@ async def rdp_tunnel(
)
return
# username/domain stehen seit Migration 0010 bei den Zugangsdaten und nicht
# mehr am Host -- der Benutzername gehoert zur Anmeldung, nicht zur
# Beschreibung des Zielsystems.
cred_cursor = await conn.execute(
"SELECT password_enc FROM rdp_credentials WHERE host_id = ?", (host_id,)
"SELECT password_enc, username, domain FROM rdp_credentials WHERE host_id = ?",
(host_id,),
)
cred_row = await cred_cursor.fetchone()
if cred_row is None:
@ -221,7 +225,9 @@ async def rdp_tunnel(
# riss ein Fehler hier VOR jeglicher Protokollierung durch und die
# Sitzung verschwand spurlos (schwarzer Bildschirm, kein Log-Eintrag).
password = decrypt_secret(cred_row[0], associated_data=b"rdp_password")
params = build_rdp_params(host, password.decode())
params = build_rdp_params(
host, password.decode(), username=cred_row[1], domain=cred_row[2]
)
tunnel = await open_tunnel(
guacd_host=settings.guacd_host, guacd_port=settings.guacd_port,
protocol="rdp", params=params, screen_width=width, screen_height=height, dpi=dpi,

View File

@ -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()
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

View File

@ -17,9 +17,11 @@ from app.rbac import user_has_role_for_host
from app.security.audit import write_audit_event
from app.security.av_scan import scan_bytes
from app.ssh_proxy.proxy import (
SSH_SETUP_ERRORS,
HostNotConfiguredError,
PrivateKeyUnusableError,
connect_to_host,
describe_connection_error,
load_host,
)
@ -102,15 +104,13 @@ async def upload_file(
await remote_file.write(data)
finally:
ssh_conn.close()
except HostNotConfiguredError as exc:
raise HTTPException(status.HTTP_400_BAD_REQUEST, str(exc))
except PrivateKeyUnusableError as exc:
# Gleiche Ursache wie bei einer scheiternden Terminalsitzung (siehe
# app/ssh_proxy/terminal_ws.py): passphrasegeschuetzter Schluessel
# ohne hinterlegte Passphrase. Hier als 400 mit Klartext statt als
# unbehandelter 500.
except SSH_SETUP_ERRORS as exc:
# Alles, was den Verbindungsaufbau verhindert und in der Konfiguration
# begruendet ist (fehlender Benutzername/Schluessel, unbrauchbarer
# Schluessel, nicht gepinnter oder abweichender Host-Key, Ziel nicht
# erreichbar): 400 mit Klartext statt eines unbehandelten 500.
logger.warning("Dateitransfer fuer Host %s nicht moeglich: %s", host_id, exc)
raise HTTPException(status.HTTP_400_BAD_REQUEST, str(exc))
raise HTTPException(status.HTTP_400_BAD_REQUEST, describe_connection_error(exc))
await _log_transfer(
conn, user=user, host_id=host_id, client_ip=_client_ip(request), direction="upload",
@ -139,15 +139,13 @@ async def download_file(
data = await remote_file.read()
finally:
ssh_conn.close()
except HostNotConfiguredError as exc:
raise HTTPException(status.HTTP_400_BAD_REQUEST, str(exc))
except PrivateKeyUnusableError as exc:
# Gleiche Ursache wie bei einer scheiternden Terminalsitzung (siehe
# app/ssh_proxy/terminal_ws.py): passphrasegeschuetzter Schluessel
# ohne hinterlegte Passphrase. Hier als 400 mit Klartext statt als
# unbehandelter 500.
except SSH_SETUP_ERRORS as exc:
# Alles, was den Verbindungsaufbau verhindert und in der Konfiguration
# begruendet ist (fehlender Benutzername/Schluessel, unbrauchbarer
# Schluessel, nicht gepinnter oder abweichender Host-Key, Ziel nicht
# erreichbar): 400 mit Klartext statt eines unbehandelten 500.
logger.warning("Dateitransfer fuer Host %s nicht moeglich: %s", host_id, exc)
raise HTTPException(status.HTTP_400_BAD_REQUEST, str(exc))
raise HTTPException(status.HTTP_400_BAD_REQUEST, describe_connection_error(exc))
sha256 = hashlib.sha256(data).hexdigest()
filename = remote_path.rsplit("/", 1)[-1]

View File

@ -24,9 +24,11 @@ from app.recordings.recorder import SessionRecorder
from app.security import active_sessions
from app.security.audit import write_audit_event
from app.ssh_proxy.proxy import (
SSH_SETUP_ERRORS,
HostNotConfiguredError,
PrivateKeyUnusableError,
connect_to_host,
describe_connection_error,
load_host,
)
@ -153,11 +155,18 @@ async def ssh_terminal(websocket: WebSocket, host_id: int):
await websocket.send_json({"type": "error", "message": str(exc)})
except Exception:
logger.debug("Fehlermeldung konnte nicht mehr an Client gesendet werden", exc_info=True)
except HostNotConfiguredError as exc:
except SSH_SETUP_ERRORS as exc:
# Alles, was den Sitzungsaufbau verhindert und der Benutzer selbst
# einordnen kann: fehlender Benutzername/Schluessel, nicht gepinnter
# oder abweichender Host-Key, unerreichbares Ziel. Bewusst mit
# Klartextmeldung statt "interner Fehler" -- der Grund steht in der
# Konfiguration, nicht im Code.
logger.warning("SSH-Sitzung %s abgebrochen: %s", session_id, exc)
end_reason = "error"
try:
await websocket.send_json({"type": "error", "message": str(exc)})
await websocket.send_json(
{"type": "error", "message": describe_connection_error(exc)}
)
except Exception:
logger.debug("Fehlermeldung konnte nicht mehr an Client gesendet werden", exc_info=True)
except asyncio.CancelledError:

View File

@ -6,6 +6,12 @@ starlette==1.6.0
uvicorn[standard]==0.32.1
aiosqlite==0.20.0
asyncssh==2.18.0
# Pflicht, nicht optional: asyncssh (und cryptography) koennen ohne dieses
# Modul KEINEN passphrasegeschuetzten Schluessel im OpenSSH-Format lesen --
# also genau das, was `ssh-keygen` standardmaessig erzeugt. Fehlt es, endet
# jeder Import mit "OpenSSH private key encryption requires bcrypt with KDF
# support". Entspricht dem Extra asyncssh[bcrypt].
bcrypt==4.3.0
argon2-cffi==23.1.0
pyotp==2.9.0
qrcode[pil]==7.4.2

View File

@ -505,6 +505,27 @@
box.replaceChildren(el("div", { className: `banner ${type === "error" ? "error" : "ok"}`, textContent: message }));
}
function describeEffectiveUsername(host, isRdp) {
if (isRdp) {
if (host.rdp_credentials_username) {
const domain = host.rdp_credentials_domain ? `, Domaene ${host.rdp_credentials_domain}` : "";
return `Anmeldung als "${host.rdp_credentials_username}"${domain} (aus den RDP-Zugangsdaten unten).`;
}
if (host.rdp_username) {
return `Noch kein Benutzername in den Zugangsdaten -- es wird der alte Wert am Host verwendet ("${host.rdp_username}"). Bitte unten bei den RDP-Zugangsdaten neu setzen.`;
}
return "Kein Windows-Benutzername hinterlegt -- unten bei den RDP-Zugangsdaten eintragen, sonst schlaegt die Anmeldung fehl.";
}
const named = (host.ssh_keys || []).filter((k) => k.username);
if (named.length) {
return `Anmeldung als ${named.map((k) => `"${k.username}" (Key ${k.label})`).join(", ")}.`;
}
if (host.ssh_username) {
return `Noch kein Benutzername am SSH-Key -- es wird der alte Wert am Host verwendet ("${host.ssh_username}"). Bitte im Reiter "Zugangsdaten" am Schluessel eintragen.`;
}
return 'Kein SSH-Benutzername hinterlegt -- im Reiter "Zugangsdaten" am zugeordneten Schluessel eintragen, sonst kommt keine Verbindung zustande.';
}
async function showHostDetail(hostId) {
setDetailBanner("", "ok");
try {
@ -516,9 +537,6 @@
document.getElementById("he-hostname").value = host.hostname;
document.getElementById("he-address").value = host.address;
document.getElementById("he-port").value = String(host.port);
document.getElementById("he-ssh-username").value = host.ssh_username || "";
document.getElementById("he-rdp-username").value = host.rdp_username || "";
document.getElementById("he-rdp-domain").value = host.rdp_domain || "";
document.getElementById("he-clipboard").checked = host.clipboard_enabled;
document.getElementById("he-filetransfer").checked = host.file_transfer_enabled;
document.getElementById("he-nla").checked = host.rdp_require_nla;
@ -526,9 +544,11 @@
document.getElementById("he-active").checked = host.is_active;
const isRdp = host.protocol === "rdp";
document.getElementById("he-ssh-username-box").classList.toggle("hidden", isRdp);
document.getElementById("he-rdp-username-box").classList.toggle("hidden", !isRdp);
document.getElementById("he-rdp-domain-box").classList.toggle("hidden", !isRdp);
// Der Anmeldename steht bei den Zugangsdaten (SSH-Key bzw.
// RDP-Passwortsatz). Hier wird nur angezeigt, welcher Name greifen
// wuerde -- inklusive des Altbestands am Host, solange er noch benutzt
// wird.
document.getElementById("he-username-hint").textContent = describeEffectiveUsername(host, isRdp);
document.getElementById("he-nla-box").classList.toggle("hidden", !isRdp);
document.getElementById("he-ignorecert-box").classList.toggle("hidden", !isRdp);
document.getElementById("host-detail-ssh").classList.toggle("hidden", isRdp);
@ -548,6 +568,7 @@
el("tr", {}, [
el("td", { textContent: String(k.id) }),
el("td", { textContent: k.label }),
el("td", { textContent: k.username || "(kein Benutzername)" }),
el("td", {}, [
actionButton("Entfernen", "btn-danger", async () => {
await apiFetch(`/admin/hosts/${host.id}/ssh-keys/${k.id}`, { method: "DELETE" });
@ -561,8 +582,10 @@
keySelect.replaceChildren(...optionsFromList(cachedSshKeys, "id", (k) => `${k.label} (#${k.id})`));
document.getElementById("rdp-cred-status").textContent = host.rdp_credentials_set
? `RDP-Passwort ist gesetzt (zuletzt aktualisiert: ${host.rdp_credentials_updated_at}).`
: "Noch kein RDP-Passwort gesetzt.";
? `RDP-Zugangsdaten gesetzt fuer "${host.rdp_credentials_username || "(kein Benutzername)"}" (zuletzt aktualisiert: ${host.rdp_credentials_updated_at}).`
: "Noch keine RDP-Zugangsdaten gesetzt.";
document.getElementById("hrc-username").value = host.rdp_credentials_username || host.rdp_username || "";
document.getElementById("hrc-domain").value = host.rdp_credentials_domain || host.rdp_domain || "";
} catch (err) {
setDetailBanner(err.message, "error");
}
@ -575,9 +598,6 @@
hostname: document.getElementById("he-hostname").value.trim(),
address: document.getElementById("he-address").value.trim(),
port: Number(document.getElementById("he-port").value),
ssh_username: document.getElementById("he-ssh-username").value.trim() || null,
rdp_username: document.getElementById("he-rdp-username").value.trim() || null,
rdp_domain: document.getElementById("he-rdp-domain").value.trim() || null,
clipboard_enabled: document.getElementById("he-clipboard").checked,
file_transfer_enabled: document.getElementById("he-filetransfer").checked,
rdp_require_nla: document.getElementById("he-nla").checked,
@ -594,9 +614,6 @@
document.getElementById("hc-protocol").addEventListener("change", (ev) => {
const isRdp = ev.target.value === "rdp";
document.getElementById("hc-ssh-username-box").classList.toggle("hidden", isRdp);
document.getElementById("hc-rdp-username-box").classList.toggle("hidden", !isRdp);
document.getElementById("hc-rdp-domain-box").classList.toggle("hidden", !isRdp);
document.getElementById("hc-nla-box").classList.toggle("hidden", !isRdp);
document.getElementById("hc-ignorecert-box").classList.toggle("hidden", !isRdp);
document.getElementById("hc-ignorecert-hint").classList.toggle("hidden", !isRdp);
@ -632,9 +649,6 @@
protocol,
port: Number(document.getElementById("hc-port").value),
os_type: document.getElementById("hc-os-type").value,
ssh_username: document.getElementById("hc-ssh-username").value.trim() || null,
rdp_username: document.getElementById("hc-rdp-username").value.trim() || null,
rdp_domain: document.getElementById("hc-rdp-domain").value.trim() || null,
rdp_require_nla: document.getElementById("hc-nla").checked,
rdp_ignore_cert: document.getElementById("hc-ignorecert").checked,
clipboard_enabled: document.getElementById("hc-clipboard").checked,
@ -679,7 +693,9 @@
ev.preventDefault();
try {
const password = document.getElementById("hrc-password").value;
await sendJson(`/admin/hosts/${activeHostId}/rdp-credentials`, "PUT", { password });
const username = document.getElementById("hrc-username").value.trim();
const domain = document.getElementById("hrc-domain").value.trim() || null;
await sendJson(`/admin/hosts/${activeHostId}/rdp-credentials`, "PUT", { password, username, domain });
showBanner("RDP-Zugangsdaten gespeichert.", "ok");
ev.target.reset();
await showHostDetail(activeHostId);
@ -709,6 +725,7 @@
el("tr", {}, [
el("td", { textContent: String(k.id) }),
el("td", { textContent: k.label }),
el("td", { textContent: k.username || "-" }),
el("td", { textContent: k.key_type }),
el("td", { textContent: k.owner_user_id === null ? "-" : String(k.owner_user_id) }),
el("td", { textContent: k.tenant_name }),
@ -736,6 +753,7 @@
document.getElementById("ssh-key-edit-panel").classList.remove("hidden");
document.getElementById("ssh-key-edit-name").textContent = `${k.label} (#${k.id})`;
document.getElementById("ske-label").value = k.label;
document.getElementById("ske-username").value = k.username || "";
document.getElementById("ske-owner").value = k.owner_user_id === null ? "" : String(k.owner_user_id);
document.getElementById("skr-type").value = k.key_type;
document.getElementById("skp-passphrase").value = "";
@ -784,6 +802,9 @@
const ownerRaw = document.getElementById("ske-owner").value.trim();
await sendJson(`/admin/ssh-keys/${editingSshKeyId}`, "PUT", {
label: document.getElementById("ske-label").value.trim() || null,
// Leeres Feld -> null entfernt den Benutzernamen bewusst (die
// Server-Semantik unterscheidet zwischen "Feld fehlt" und "null").
username: document.getElementById("ske-username").value.trim() || null,
owner_user_id: ownerRaw ? Number(ownerRaw) : null,
});
showBanner("SSH-Key aktualisiert.", "ok");
@ -817,6 +838,7 @@
const tenantSelect = document.getElementById("skc-tenant");
const payload = {
label: document.getElementById("skc-label").value.trim(),
username: document.getElementById("skc-username").value.trim() || null,
owner_user_id: ownerRaw ? Number(ownerRaw) : null,
key_type: document.getElementById("skc-type").value,
private_key_pem: document.getElementById("skc-private").value,
@ -844,6 +866,7 @@
el("td", { textContent: r.hostname }),
el("td", { textContent: r.address }),
el("td", { textContent: r.host_group_name }),
el("td", { textContent: r.username || "-" }),
el("td", {}, [el("span", { className: `badge ${r.credentials_set ? "ok" : ""}`, textContent: r.credentials_set ? "gesetzt" : "nicht gesetzt" })]),
el("td", { textContent: r.updated_at || "-" }),
el("td", {}, [

View File

@ -235,18 +235,7 @@
<option value="windows">Windows</option>
</select>
</div>
<div id="hc-ssh-username-box">
<label for="hc-ssh-username">SSH-Benutzername</label>
<input type="text" id="hc-ssh-username" maxlength="128">
</div>
<div id="hc-rdp-username-box" class="hidden">
<label for="hc-rdp-username">RDP-Benutzername</label>
<input type="text" id="hc-rdp-username" maxlength="128">
</div>
<div id="hc-rdp-domain-box" class="hidden">
<label for="hc-rdp-domain">RDP-Domaene (optional)</label>
<input type="text" id="hc-rdp-domain" maxlength="128">
</div>
<p class="hint full-width">Der Benutzername wird nicht mehr am Server gepflegt, sondern bei den Zugangsdaten: bei SSH am SSH-Key, bei RDP/Windows zusammen mit dem Passwort (Reiter "Zugangsdaten").</p>
<div class="checkbox-row"><input type="checkbox" id="hc-clipboard" checked><label for="hc-clipboard">Zwischenablage erlaubt</label></div>
<div class="checkbox-row"><input type="checkbox" id="hc-filetransfer" checked><label for="hc-filetransfer">Dateitransfer erlaubt</label></div>
<div class="checkbox-row hidden" id="hc-nla-box"><input type="checkbox" id="hc-nla" checked><label for="hc-nla">RDP: NLA erforderlich</label></div>
@ -286,18 +275,7 @@
<label for="he-port">Port</label>
<input type="text" id="he-port" inputmode="numeric" required maxlength="5">
</div>
<div id="he-ssh-username-box">
<label for="he-ssh-username">SSH-Benutzername</label>
<input type="text" id="he-ssh-username" maxlength="128">
</div>
<div id="he-rdp-username-box" class="hidden">
<label for="he-rdp-username">RDP-Benutzername</label>
<input type="text" id="he-rdp-username" maxlength="128">
</div>
<div id="he-rdp-domain-box" class="hidden">
<label for="he-rdp-domain">RDP-Domaene</label>
<input type="text" id="he-rdp-domain" maxlength="128">
</div>
<p class="hint full-width" id="he-username-hint"></p>
<div class="checkbox-row"><input type="checkbox" id="he-clipboard"><label for="he-clipboard">Zwischenablage erlaubt</label></div>
<div class="checkbox-row"><input type="checkbox" id="he-filetransfer"><label for="he-filetransfer">Dateitransfer erlaubt</label></div>
<div class="checkbox-row hidden" id="he-nla-box"><input type="checkbox" id="he-nla"><label for="he-nla">RDP: NLA erforderlich</label></div>
@ -308,7 +286,7 @@
<div id="host-detail-ssh">
<h3>SSH-Host-Key</h3>
<p class="hint">Ermittelt den Host-Key EINMALIG ohne Pruefung (bewusste Trust-Entscheidung) und speichert den Fingerprint fuer kuenftiges Pinning. Wird prominent im Audit-Log vermerkt.</p>
<p class="hint">Holt den Host-Key des Ziels EINMALIG ohne Anmeldung (reiner Key-Exchange) und speichert Fingerprint und Schluessel fuer das Pinning kuenftiger Verbindungen. Bewusste Vertrauensentscheidung, wird prominent im Audit-Log vermerkt. Ohne hinterlegten Host-Key wird jede SSH-Verbindung zu diesem Host abgelehnt.</p>
<button type="button" class="btn-secondary" id="host-discover-key-btn">Host-Key ermitteln</button>
<div id="host-key-result" class="reveal-box hidden"></div>
@ -331,10 +309,19 @@
<h3>RDP-Zugangsdaten</h3>
<p class="hint" id="rdp-cred-status"></p>
<form id="host-rdp-cred-form" class="form-grid">
<div>
<label for="hrc-username">Windows-Benutzername</label>
<input type="text" id="hrc-username" required maxlength="128" autocomplete="off">
</div>
<div>
<label for="hrc-domain">Domaene (optional)</label>
<input type="text" id="hrc-domain" maxlength="128" autocomplete="off">
</div>
<div class="full-width">
<label for="hrc-password">RDP-Passwort setzen/aendern</label>
<input type="text" id="hrc-password" required maxlength="512">
</div>
<p class="hint full-width">Benutzername, Domaene und Passwort gehoeren zusammen und werden gemeinsam gespeichert. Das Passwort wird AES-256-GCM-verschluesselt abgelegt und nie wieder ausgegeben; beim Aendern bitte alle drei Felder ausfuellen.</p>
<button type="submit">Speichern</button>
</form>
</div>
@ -350,6 +337,11 @@
<label for="skc-label">Label</label>
<input type="text" id="skc-label" required maxlength="128">
</div>
<div>
<label for="skc-username">Benutzername am Zielsystem</label>
<input type="text" id="skc-username" maxlength="128" autocomplete="off">
<p class="hint">Der Anmeldename, mit dem dieser Schluessel benutzt wird (z.B. root oder l4u). Er gehoert zum Zugang, nicht zum Server -- fuer ein zweites Konto einen zweiten Schluesseleintrag anlegen.</p>
</div>
<div>
<label for="skc-owner">Besitzer (User-ID, optional)</label>
<input type="text" id="skc-owner" inputmode="numeric" maxlength="10">
@ -385,7 +377,7 @@
<h3>Vorhandene SSH-Keys</h3>
<div class="table-wrap">
<table class="data-table" id="ssh-keys-table">
<thead><tr><th>ID</th><th>Label</th><th>Typ</th><th>Besitzer</th><th>Mandant</th><th>Passphrase</th><th>Erstellt</th><th>Rotiert</th><th></th></tr></thead>
<thead><tr><th>ID</th><th>Label</th><th>Benutzer</th><th>Typ</th><th>Besitzer</th><th>Mandant</th><th>Passphrase</th><th>Erstellt</th><th>Rotiert</th><th></th></tr></thead>
<tbody></tbody>
</table>
</div>
@ -398,11 +390,15 @@
<label for="ske-label">Label</label>
<input type="text" id="ske-label" maxlength="128">
</div>
<div>
<label for="ske-username">Benutzername am Zielsystem</label>
<input type="text" id="ske-username" maxlength="128" autocomplete="off">
</div>
<div>
<label for="ske-owner">Besitzer (User-ID, leer = keiner)</label>
<input type="text" id="ske-owner" inputmode="numeric" maxlength="10">
</div>
<button type="submit">Speichern (nur Label/Besitzer)</button>
<button type="submit">Speichern (Label/Benutzername/Besitzer)</button>
</form>
<h3>Passphrase</h3>
<p class="hint" id="ske-passphrase-state"></p>
@ -448,7 +444,7 @@
<p class="hint">Uebersicht aller RDP-Hosts. Passwoerter werden AES-256-GCM-verschluesselt gespeichert und sind nach dem Setzen nicht mehr im Klartext abrufbar.</p>
<div class="table-wrap">
<table class="data-table" id="rdp-creds-table">
<thead><tr><th>Host</th><th>Adresse</th><th>Hostgruppe</th><th>Status</th><th>Zuletzt gesetzt</th><th></th></tr></thead>
<thead><tr><th>Host</th><th>Adresse</th><th>Hostgruppe</th><th>Benutzer</th><th>Status</th><th>Zuletzt gesetzt</th><th></th></tr></thead>
<tbody></tbody>
</table>
</div>

167
tests/test_phase11.py Normal file
View 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
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

View File

@ -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()

View File

@ -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}")