connect fix 4
This commit is contained in:
112
README.md
112
README.md
@ -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
|
||||
|
||||
@ -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()
|
||||
|
||||
55
app/db/migrations/0010_credential_usernames.sql
Normal file
55
app/db/migrations/0010_credential_usernames.sql
Normal 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;
|
||||
22
app/main.py
22
app/main.py
@ -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()
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -44,6 +44,23 @@ class HostKeyDiscoveryError(Exception):
|
||||
super().__init__(f"Host-Key-Ermittlung fuer Host {host_id} fehlgeschlagen: {reason}")
|
||||
|
||||
|
||||
class HostKeyNotPinnedError(Exception):
|
||||
"""Fuer den Host ist kein Host-Key hinterlegt.
|
||||
|
||||
Bewusst ein harter Abbruch statt Trust-on-First-Use: ohne gepinnten
|
||||
Schluessel laesst sich nicht feststellen, ob am anderen Ende das
|
||||
erwartete System antwortet (Konzept 4.2/6.4).
|
||||
"""
|
||||
|
||||
def __init__(self, host_id: int) -> None:
|
||||
self.host_id = host_id
|
||||
super().__init__(
|
||||
"Fuer diesen Host ist kein SSH-Host-Key hinterlegt. Im Adminbereich unter "
|
||||
"'Server' -> Host-Key ermitteln einmalig uebernehmen (bewusste "
|
||||
"Vertrauensentscheidung), danach ist die Verbindung moeglich."
|
||||
)
|
||||
|
||||
|
||||
class PrivateKeyUnusableError(Exception):
|
||||
"""Der hinterlegte private Schluessel laesst sich nicht laden.
|
||||
|
||||
@ -62,6 +79,35 @@ class PrivateKeyUnusableError(Exception):
|
||||
super().__init__(message)
|
||||
|
||||
|
||||
BCRYPT_MISSING_MESSAGE = (
|
||||
"Der private Schluessel ist passphrasegeschuetzt im OpenSSH-Format, auf dem "
|
||||
"Server fehlt aber das Python-Modul 'bcrypt' -- asyncssh kann die "
|
||||
"Schluesseldatei deshalb gar nicht erst entschluesseln. Weder der Schluessel "
|
||||
"noch die hinterlegte Passphrase sind schuld. Abhilfe: 'bcrypt' im "
|
||||
"Virtualenv der Anwendung nachinstallieren (pip install -r requirements.txt "
|
||||
"bzw. Ansible-Rolle python_runtime erneut ausrollen) und den Dienst "
|
||||
"jumphost-app neu starten."
|
||||
)
|
||||
|
||||
|
||||
def bcrypt_kdf_available() -> bool:
|
||||
"""Ist das (formal optionale) bcrypt-Modul mit KDF-Unterstuetzung da?
|
||||
|
||||
asyncssh und cryptography benoetigen es fuer die bcrypt_pbkdf-Ableitung,
|
||||
mit der OpenSSH verschluesselte Schluesseldateien schuetzt -- also fuer
|
||||
genau das Format, das 'ssh-keygen' standardmaessig erzeugt. Fehlt das
|
||||
Modul, scheitert JEDER passphrasegeschuetzte Schluessel mit
|
||||
"OpenSSH private key encryption requires bcrypt with KDF support", obwohl
|
||||
Schluessel und Passphrase korrekt sind. Es stand bis Phase 11 nicht in
|
||||
requirements.txt und fehlte deshalb im ausgerollten Virtualenv.
|
||||
"""
|
||||
try:
|
||||
import bcrypt # noqa: PLC0415 - bewusst lazy, das Modul ist optional
|
||||
except ImportError:
|
||||
return False
|
||||
return hasattr(bcrypt, "kdf")
|
||||
|
||||
|
||||
def import_private_key_material(
|
||||
pem: bytes | str, passphrase: bytes | str | None = None
|
||||
) -> asyncssh.SSHKey:
|
||||
@ -96,6 +142,18 @@ def import_private_key_material(
|
||||
# wird hier korrekterweise nicht mitgefangen.
|
||||
text = str(exc)
|
||||
|
||||
if "bcrypt" in text.lower():
|
||||
# Umgebungsfehler, kein Benutzerfehler: ohne bcrypt kann asyncssh
|
||||
# OpenSSH-verschluesselte Schluessel grundsaetzlich nicht lesen.
|
||||
# Ohne diese Uebersetzung landete die englische asyncssh-Meldung
|
||||
# ungefiltert im Terminalfenster und sah nach einem kaputten
|
||||
# Schluessel aus.
|
||||
logger.error(
|
||||
"Import eines privaten Schluessels fehlgeschlagen: bcrypt fehlt im "
|
||||
"Virtualenv (asyncssh-Meldung: %s)", text,
|
||||
)
|
||||
raise PrivateKeyUnusableError(BCRYPT_MISSING_MESSAGE) from exc
|
||||
|
||||
if "Passphrase must be specified" in text:
|
||||
raise PrivateKeyUnusableError(
|
||||
"Der private Schluessel ist passphrasegeschuetzt, es ist aber keine "
|
||||
@ -123,9 +181,41 @@ def import_private_key_material(
|
||||
) from exc
|
||||
|
||||
|
||||
#: Fehler, die den Aufbau einer SSH-Sitzung verhindern und dem angemeldeten
|
||||
#: Benutzer im Klartext gezeigt werden duerfen (keine Geheimnisse, nur
|
||||
#: Konfigurations-/Erreichbarkeitsaussagen). Wird von terminal_ws.py und
|
||||
#: sftp.py gemeinsam benutzt, damit beide Wege dieselbe Meldung zeigen.
|
||||
SSH_SETUP_ERRORS: tuple[type[Exception], ...] = (
|
||||
HostNotConfiguredError,
|
||||
HostKeyNotPinnedError,
|
||||
HostKeyMismatchError,
|
||||
HostKeyDiscoveryError,
|
||||
PrivateKeyUnusableError,
|
||||
)
|
||||
|
||||
|
||||
def describe_connection_error(exc: Exception) -> str:
|
||||
"""Deutsche Klartextmeldung zu einem Verbindungsfehler."""
|
||||
if isinstance(exc, HostKeyMismatchError):
|
||||
return (
|
||||
"Der SSH-Host-Key des Ziels weicht vom hinterlegten ab -- die Verbindung wurde "
|
||||
"abgebrochen, bevor Zugangsdaten gesendet wurden. "
|
||||
f"Erwartet: {exc.expected}; angeboten: {exc.observed}. Wurde das Zielsystem neu "
|
||||
"aufgesetzt, den Host-Key im Adminbereich erneut ermitteln."
|
||||
)
|
||||
if isinstance(exc, HostKeyDiscoveryError):
|
||||
return f"Das Ziel ist nicht erreichbar: {exc.reason}"
|
||||
return str(exc)
|
||||
|
||||
|
||||
class _PinnedHostKeyClient(asyncssh.SSHClient):
|
||||
"""Erzwingt Strict Host Key Checking gegen einen fest hinterlegten
|
||||
SHA-256-Fingerprint. Kein automatisches Trust-on-First-Use (TOFU)."""
|
||||
"""Zweite Verteidigungslinie fuer Strict Host Key Checking.
|
||||
|
||||
ACHTUNG: asyncssh ruft validate_host_public_key() nur auf, wenn
|
||||
known_hosts NICHT None ist -- diese Klasse allein hat das Pinning also
|
||||
nicht durchgesetzt (siehe _verified_host_key(), das die eigentliche
|
||||
Pruefung vor der Anmeldung uebernimmt). Sie bleibt erhalten, weil sie in
|
||||
Konfigurationen mit gesetztem known_hosts greift und nie schadet."""
|
||||
|
||||
def __init__(self, expected_fingerprint: str | None, *, discovery_mode: bool = False) -> None:
|
||||
self.expected_fingerprint = expected_fingerprint
|
||||
@ -147,7 +237,8 @@ async def load_host(conn: aiosqlite.Connection, host_id: int) -> dict:
|
||||
cursor = await conn.execute(
|
||||
"SELECT id, hostname, address, port, os_type, protocol, ssh_host_key_fingerprint, "
|
||||
"ssh_username, file_transfer_enabled, host_group_id, "
|
||||
"rdp_username, rdp_domain, rdp_require_nla, clipboard_enabled, rdp_ignore_cert "
|
||||
"rdp_username, rdp_domain, rdp_require_nla, clipboard_enabled, rdp_ignore_cert, "
|
||||
"ssh_host_key "
|
||||
"FROM hosts WHERE id = ? AND is_active = 1",
|
||||
(host_id,),
|
||||
)
|
||||
@ -166,6 +257,11 @@ async def load_host(conn: aiosqlite.Connection, host_id: int) -> dict:
|
||||
# dauerhaft gesperrt, unabhaengig von der Hostkonfiguration).
|
||||
"rdp_username", "rdp_domain", "rdp_require_nla", "clipboard_enabled",
|
||||
"rdp_ignore_cert",
|
||||
# Vollstaendiger Host-Key (Migration 0010): erlaubt es, den vom Ziel
|
||||
# angebotenen Schluessel VOR der Anmeldung byteweise zu vergleichen --
|
||||
# mit einem Fingerprint allein geht das nicht, den sieht man erst,
|
||||
# wenn der Server seinen Schluessel bereits praesentiert hat.
|
||||
"ssh_host_key",
|
||||
)
|
||||
host = dict(zip(keys, row))
|
||||
for flag in ("file_transfer_enabled", "rdp_require_nla", "clipboard_enabled", "rdp_ignore_cert"):
|
||||
@ -173,17 +269,20 @@ async def load_host(conn: aiosqlite.Connection, host_id: int) -> dict:
|
||||
return host
|
||||
|
||||
|
||||
async def load_private_key_for_host(conn: aiosqlite.Connection, host_id: int) -> asyncssh.SSHKey:
|
||||
"""Laedt den dem Host zugeordneten Schluessel und entschluesselt ihn.
|
||||
async def load_ssh_credential_for_host(
|
||||
conn: aiosqlite.Connection, host_id: int
|
||||
) -> tuple[asyncssh.SSHKey, str | None]:
|
||||
"""Laedt den dem Host zugeordneten Schluessel, entschluesselt ihn und gibt
|
||||
ihn zusammen mit dem am Schluessel hinterlegten Benutzernamen zurueck.
|
||||
|
||||
Ist zu dem Schluessel eine Passphrase hinterlegt (ssh_keys.passphrase_enc,
|
||||
Migration 0009), wird sie ebenfalls entschluesselt und an asyncssh
|
||||
uebergeben. Ohne diesen Schritt scheiterte der Import jedes
|
||||
passphrasegeschuetzten Schluessels grundsaetzlich -- siehe
|
||||
PrivateKeyUnusableError.
|
||||
Der Benutzername gehoert seit Migration 0010 zu den Zugangsdaten
|
||||
(ssh_keys.username) und nicht mehr zum Host: er ist Teil der Anmeldung,
|
||||
nicht der Beschreibung des Zielsystems. Ist zu dem Schluessel eine
|
||||
Passphrase hinterlegt (ssh_keys.passphrase_enc, Migration 0009), wird sie
|
||||
ebenfalls entschluesselt und an asyncssh uebergeben.
|
||||
"""
|
||||
cursor = await conn.execute(
|
||||
"SELECT sk.private_key_enc, sk.passphrase_enc FROM ssh_keys sk "
|
||||
"SELECT sk.private_key_enc, sk.passphrase_enc, sk.username FROM ssh_keys sk "
|
||||
"JOIN host_ssh_key_map m ON m.ssh_key_id = sk.id "
|
||||
"WHERE m.host_id = ? LIMIT 1",
|
||||
(host_id,),
|
||||
@ -195,8 +294,9 @@ async def load_private_key_for_host(conn: aiosqlite.Connection, host_id: int) ->
|
||||
passphrase = (
|
||||
decrypt_secret(row[1], associated_data=b"ssh_key_passphrase") if row[1] else None
|
||||
)
|
||||
username = (row[2] or "").strip() or None
|
||||
try:
|
||||
return import_private_key_material(pem, passphrase)
|
||||
return import_private_key_material(pem, passphrase), username
|
||||
finally:
|
||||
# Bestpraxis: Referenzen auf das Klartextmaterial so schnell wie
|
||||
# moeglich loslassen (Konzept 6.4).
|
||||
@ -204,78 +304,173 @@ async def load_private_key_for_host(conn: aiosqlite.Connection, host_id: int) ->
|
||||
del passphrase
|
||||
|
||||
|
||||
async def load_private_key_for_host(conn: aiosqlite.Connection, host_id: int) -> asyncssh.SSHKey:
|
||||
"""Rueckwaertskompatible Fassung ohne Benutzername (siehe
|
||||
load_ssh_credential_for_host)."""
|
||||
key, _username = await load_ssh_credential_for_host(conn, host_id)
|
||||
return key
|
||||
|
||||
|
||||
def resolve_ssh_username(host: dict, credential_username: str | None) -> str:
|
||||
"""Ermittelt den Anmeldenamen fuer eine SSH-Sitzung.
|
||||
|
||||
Vorrang hat der Benutzername der Zugangsdaten (ssh_keys.username). Der
|
||||
alte Wert am Host (hosts.ssh_username) wird nur noch als Fallback fuer
|
||||
Datensaetze gelesen, die vor Migration 0010 angelegt wurden -- neue
|
||||
Schreibpfade und die Oberflaeche pflegen ihn nicht mehr.
|
||||
"""
|
||||
username = (credential_username or "").strip() or (host.get("ssh_username") or "").strip()
|
||||
if not username:
|
||||
raise HostNotConfiguredError(
|
||||
"Fuer diesen Host ist kein SSH-Benutzername hinterlegt. Der Benutzername "
|
||||
"gehoert zum SSH-Key (Adminbereich -> Zugangsdaten -> SSH-Key bearbeiten)."
|
||||
)
|
||||
return username
|
||||
|
||||
|
||||
async def _verified_host_key(
|
||||
conn: aiosqlite.Connection, host: dict
|
||||
) -> asyncssh.SSHKey:
|
||||
"""Holt den Host-Key des Ziels OHNE Anmeldung und prueft ihn gegen den
|
||||
hinterlegten Schluessel bzw. Fingerprint.
|
||||
|
||||
Warum ueberhaupt ein eigener Schritt: asyncssh ruft
|
||||
SSHClient.validate_host_public_key() NUR auf, wenn known_hosts nicht None
|
||||
ist -- mit known_hosts=None ist die Host-Key-Pruefung komplett abgeschaltet
|
||||
und der Callback wird nie erreicht. Genau diese Kombination war hier im
|
||||
Einsatz, das Pinning also faktisch wirkungslos (aufgefallen, weil aus dem
|
||||
gleichen Grund auch die Host-Key-Ermittlung keinen Fingerprint mehr sah).
|
||||
asyncssh.get_server_host_key() fuehrt nur den Key-Exchange durch und
|
||||
liefert den Schluessel zurueck -- damit laesst sich VOR jeder Anmeldung
|
||||
entscheiden, ob das Gegenueber das erwartete System ist.
|
||||
"""
|
||||
expected_fingerprint = (host.get("ssh_host_key_fingerprint") or "").strip()
|
||||
if not expected_fingerprint:
|
||||
raise HostKeyNotPinnedError(host["id"])
|
||||
|
||||
try:
|
||||
observed_key = await asyncssh.get_server_host_key(
|
||||
host["address"], port=host["port"], connect_timeout=10
|
||||
)
|
||||
except (asyncssh.Error, OSError) as exc:
|
||||
raise HostKeyDiscoveryError(host["id"], str(exc)) from exc
|
||||
if observed_key is None:
|
||||
raise HostKeyDiscoveryError(host["id"], "Das Ziel hat keinen Host-Key gesendet")
|
||||
|
||||
observed_fingerprint = observed_key.get_fingerprint("sha256")
|
||||
if observed_fingerprint != expected_fingerprint:
|
||||
raise HostKeyMismatchError(expected_fingerprint, observed_fingerprint)
|
||||
|
||||
stored = (host.get("ssh_host_key") or "").strip()
|
||||
observed_pub = observed_key.export_public_key("openssh").decode().strip()
|
||||
if stored:
|
||||
# Schaerfer als der Fingerprintvergleich: byteweise Gleichheit des
|
||||
# gesamten Schluessels.
|
||||
if _public_key_body(stored) != _public_key_body(observed_pub):
|
||||
raise HostKeyMismatchError(expected_fingerprint, observed_fingerprint)
|
||||
else:
|
||||
# Altbestand (vor Migration 0010 ermittelt): Schluessel nachtragen,
|
||||
# nachdem er gegen den gepinnten Fingerprint geprueft wurde.
|
||||
await conn.execute(
|
||||
"UPDATE hosts SET ssh_host_key = ? WHERE id = ?", (observed_pub, host["id"])
|
||||
)
|
||||
await conn.commit()
|
||||
return observed_key
|
||||
|
||||
|
||||
def _public_key_body(openssh_line: str) -> tuple[str, str]:
|
||||
"""(Algorithmus, Base64-Material) einer OpenSSH-Zeile -- ohne Kommentar,
|
||||
der sich zwischen Exporten unterscheiden darf."""
|
||||
parts = openssh_line.split()
|
||||
if len(parts) < 2:
|
||||
return ("", openssh_line.strip())
|
||||
return (parts[0], parts[1])
|
||||
|
||||
|
||||
async def connect_to_host(conn: aiosqlite.Connection, host_id: int) -> asyncssh.SSHClientConnection:
|
||||
host = await load_host(conn, host_id)
|
||||
if host["protocol"] != "ssh":
|
||||
raise HostNotConfiguredError("Host ist kein SSH-Ziel")
|
||||
|
||||
private_key = await load_private_key_for_host(conn, host_id)
|
||||
# Reihenfolge mit Absicht: erst das Ziel identifizieren, dann erst
|
||||
# Schluesselmaterial entschluesseln und eine Anmeldung versuchen.
|
||||
trusted_key = await _verified_host_key(conn, host)
|
||||
private_key, credential_username = await load_ssh_credential_for_host(conn, host_id)
|
||||
username = resolve_ssh_username(host, credential_username)
|
||||
client_factory = lambda: _PinnedHostKeyClient(host["ssh_host_key_fingerprint"])
|
||||
|
||||
try:
|
||||
connection = await asyncssh.connect(
|
||||
host["address"],
|
||||
port=host["port"],
|
||||
username=host["ssh_username"],
|
||||
username=username,
|
||||
client_keys=[private_key],
|
||||
known_hosts=None, # Validierung erfolgt ausschliesslich ueber validate_host_public_key
|
||||
known_hosts=None,
|
||||
client_factory=client_factory,
|
||||
connect_timeout=10,
|
||||
)
|
||||
except asyncssh.Error as exc:
|
||||
logger.warning("SSH-Verbindungsfehler zu Host %s: %s", host_id, exc)
|
||||
raise
|
||||
|
||||
# Zweite Haelfte des Pinnings: der Schluessel dieser Sitzung muss derselbe
|
||||
# sein wie der eben gepruefte. Faengt den (sehr schmalen) Fall ab, dass
|
||||
# zwischen Pruefung und Sitzungsaufbau ein anderes System antwortet.
|
||||
session_key = connection.get_server_host_key()
|
||||
session_fingerprint = session_key.get_fingerprint("sha256") if session_key else None
|
||||
if session_fingerprint != trusted_key.get_fingerprint("sha256"):
|
||||
connection.abort()
|
||||
logger.error(
|
||||
"Host-Key-Wechsel zwischen Pruefung und Sitzungsaufbau bei Host %s", host_id
|
||||
)
|
||||
raise HostKeyMismatchError(host["ssh_host_key_fingerprint"], session_fingerprint)
|
||||
return connection
|
||||
|
||||
|
||||
async def discover_and_store_host_key(
|
||||
conn: aiosqlite.Connection, host_id: int, *, admin_user_id: int
|
||||
) -> str:
|
||||
"""Verbindet EINMALIG ohne Pinning, um den Host-Key-Fingerprint zu erfassen
|
||||
und in der DB zu hinterlegen. Nur ueber einen dedizierten, admin-only
|
||||
Endpunkt aufrufbar -- jeder Aufruf ist eine bewusste Vertrauensentscheidung
|
||||
und wird im Audit-Log als solche vermerkt (siehe admin/routes.py).
|
||||
"""Holt den Host-Key des Ziels EINMALIG und hinterlegt ihn (Fingerprint und
|
||||
vollstaendigen Schluessel) in der DB. Nur ueber einen dedizierten,
|
||||
admin-only Endpunkt aufrufbar -- jeder Aufruf ist eine bewusste
|
||||
Vertrauensentscheidung und wird im Audit-Log als solche vermerkt (siehe
|
||||
admin/routes.py).
|
||||
|
||||
Bugfix (Fehler 500 bei 'Host-Key ermitteln'): asyncssh.connect() fuehrt
|
||||
nach dem Key-Exchange (bei dem validate_host_public_key() den Fingerprint
|
||||
bereits erfasst) automatisch die Authentifizierung durch -- ohne
|
||||
hinterlegten Client-Key/Passwort schlaegt die IMMER fehl (asyncssh.Error,
|
||||
z.B. PermissionDenied), obwohl der Fingerprint laengst bekannt ist. Diese
|
||||
fuer die reine Discovery irrelevante Auth-Fehlermeldung wurde bisher NICHT
|
||||
abgefangen und riss als unbehandelte Exception bis zu FastAPI durch ->
|
||||
500 Internal Server Error bei praktisch jedem Aufruf. Fix: Verbindungs-
|
||||
fehler abfangen; wenn dabei bereits ein Fingerprint beobachtet wurde, gilt
|
||||
die Discovery als erfolgreich. Nur wenn wirklich KEIN Fingerprint erfasst
|
||||
wurde (Host nicht erreichbar, DNS-Fehler, Timeout -- also ein Fehler VOR
|
||||
dem Key-Exchange), ist es ein echter Fehler (HostKeyDiscoveryError)."""
|
||||
Bugfix (Meldung "Ziel nicht erreichbar: Permission denied for user ..."):
|
||||
Die Ermittlung lief bisher ueber einen vollstaendigen
|
||||
asyncssh.connect()-Versuch und las den Fingerprint aus dem Callback
|
||||
SSHClient.validate_host_public_key(). Diesen Callback ruft asyncssh aber
|
||||
NUR auf, wenn known_hosts nicht None ist -- mit known_hosts=None ist die
|
||||
Host-Key-Pruefung abgeschaltet und der Callback wird nie erreicht. Der
|
||||
Fingerprint blieb deshalb leer, und der voellig erwartbare Auth-Fehler der
|
||||
anschliessenden Anmeldung ("Permission denied") wurde als "Ziel nicht
|
||||
erreichbar" gemeldet, obwohl der Key-Exchange laengst erfolgreich war.
|
||||
Jetzt wird asyncssh.get_server_host_key() verwendet: das fuehrt nur den
|
||||
Key-Exchange durch, versucht gar keine Anmeldung und liefert den
|
||||
Schluessel direkt zurueck. Ein Auth-Fehler kann die Ermittlung damit
|
||||
nicht mehr verhindern."""
|
||||
host = await load_host(conn, host_id)
|
||||
if host["protocol"] != "ssh":
|
||||
raise HostNotConfiguredError("Host-Key-Ermittlung ist nur fuer SSH-Ziele moeglich")
|
||||
|
||||
client = _PinnedHostKeyClient(None, discovery_mode=True)
|
||||
connection = None
|
||||
try:
|
||||
connection = await asyncssh.connect(
|
||||
host["address"], port=host["port"], username=host["ssh_username"],
|
||||
known_hosts=None, client_factory=lambda: client, connect_timeout=10,
|
||||
key = await asyncssh.get_server_host_key(
|
||||
host["address"], port=host["port"], connect_timeout=10
|
||||
)
|
||||
except (asyncssh.Error, OSError) as exc:
|
||||
if client.observed_fingerprint is None:
|
||||
logger.warning("Host-Key-Ermittlung fuer Host %s fehlgeschlagen: %s", host_id, exc)
|
||||
raise HostKeyDiscoveryError(host_id, str(exc)) from exc
|
||||
# Fingerprint wurde bereits waehrend des Key-Exchange erfasst -- der
|
||||
# anschliessende Auth-Fehler ist fuer die Discovery unschaedlich.
|
||||
logger.info(
|
||||
"Host-Key fuer Host %s erfasst (Auth-Phase erwartungsgemaess fehlgeschlagen: %s)",
|
||||
host_id, exc,
|
||||
)
|
||||
finally:
|
||||
if connection is not None:
|
||||
connection.close()
|
||||
logger.warning("Host-Key-Ermittlung fuer Host %s fehlgeschlagen: %s", host_id, exc)
|
||||
raise HostKeyDiscoveryError(host_id, str(exc)) from exc
|
||||
if key is None:
|
||||
raise HostKeyDiscoveryError(host_id, "Das Ziel hat keinen Host-Key gesendet")
|
||||
|
||||
fingerprint = client.observed_fingerprint
|
||||
fingerprint = key.get_fingerprint("sha256")
|
||||
public_key = key.export_public_key("openssh").decode().strip()
|
||||
await conn.execute(
|
||||
"UPDATE hosts SET ssh_host_key_fingerprint = ? WHERE id = ?", (fingerprint, host_id)
|
||||
"UPDATE hosts SET ssh_host_key_fingerprint = ?, ssh_host_key = ? WHERE id = ?",
|
||||
(fingerprint, public_key, host_id),
|
||||
)
|
||||
await conn.commit()
|
||||
logger.info(
|
||||
"Host-Key fuer Host %s durch Admin %s uebernommen: %s", host_id, admin_user_id, fingerprint
|
||||
)
|
||||
return fingerprint
|
||||
|
||||
@ -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]
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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", {}, [
|
||||
|
||||
@ -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
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