more stuff

This commit is contained in:
2026-08-21 08:45:36 +02:00
parent 5e506c9921
commit ad728ba96c
12 changed files with 1148 additions and 64 deletions

View File

@ -48,7 +48,9 @@ from app.models.schemas import (
RoleGrantRequest, RoleGrantRequest,
RoleRevokeRequest, RoleRevokeRequest,
SshKeyCreateRequest, SshKeyCreateRequest,
SshKeyGenerateRequest,
SshKeyUpdateRequest, SshKeyUpdateRequest,
SshPasswordCredentialsRequest,
TenantAdminAssignRequest, TenantAdminAssignRequest,
TenantCreateRequest, TenantCreateRequest,
TenantUpdateRequest, TenantUpdateRequest,
@ -74,6 +76,7 @@ from app.ssh_proxy.proxy import (
HostNotConfiguredError, HostNotConfiguredError,
PrivateKeyUnusableError, PrivateKeyUnusableError,
discover_and_store_host_key, discover_and_store_host_key,
generate_key_material,
import_private_key_material, import_private_key_material,
) )
from app.tenancy import TenantScope, resolve_host_group_tenant, resolve_host_tenant, tenant_user_ids from app.tenancy import TenantScope, resolve_host_group_tenant, resolve_host_tenant, tenant_user_ids
@ -819,6 +822,9 @@ async def get_host_detail(
rdp_row = await (await conn.execute( rdp_row = await (await conn.execute(
"SELECT updated_at, username, domain FROM rdp_credentials WHERE host_id = ?", (host_id,) "SELECT updated_at, username, domain FROM rdp_credentials WHERE host_id = ?", (host_id,)
)).fetchone() )).fetchone()
ssh_pw_row = await (await conn.execute(
"SELECT updated_at, username FROM ssh_password_credentials WHERE host_id = ?", (host_id,)
)).fetchone()
return { return {
"id": row[0], "hostname": row[1], "address": row[2], "protocol": row[3], "port": row[4], "id": row[0], "hostname": row[1], "address": row[2], "protocol": row[3], "port": row[4],
"os_type": row[5], "host_group_id": row[6], "ssh_host_key_fingerprint": row[7], "os_type": row[5], "host_group_id": row[6], "ssh_host_key_fingerprint": row[7],
@ -833,6 +839,10 @@ async def get_host_detail(
# Zugangsdaten; sie werden hier nur zur Anzeige mitgeliefert. # Zugangsdaten; sie werden hier nur zur Anzeige mitgeliefert.
"rdp_credentials_username": rdp_row[1] 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, "rdp_credentials_domain": rdp_row[2] if rdp_row else None,
# SSH-Passwort als Alternative zum Schluessel (Migration 0011).
"ssh_password_credentials_set": ssh_pw_row is not None,
"ssh_password_credentials_updated_at": ssh_pw_row[0] if ssh_pw_row else None,
"ssh_password_credentials_username": ssh_pw_row[1] if ssh_pw_row else None,
} }
@ -1005,6 +1015,59 @@ async def delete_rdp_credentials(
return {"status": "ok"} return {"status": "ok"}
@router.put("/hosts/{host_id}/ssh-password")
async def set_ssh_password_credentials(
host_id: int, payload: SshPasswordCredentialsRequest, request: Request,
admin: CurrentUser = Depends(
require_admin_scope_or_host_role("hosts", "write", ("credentials_manage",))
),
):
"""Setzt/aendert das SSH-Passwort fuer einen Host (Migration 0011,
Alternative zum SSH-Key -- 'Linux kann statt SSH-Key auch Passwort
haben'). Wird beim Verbindungsaufbau nur beruecksichtigt, solange dem
Host KEIN SSH-Key zugeordnet ist (siehe
app/ssh_proxy/proxy.py::connect_to_host)."""
conn = get_db()
if admin.is_any_admin:
await _assert_host_in_scope(conn, _scope(admin), host_id)
username = payload.username.strip()
if not username:
raise HTTPException(status.HTTP_400_BAD_REQUEST, "Benutzername darf nicht leer sein")
encrypted = encrypt_secret(payload.password.encode(), associated_data=b"ssh_password")
await conn.execute(
"INSERT INTO ssh_password_credentials (host_id, username, password_enc, updated_at) "
"VALUES (?, ?, ?, strftime('%Y-%m-%dT%H:%M:%fZ','now')) "
"ON CONFLICT(host_id) DO UPDATE SET username = excluded.username, "
"password_enc = excluded.password_enc, updated_at = excluded.updated_at",
(host_id, username, encrypted),
)
await write_audit_event(
conn, event_type="ssh_password_credentials_set", user_id=admin.id, client_ip=_client_ip(request),
details={"host_id": host_id, "username": username},
)
await conn.commit()
return {"status": "ok"}
@router.delete("/hosts/{host_id}/ssh-password")
async def delete_ssh_password_credentials(
host_id: int, request: Request,
admin: CurrentUser = Depends(
require_admin_scope_or_host_role("hosts", "write", ("credentials_manage",))
),
):
conn = get_db()
if admin.is_any_admin:
await _assert_host_in_scope(conn, _scope(admin), host_id)
await conn.execute("DELETE FROM ssh_password_credentials WHERE host_id = ?", (host_id,))
await write_audit_event(
conn, event_type="ssh_password_credentials_deleted", user_id=admin.id, client_ip=_client_ip(request),
details={"host_id": host_id},
)
await conn.commit()
return {"status": "ok"}
@router.get("/hosts/{host_id}/credentials") @router.get("/hosts/{host_id}/credentials")
async def get_host_credentials( async def get_host_credentials(
host_id: int, host_id: int,
@ -1034,12 +1097,18 @@ async def get_host_credentials(
rdp_row = await (await conn.execute( rdp_row = await (await conn.execute(
"SELECT updated_at, username, domain FROM rdp_credentials WHERE host_id = ?", (host_id,) "SELECT updated_at, username, domain FROM rdp_credentials WHERE host_id = ?", (host_id,)
)).fetchone() )).fetchone()
ssh_pw_row = await (await conn.execute(
"SELECT updated_at, username FROM ssh_password_credentials WHERE host_id = ?", (host_id,)
)).fetchone()
return { return {
"host_id": host_id, "ssh_keys": ssh_keys, "host_id": host_id, "ssh_keys": ssh_keys,
"rdp_credentials_set": rdp_row is not None, "rdp_credentials_set": rdp_row is not None,
"rdp_credentials_updated_at": rdp_row[0] if rdp_row else 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_username": rdp_row[1] if rdp_row else None,
"rdp_credentials_domain": rdp_row[2] if rdp_row else None, "rdp_credentials_domain": rdp_row[2] if rdp_row else None,
"ssh_password_credentials_set": ssh_pw_row is not None,
"ssh_password_credentials_updated_at": ssh_pw_row[0] if ssh_pw_row else None,
"ssh_password_credentials_username": ssh_pw_row[1] if ssh_pw_row else None,
} }
@ -1291,9 +1360,9 @@ async def create_ssh_key(
else None else None
) )
cursor = await conn.execute( cursor = await conn.execute(
"INSERT INTO ssh_keys (label, owner_user_id, private_key_enc, public_key, key_type, " "INSERT INTO ssh_keys (label, private_key_enc, public_key, key_type, "
"tenant_id, passphrase_enc, username) VALUES (?, ?, ?, ?, ?, ?, ?, ?)", "tenant_id, passphrase_enc, username) VALUES (?, ?, ?, ?, ?, ?, ?)",
(payload.label, payload.owner_user_id, encrypted, payload.public_key, payload.key_type, (payload.label, encrypted, payload.public_key, payload.key_type,
tenant_id, passphrase_enc, (payload.username or "").strip() or None), tenant_id, passphrase_enc, (payload.username or "").strip() or None),
) )
new_id = cursor.lastrowid new_id = cursor.lastrowid
@ -1311,13 +1380,30 @@ async def create_ssh_key(
return {"id": new_id, "label": payload.label} return {"id": new_id, "label": payload.label}
@router.post("/ssh-keys/generate")
async def generate_ssh_key(
payload: SshKeyGenerateRequest,
admin: CurrentUser = Depends(require_admin_or_scope("ssh_keys", "write")),
):
"""Erzeugt ein neues Schluesselpaar serverseitig ('Schluessel automatisch
generieren'-Button) und gibt es EINMALIG zurueck -- es wird hier nichts
gespeichert, das passiert erst ueber den regulaeren POST /ssh-keys, wenn
der Admin das befuellte Formular tatsaechlich absendet (siehe
app/ssh_proxy/proxy.py::generate_key_material)."""
try:
private_key_pem, public_key = generate_key_material(payload.key_type)
except ValueError as exc:
raise HTTPException(status.HTTP_400_BAD_REQUEST, str(exc)) from exc
return {"key_type": payload.key_type, "private_key_pem": private_key_pem, "public_key": public_key}
@router.get("/ssh-keys") @router.get("/ssh-keys")
async def list_ssh_keys(admin: CurrentUser = Depends(require_admin_or_scope("ssh_keys", "read"))): async def list_ssh_keys(admin: CurrentUser = Depends(require_admin_or_scope("ssh_keys", "read"))):
conn = get_db() conn = get_db()
scope = _scope(admin) scope = _scope(admin)
tenant_filter, params = scope.sql_filter("sk.tenant_id") tenant_filter, params = scope.sql_filter("sk.tenant_id")
cursor = await conn.execute( cursor = await conn.execute(
"SELECT sk.id, sk.label, sk.owner_user_id, sk.key_type, sk.created_at, sk.rotated_at, " "SELECT sk.id, sk.label, sk.key_type, sk.created_at, sk.rotated_at, "
"sk.expires_at, sk.tenant_id, t.name, (sk.passphrase_enc IS NOT NULL), sk.username " "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 " "FROM ssh_keys sk JOIN tenants t ON t.id = sk.tenant_id "
f"WHERE 1=1{tenant_filter} ORDER BY sk.id", f"WHERE 1=1{tenant_filter} ORDER BY sk.id",
@ -1326,15 +1412,15 @@ async def list_ssh_keys(admin: CurrentUser = Depends(require_admin_or_scope("ssh
rows = await cursor.fetchall() rows = await cursor.fetchall()
return [ return [
{ {
"id": r[0], "label": r[1], "owner_user_id": r[2], "key_type": r[3], "id": r[0], "label": r[1], "key_type": r[2],
"created_at": r[4], "rotated_at": r[5], "expires_at": r[6], "created_at": r[3], "rotated_at": r[4], "expires_at": r[5],
"tenant_id": r[7], "tenant_name": r[8], "tenant_id": r[6], "tenant_name": r[7],
# Nur die Tatsache, NIE die Passphrase selbst -- kein Endpunkt # Nur die Tatsache, NIE die Passphrase selbst -- kein Endpunkt
# dieser Anwendung gibt jemals Klartext-Geheimnisse zurueck. # dieser Anwendung gibt jemals Klartext-Geheimnisse zurueck.
"has_passphrase": bool(r[9]), "has_passphrase": bool(r[8]),
# Anmeldename des Zielsystems (Migration 0010). Kein Geheimnis -- # Anmeldename des Zielsystems (Migration 0010). Kein Geheimnis --
# er wird angezeigt, damit erkennbar ist, welcher Zugang das ist. # er wird angezeigt, damit erkennbar ist, welcher Zugang das ist.
"username": r[10], "username": r[9],
} }
for r in rows for r in rows
] ]
@ -1393,8 +1479,6 @@ async def update_ssh_key(
fields, values = [], [] fields, values = [], []
if payload.label is not None: if payload.label is not None:
fields.append("label = ?"); values.append(payload.label) 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: if "username" in payload.model_fields_set:
# Gleiche Semantik wie bei passphrase: Weglassen = unveraendert, # Gleiche Semantik wie bei passphrase: Weglassen = unveraendert,
# explizites null/"" = entfernen. # explizites null/"" = entfernen.
@ -1749,6 +1833,58 @@ async def get_session_recording(session_id: int, admin: CurrentUser = Depends(re
return {"session_id": session_id, "verified": verified, "entry_count": entry_count} return {"session_id": session_id, "verified": verified, "entry_count": entry_count}
@router.get("/sessions/{session_id}/recording/entries")
async def get_session_recording_entries(
session_id: int, request: Request, admin: CurrentUser = Depends(require_global_admin)
):
"""Liefert den VOLLEN Inhalt einer Sitzungsaufzeichnung fuer die
Wiedergabe im Adminbereich (SSH: Terminal-Replay ueber xterm.js; RDP:
grafische Wiedergabe ueber Guacamole.SessionRecording -- siehe
static/js/admin.js). Im Unterschied zu GET .../recording (nur Metadaten
+ Integritaetsstatus) verlaesst hier der tatsaechliche Sitzungsinhalt
(Tastatureingaben bzw. Bildschirminhalt) den Server -- deshalb bewusst
NUR require_global_admin (echter Super-Admin, ausschliesslich per
Session, wie die uebrige Sessionview) UND ein eigener, prominenter
Audit-Log-Eintrag bei jedem Aufruf (Konzept 6.5: Aufzeichnungen sind
hochsensibel -- dieselbe bewusste 'wird jede Nutzung vermerkt'-Haltung
wie bei 'Host-Key ermitteln')."""
conn = get_db()
row = await (await conn.execute(
"SELECT recording_path, protocol FROM sessions WHERE id = ?", (session_id,)
)).fetchone()
if row is None or not row[0]:
raise HTTPException(status.HTTP_404_NOT_FOUND, "Keine Aufzeichnung fuer diese Sitzung vorhanden")
path = Path(row[0])
if not path.exists():
raise HTTPException(status.HTTP_404_NOT_FOUND, "Aufzeichnungsdatei nicht (mehr) vorhanden")
try:
verified = verify_recording(path)
except Exception:
logger.exception("Aufzeichnung %s konnte nicht gelesen/verifiziert werden", session_id)
raise HTTPException(status.HTTP_500_INTERNAL_SERVER_ERROR, "Aufzeichnung konnte nicht gelesen werden")
entries = []
try:
with open(path, encoding="utf-8") as fh:
for line in fh:
line = line.strip()
if not line:
continue
parsed = json.loads(line)
entry = parsed["entry"]
entries.append({"t": entry["t"], "dir": entry["dir"], "data": entry["data"]})
except (json.JSONDecodeError, KeyError) as exc:
logger.exception("Aufzeichnung %s konnte nicht geparst werden", session_id)
raise HTTPException(status.HTTP_500_INTERNAL_SERVER_ERROR, "Aufzeichnung konnte nicht gelesen werden") from exc
await write_audit_event(
conn, event_type="session_recording_viewed", user_id=admin.id, client_ip=_client_ip(request),
details={"session_id": session_id, "protocol": row[1], "entry_count": len(entries)},
)
await conn.commit()
return {"session_id": session_id, "protocol": row[1], "verified": verified, "entries": entries}
# Hinweis: Das Live-'Verbindungslog' (WS /ws/logs) liegt bewusst NICHT unter # Hinweis: Das Live-'Verbindungslog' (WS /ws/logs) liegt bewusst NICHT unter
# diesem /admin-Router, sondern als eigener Top-Level-Router in # diesem /admin-Router, sondern als eigener Top-Level-Router in
# app/admin/log_ws.py -- siehe dort fuer den Grund (nginx-Reverse-Proxy- # app/admin/log_ws.py -- siehe dort fuer den Grund (nginx-Reverse-Proxy-

View File

@ -0,0 +1,13 @@
-- Migration 0011: SSH-Passwort als Alternative zum SSH-Key (Linux-Hosts).
--
-- Analog zu rdp_credentials (ein Datensatz pro Host, Benutzername gehoert zu
-- den Zugangsdaten). Nur wirksam, wenn dem Host KEIN SSH-Key zugeordnet ist
-- -- ein zugeordneter Schluessel (host_ssh_key_map) hat beim Verbindungsaufbau
-- immer Vorrang (siehe app/ssh_proxy/proxy.py::connect_to_host).
CREATE TABLE IF NOT EXISTS ssh_password_credentials (
host_id INTEGER PRIMARY KEY REFERENCES hosts(id),
username TEXT NOT NULL,
password_enc BLOB NOT NULL,
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))
);

View File

@ -216,7 +216,6 @@ class ApiTokenCreateRequest(BaseModel):
class SshKeyCreateRequest(BaseModel): class SshKeyCreateRequest(BaseModel):
label: str = Field(min_length=1, max_length=128) label: str = Field(min_length=1, max_length=128)
owner_user_id: int | None = None
private_key_pem: str = Field(min_length=1, max_length=32_768) private_key_pem: str = Field(min_length=1, max_length=32_768)
public_key: str = Field(min_length=1, max_length=8192) public_key: str = Field(min_length=1, max_length=8192)
key_type: Literal["ed25519", "rsa-3072", "rsa-4096", "ca-cert"] key_type: Literal["ed25519", "rsa-3072", "rsa-4096", "ca-cert"]
@ -234,7 +233,6 @@ class SshKeyCreateRequest(BaseModel):
class SshKeyUpdateRequest(BaseModel): class SshKeyUpdateRequest(BaseModel):
label: str | None = Field(default=None, min_length=1, max_length=128) label: str | None = Field(default=None, min_length=1, max_length=128)
owner_user_id: int | None = None
# Wenn gesetzt, wird der Schluessel rotiert (neues Schluesselmaterial, # Wenn gesetzt, wird der Schluessel rotiert (neues Schluesselmaterial,
# rotated_at wird aktualisiert). Alle drei Felder muessen dann zusammen # rotated_at wird aktualisiert). Alle drei Felder muessen dann zusammen
# angegeben werden (siehe rotate_ssh_key() in admin/routes.py). # angegeben werden (siehe rotate_ssh_key() in admin/routes.py).
@ -263,3 +261,19 @@ class RdpCredentialsRequest(BaseModel):
# Migration 0010 Teil der Zugangsdaten statt des Hosts. # Migration 0010 Teil der Zugangsdaten statt des Hosts.
username: str | None = Field(default=None, max_length=128) username: str | None = Field(default=None, max_length=128)
domain: str | None = Field(default=None, max_length=128) domain: str | None = Field(default=None, max_length=128)
class SshPasswordCredentialsRequest(BaseModel):
"""Alternative zum SSH-Key (Migration 0011): Passwort-Login fuer Linux-
Hosts, analog zu RdpCredentialsRequest. Nur wirksam, solange dem Host
KEIN SSH-Key zugeordnet ist -- ein zugeordneter Schluessel hat immer
Vorrang (siehe app/ssh_proxy/proxy.py::connect_to_host)."""
username: str = Field(min_length=1, max_length=128)
password: str = Field(min_length=1, max_length=512)
class SshKeyGenerateRequest(BaseModel):
"""Fuer den 'Schluessel automatisch generieren'-Button im Adminbereich --
erzeugt nur Schluesselmaterial, speichert nichts (siehe
POST /admin/ssh-keys/generate)."""
key_type: Literal["ed25519", "rsa-3072", "rsa-4096"]

View File

@ -181,6 +181,29 @@ def import_private_key_material(
) from exc ) from exc
def generate_key_material(key_type: str) -> tuple[str, str]:
"""Erzeugt ein frisches Schluesselpaar serverseitig (asyncssh) fuer den
'Schluessel automatisch generieren'-Button im Adminbereich.
Der private Schluessel verlaesst den Server genau EINMAL in der HTTP-
Antwort dieses Aufrufs (analog zu einem frisch erstellten API-Token,
siehe admin/routes.py::create_api_token) -- gespeichert wird er erst,
wenn der Admin danach das SSH-Key-Formular tatsaechlich absendet, und ab
dann nur noch AES-256-GCM-verschluesselt (Konzept 6.4). Diese Funktion
selbst persistiert nichts."""
if key_type == "ed25519":
key = asyncssh.generate_private_key("ssh-ed25519")
elif key_type == "rsa-3072":
key = asyncssh.generate_private_key("ssh-rsa", key_size=3072)
elif key_type == "rsa-4096":
key = asyncssh.generate_private_key("ssh-rsa", key_size=4096)
else:
raise ValueError(f"Automatische Erzeugung nicht unterstuetzt fuer key_type={key_type!r}")
private_pem = key.export_private_key("openssh").decode()
public_key = key.export_public_key("openssh").decode().strip()
return private_pem, public_key
#: Fehler, die den Aufbau einer SSH-Sitzung verhindern und dem angemeldeten #: Fehler, die den Aufbau einer SSH-Sitzung verhindern und dem angemeldeten
#: Benutzer im Klartext gezeigt werden duerfen (keine Geheimnisse, nur #: Benutzer im Klartext gezeigt werden duerfen (keine Geheimnisse, nur
#: Konfigurations-/Erreichbarkeitsaussagen). Wird von terminal_ws.py und #: Konfigurations-/Erreichbarkeitsaussagen). Wird von terminal_ws.py und
@ -269,11 +292,14 @@ async def load_host(conn: aiosqlite.Connection, host_id: int) -> dict:
return host return host
async def load_ssh_credential_for_host( async def load_ssh_key_credential_for_host(
conn: aiosqlite.Connection, host_id: int conn: aiosqlite.Connection, host_id: int
) -> tuple[asyncssh.SSHKey, str | None]: ) -> tuple[asyncssh.SSHKey, str | None] | None:
"""Laedt den dem Host zugeordneten Schluessel, entschluesselt ihn und gibt """Laedt den dem Host zugeordneten Schluessel, entschluesselt ihn und gibt
ihn zusammen mit dem am Schluessel hinterlegten Benutzernamen zurueck. ihn zusammen mit dem am Schluessel hinterlegten Benutzernamen zurueck.
Gibt None zurueck (statt zu werfen), wenn kein Schluessel zugeordnet ist
-- der Aufrufer (connect_to_host) faellt dann auf ein SSH-Passwort
zurueck, falls eines hinterlegt ist (Migration 0011).
Der Benutzername gehoert seit Migration 0010 zu den Zugangsdaten Der Benutzername gehoert seit Migration 0010 zu den Zugangsdaten
(ssh_keys.username) und nicht mehr zum Host: er ist Teil der Anmeldung, (ssh_keys.username) und nicht mehr zum Host: er ist Teil der Anmeldung,
@ -289,7 +315,7 @@ async def load_ssh_credential_for_host(
) )
row = await cursor.fetchone() row = await cursor.fetchone()
if row is None: if row is None:
raise HostNotConfiguredError(f"Kein SSH-Schluessel fuer Host {host_id} hinterlegt") return None
pem = decrypt_secret(row[0], associated_data=b"ssh_private_key") pem = decrypt_secret(row[0], associated_data=b"ssh_private_key")
passphrase = ( passphrase = (
decrypt_secret(row[1], associated_data=b"ssh_key_passphrase") if row[1] else None decrypt_secret(row[1], associated_data=b"ssh_key_passphrase") if row[1] else None
@ -304,10 +330,35 @@ async def load_ssh_credential_for_host(
del passphrase del passphrase
async def load_ssh_password_credential_for_host(
conn: aiosqlite.Connection, host_id: int
) -> tuple[str, str] | None:
"""Laedt das (Passwort, Benutzername)-Paar fuer einen Host OHNE
zugeordneten SSH-Key (ssh_password_credentials, Migration 0011). Gibt
None zurueck, wenn kein SSH-Passwort hinterlegt ist."""
cursor = await conn.execute(
"SELECT password_enc, username FROM ssh_password_credentials WHERE host_id = ?",
(host_id,),
)
row = await cursor.fetchone()
if row is None:
return None
password = decrypt_secret(row[0], associated_data=b"ssh_password")
try:
return password.decode(), row[1]
finally:
del password
async def load_private_key_for_host(conn: aiosqlite.Connection, host_id: int) -> asyncssh.SSHKey: async def load_private_key_for_host(conn: aiosqlite.Connection, host_id: int) -> asyncssh.SSHKey:
"""Rueckwaertskompatible Fassung ohne Benutzername (siehe """Rueckwaertskompatible Fassung ohne Benutzername (siehe
load_ssh_credential_for_host).""" load_ssh_key_credential_for_host). Wirft HostNotConfiguredError, wenn kein
key, _username = await load_ssh_credential_for_host(conn, host_id) Schluessel zugeordnet ist -- anders als connect_to_host beruecksichtigt
diese Fassung KEIN SSH-Passwort als Alternative."""
result = await load_ssh_key_credential_for_host(conn, host_id)
if result is None:
raise HostNotConfiguredError(f"Kein SSH-Schluessel fuer Host {host_id} hinterlegt")
key, _username = result
return key return key
@ -323,7 +374,8 @@ def resolve_ssh_username(host: dict, credential_username: str | None) -> str:
if not username: if not username:
raise HostNotConfiguredError( raise HostNotConfiguredError(
"Fuer diesen Host ist kein SSH-Benutzername hinterlegt. Der Benutzername " "Fuer diesen Host ist kein SSH-Benutzername hinterlegt. Der Benutzername "
"gehoert zum SSH-Key (Adminbereich -> Zugangsdaten -> SSH-Key bearbeiten)." "gehoert zu den Zugangsdaten (Adminbereich -> Server -> Host -> SSH-Key "
"bzw. SSH-Passwort bearbeiten)."
) )
return username return username
@ -393,10 +445,33 @@ async def connect_to_host(conn: aiosqlite.Connection, host_id: int) -> asyncssh.
raise HostNotConfiguredError("Host ist kein SSH-Ziel") raise HostNotConfiguredError("Host ist kein SSH-Ziel")
# Reihenfolge mit Absicht: erst das Ziel identifizieren, dann erst # Reihenfolge mit Absicht: erst das Ziel identifizieren, dann erst
# Schluesselmaterial entschluesseln und eine Anmeldung versuchen. # Zugangsdaten entschluesseln und eine Anmeldung versuchen.
trusted_key = await _verified_host_key(conn, host) trusted_key = await _verified_host_key(conn, host)
private_key, credential_username = await load_ssh_credential_for_host(conn, host_id)
# SSH-Key hat immer Vorrang vor einem SSH-Passwort (Migration 0011,
# "Linux kann statt SSH-Key auch Passwort haben" -- explizit als
# Alternative gewuenscht, nicht als gleichrangige zweite Option: ist ein
# Schluessel zugeordnet, wird er benutzt, unabhaengig davon, ob zusaetzlich
# ein Passwort hinterlegt ist).
key_credential = await load_ssh_key_credential_for_host(conn, host_id)
password_credential = None if key_credential is not None else await load_ssh_password_credential_for_host(conn, host_id)
if key_credential is None and password_credential is None:
raise HostNotConfiguredError(
f"Weder ein SSH-Schluessel noch ein SSH-Passwort fuer Host {host_id} hinterlegt"
)
if key_credential is not None:
private_key, credential_username = key_credential
username = resolve_ssh_username(host, credential_username) username = resolve_ssh_username(host, credential_username)
auth_kwargs: dict = {"client_keys": [private_key]}
else:
password, credential_username = password_credential
username = resolve_ssh_username(host, credential_username)
# client_keys=[] deaktiviert bewusst jeden impliziten Rueckgriff auf
# lokale Default-Schluessel (~/.ssh, Agent) -- asyncssh probiert die
# sonst automatisch VOR der Passwort-Authentifizierung.
auth_kwargs = {"client_keys": [], "password": password}
client_factory = lambda: _PinnedHostKeyClient(host["ssh_host_key_fingerprint"]) client_factory = lambda: _PinnedHostKeyClient(host["ssh_host_key_fingerprint"])
try: try:
@ -404,14 +479,17 @@ async def connect_to_host(conn: aiosqlite.Connection, host_id: int) -> asyncssh.
host["address"], host["address"],
port=host["port"], port=host["port"],
username=username, username=username,
client_keys=[private_key],
known_hosts=None, known_hosts=None,
client_factory=client_factory, client_factory=client_factory,
connect_timeout=10, connect_timeout=10,
**auth_kwargs,
) )
except asyncssh.Error as exc: except asyncssh.Error as exc:
logger.warning("SSH-Verbindungsfehler zu Host %s: %s", host_id, exc) logger.warning("SSH-Verbindungsfehler zu Host %s: %s", host_id, exc)
raise raise
finally:
if key_credential is None and password_credential is not None:
del password
# Zweite Haelfte des Pinnings: der Schluessel dieser Sitzung muss derselbe # Zweite Haelfte des Pinnings: der Schluessel dieser Sitzung muss derselbe
# sein wie der eben gepruefte. Faengt den (sehr schmalen) Fall ab, dass # sein wie der eben gepruefte. Faengt den (sehr schmalen) Fall ab, dass

View File

@ -39,6 +39,23 @@ MAX_SESSION_SECONDS = 8 * 3600
IDLE_TIMEOUT_SECONDS = 15 * 60 IDLE_TIMEOUT_SECONDS = 15 * 60
async def _reject(websocket: WebSocket, code: int, reason: str, *, accepted: bool) -> None:
"""Beendet eine SSH-Sitzung vor ihrem eigentlichen Beginn -- mit einem fuer
den Benutzer lesbaren Grund als WebSocket-Close-Reason, analog zu
app/rdp_proxy/ws_tunnel.py::_reject (Troubleshooting-Verbesserung:
vorher endeten diese Pfade in einem nackten `websocket.close(code=...)`,
static/js/terminal.js zeigte dann nur ein generisches
'Verbindung beendet' ohne jeden Grund an). Voraussetzung fuer eine
sichtbare Reason ist ein zustande gekommener Handshake -- vor accept()
sieht der Browser nur einen HTTP-/WS-Fehler ohne Text (siehe die
bewusste Ausnahme fuer den Nicht-angemeldet-Fall unten)."""
logger.warning("SSH-Verbindung abgelehnt (code=%s): %s", code, reason)
reason_bytes = reason.encode("utf-8")[:123]
if not accepted:
await websocket.accept()
await websocket.close(code=code, reason=reason_bytes.decode("utf-8", errors="ignore"))
async def _pump_ssh_to_ws(process: asyncssh.SSHClientProcess, websocket: WebSocket, recorder: SessionRecorder): async def _pump_ssh_to_ws(process: asyncssh.SSHClientProcess, websocket: WebSocket, recorder: SessionRecorder):
try: try:
while True: while True:
@ -68,11 +85,11 @@ async def ssh_terminal(websocket: WebSocket, host_id: int):
if not user.is_admin and not await user_has_role_for_host( if not user.is_admin and not await user_has_role_for_host(
conn, user_id=user.id, host_id=host_id, role_name="ssh_connect" conn, user_id=user.id, host_id=host_id, role_name="ssh_connect"
): ):
logger.warning( await _reject(
"SSH-Verbindung abgelehnt: Benutzer %s hat keine Berechtigung 'ssh_connect' fuer Host %s", websocket, 4403,
user.username, host_id, f"Keine Berechtigung 'ssh_connect' fuer Host {host_id}",
accepted=False,
) )
await websocket.close(code=4403)
return return
await websocket.accept() await websocket.accept()
@ -81,9 +98,11 @@ async def ssh_terminal(websocket: WebSocket, host_id: int):
try: try:
host = await load_host(conn, host_id) host = await load_host(conn, host_id)
except HostNotConfiguredError as exc: except HostNotConfiguredError as exc:
logger.warning("SSH-Verbindung abgelehnt (host_id=%s): %s", host_id, exc) # Grund SOWOHL als JSON-Frame (falls der Client schon zuhoert) ALS
# AUCH als Close-Reason senden (falls nicht) -- terminal.js zeigt
# beides an, je nachdem, was zuerst ankommt.
await websocket.send_json({"type": "error", "message": str(exc)}) await websocket.send_json({"type": "error", "message": str(exc)})
await websocket.close(code=4404) await _reject(websocket, 4404, str(exc), accepted=True)
return return
cursor = await conn.execute( cursor = await conn.execute(

View File

@ -224,3 +224,21 @@ pre.log-view {
} }
.ft-list-row .name { flex: 1; word-break: break-all; } .ft-list-row .name { flex: 1; word-break: break-all; }
.ft-list-row .size { color: var(--muted); flex-shrink: 0; white-space: nowrap; } .ft-list-row .size { color: var(--muted); flex-shrink: 0; white-space: nowrap; }
/* --- Sitzungs-Wiedergabe-Panel (breiter als das Dateitransfer-Panel, --- */
/* muss Terminal- bzw. RDP-Bildschirmgroesse aufnehmen). */
.rec-panel { width: min(90vw, 1100px); }
.rec-stage {
background: #000; border: 1px solid var(--border); border-radius: 6px;
min-height: 320px; max-height: 65vh; overflow: auto; margin-top: 0.75rem;
display: flex; align-items: flex-start; justify-content: center;
}
.rec-stage .rec-terminal { width: 100%; height: 55vh; }
.rec-stage .xterm { padding: 0.4rem; height: 100%; }
.rec-controls {
display: flex; align-items: center; gap: 0.75rem; margin-top: 0.75rem;
}
.rec-controls button { width: auto; margin: 0; }
.rec-controls input[type=range] { flex: 1; }
.rec-controls select { width: auto; padding: 0.3rem 0.4rem; }
.rec-controls .hint { margin: 0; white-space: nowrap; }

View File

@ -520,10 +520,13 @@
if (named.length) { if (named.length) {
return `Anmeldung als ${named.map((k) => `"${k.username}" (Key ${k.label})`).join(", ")}.`; return `Anmeldung als ${named.map((k) => `"${k.username}" (Key ${k.label})`).join(", ")}.`;
} }
if ((host.ssh_keys || []).length === 0 && host.ssh_password_credentials_set) {
return `Anmeldung als "${host.ssh_password_credentials_username}" (SSH-Passwort, kein Schluessel zugeordnet).`;
}
if (host.ssh_username) { 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 `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.'; return 'Kein SSH-Benutzername hinterlegt -- im Reiter "Zugangsdaten" am zugeordneten Schluessel oder als SSH-Passwort eintragen, sonst kommt keine Verbindung zustande.';
} }
async function showHostDetail(hostId) { async function showHostDetail(hostId) {
@ -581,6 +584,11 @@
const keySelect = document.getElementById("hkm-key-select"); const keySelect = document.getElementById("hkm-key-select");
keySelect.replaceChildren(...optionsFromList(cachedSshKeys, "id", (k) => `${k.label} (#${k.id})`)); keySelect.replaceChildren(...optionsFromList(cachedSshKeys, "id", (k) => `${k.label} (#${k.id})`));
document.getElementById("ssh-pw-cred-status").textContent = host.ssh_password_credentials_set
? `SSH-Passwort gesetzt fuer "${host.ssh_password_credentials_username}" (zuletzt aktualisiert: ${host.ssh_password_credentials_updated_at}).`
: "Noch kein SSH-Passwort gesetzt.";
document.getElementById("hspw-username").value = host.ssh_password_credentials_username || "";
document.getElementById("rdp-cred-status").textContent = host.rdp_credentials_set document.getElementById("rdp-cred-status").textContent = host.rdp_credentials_set
? `RDP-Zugangsdaten gesetzt fuer "${host.rdp_credentials_username || "(kein Benutzername)"}" (zuletzt aktualisiert: ${host.rdp_credentials_updated_at}).` ? `RDP-Zugangsdaten gesetzt fuer "${host.rdp_credentials_username || "(kein Benutzername)"}" (zuletzt aktualisiert: ${host.rdp_credentials_updated_at}).`
: "Noch keine RDP-Zugangsdaten gesetzt."; : "Noch keine RDP-Zugangsdaten gesetzt.";
@ -704,6 +712,40 @@
} }
}); });
document.getElementById("hrc-delete-btn").addEventListener("click", async () => {
try {
await apiFetch(`/admin/hosts/${activeHostId}/rdp-credentials`, { method: "DELETE" });
showBanner("RDP-Zugangsdaten geloescht.", "ok");
await showHostDetail(activeHostId);
} catch (err) {
setDetailBanner(err.message, "error");
}
});
document.getElementById("host-ssh-pw-form").addEventListener("submit", async (ev) => {
ev.preventDefault();
try {
const username = document.getElementById("hspw-username").value.trim();
const password = document.getElementById("hspw-password").value;
await sendJson(`/admin/hosts/${activeHostId}/ssh-password`, "PUT", { username, password });
showBanner("SSH-Passwort gespeichert.", "ok");
document.getElementById("hspw-password").value = "";
await showHostDetail(activeHostId);
} catch (err) {
setDetailBanner(err.message, "error");
}
});
document.getElementById("hspw-delete-btn").addEventListener("click", async () => {
try {
await apiFetch(`/admin/hosts/${activeHostId}/ssh-password`, { method: "DELETE" });
showBanner("SSH-Passwort geloescht.", "ok");
await showHostDetail(activeHostId);
} catch (err) {
setDetailBanner(err.message, "error");
}
});
// --------------------------------------------------------------------- // ---------------------------------------------------------------------
// Zugangsdaten (SSH-Keys + RDP-Passwoerter) // Zugangsdaten (SSH-Keys + RDP-Passwoerter)
// --------------------------------------------------------------------- // ---------------------------------------------------------------------
@ -727,7 +769,6 @@
el("td", { textContent: k.label }), el("td", { textContent: k.label }),
el("td", { textContent: k.username || "-" }), el("td", { textContent: k.username || "-" }),
el("td", { textContent: k.key_type }), 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 }), el("td", { textContent: k.tenant_name }),
el("td", { textContent: k.has_passphrase ? "hinterlegt" : "-" }), el("td", { textContent: k.has_passphrase ? "hinterlegt" : "-" }),
el("td", { textContent: k.created_at }), el("td", { textContent: k.created_at }),
@ -754,7 +795,6 @@
document.getElementById("ssh-key-edit-name").textContent = `${k.label} (#${k.id})`; document.getElementById("ssh-key-edit-name").textContent = `${k.label} (#${k.id})`;
document.getElementById("ske-label").value = k.label; document.getElementById("ske-label").value = k.label;
document.getElementById("ske-username").value = k.username || ""; 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("skr-type").value = k.key_type;
document.getElementById("skp-passphrase").value = ""; document.getElementById("skp-passphrase").value = "";
document.getElementById("ske-passphrase-state").textContent = k.has_passphrase document.getElementById("ske-passphrase-state").textContent = k.has_passphrase
@ -799,13 +839,11 @@
document.getElementById("ssh-key-edit-form").addEventListener("submit", async (ev) => { document.getElementById("ssh-key-edit-form").addEventListener("submit", async (ev) => {
ev.preventDefault(); ev.preventDefault();
try { try {
const ownerRaw = document.getElementById("ske-owner").value.trim();
await sendJson(`/admin/ssh-keys/${editingSshKeyId}`, "PUT", { await sendJson(`/admin/ssh-keys/${editingSshKeyId}`, "PUT", {
label: document.getElementById("ske-label").value.trim() || null, label: document.getElementById("ske-label").value.trim() || null,
// Leeres Feld -> null entfernt den Benutzernamen bewusst (die // Leeres Feld -> null entfernt den Benutzernamen bewusst (die
// Server-Semantik unterscheidet zwischen "Feld fehlt" und "null"). // Server-Semantik unterscheidet zwischen "Feld fehlt" und "null").
username: document.getElementById("ske-username").value.trim() || null, username: document.getElementById("ske-username").value.trim() || null,
owner_user_id: ownerRaw ? Number(ownerRaw) : null,
}); });
showBanner("SSH-Key aktualisiert.", "ok"); showBanner("SSH-Key aktualisiert.", "ok");
await refreshSshKeys(); await refreshSshKeys();
@ -823,7 +861,7 @@
public_key: document.getElementById("skr-public").value, public_key: document.getElementById("skr-public").value,
passphrase: document.getElementById("skr-passphrase").value || null, passphrase: document.getElementById("skr-passphrase").value || null,
}); });
showBanner("SSH-Key rotiert.", "ok"); showBanner("Neues Schluesselmaterial gespeichert.", "ok");
ev.target.reset(); ev.target.reset();
await refreshSshKeys(); await refreshSshKeys();
} catch (err) { } catch (err) {
@ -831,15 +869,29 @@
} }
}); });
document.getElementById("skc-generate-btn").addEventListener("click", async () => {
try {
const keyType = document.getElementById("skc-type").value;
if (keyType === "ca-cert") {
showBanner("Automatische Erzeugung ist fuer ca-cert nicht moeglich -- bitte Schluessel/Zertifikat der eigenen CA einfuegen.", "error");
return;
}
const result = await sendJson("/admin/ssh-keys/generate", "POST", { key_type: keyType });
document.getElementById("skc-private").value = result.private_key_pem;
document.getElementById("skc-public").value = result.public_key;
showBanner("Schluesselpaar erzeugt -- bitte Label (und ggf. Benutzername) ausfuellen und 'Anlegen' druecken, um es zu speichern.", "ok");
} catch (err) {
showBanner(err.message, "error");
}
});
document.getElementById("ssh-key-create-form").addEventListener("submit", async (ev) => { document.getElementById("ssh-key-create-form").addEventListener("submit", async (ev) => {
ev.preventDefault(); ev.preventDefault();
try { try {
const ownerRaw = document.getElementById("skc-owner").value.trim();
const tenantSelect = document.getElementById("skc-tenant"); const tenantSelect = document.getElementById("skc-tenant");
const payload = { const payload = {
label: document.getElementById("skc-label").value.trim(), label: document.getElementById("skc-label").value.trim(),
username: document.getElementById("skc-username").value.trim() || null, username: document.getElementById("skc-username").value.trim() || null,
owner_user_id: ownerRaw ? Number(ownerRaw) : null,
key_type: document.getElementById("skc-type").value, key_type: document.getElementById("skc-type").value,
private_key_pem: document.getElementById("skc-private").value, private_key_pem: document.getElementById("skc-private").value,
public_key: document.getElementById("skc-public").value, public_key: document.getElementById("skc-public").value,
@ -1238,7 +1290,7 @@
} }
if (s.has_recording) { if (s.has_recording) {
actions.appendChild( actions.appendChild(
actionButton("Aufzeichnung", "btn-secondary", () => showSessionRecording(s.id)) actionButton("Wiedergabe", "btn-secondary", () => showSessionRecording(s))
); );
} }
return el("tr", {}, [ return el("tr", {}, [
@ -1260,17 +1312,265 @@
); );
} }
async function showSessionRecording(sessionId) { // --- Wiedergabe (SSH: Terminal-Replay ueber xterm.js; RDP: volle
// grafische Wiedergabe ueber Guacamole.SessionRecording) --------------
//
// Die vom Recorder aufgezeichneten "output"-Instruktionen einer RDP-
// Sitzung sind bereits ein gueltiger .guac-Datenstrom (siehe
// app/recordings/recorder.py + app/rdp_proxy/guacd_client.py::
// encode_instruction): guacd sendet als Teil des normalen Protokolls
// periodische "sync"-Instruktionen mit Zeitstempel, genau das nutzt
// Guacamole.SessionRecording zur Framebildung. Aneinandergehaengt ergeben
// die "output"-Eintraege deshalb exakt das Format, das die Bibliothek
// erwartet -- kein eigener Player noetig.
function recB64decode(b64) {
return decodeURIComponent(escape(atob(b64)));
}
function formatMs(ms) {
const totalSec = Math.max(0, Math.round(ms / 1000));
const m = Math.floor(totalSec / 60);
const s = totalSec % 60;
return `${m}:${String(s).padStart(2, "0")}`;
}
let activePlayer = null;
function closeRecordingOverlay() {
document.getElementById("rec-overlay").classList.add("hidden");
if (activePlayer) {
activePlayer.destroy();
activePlayer = null;
}
document.getElementById("rec-stage").replaceChildren();
}
document.getElementById("rec-close-btn").addEventListener("click", closeRecordingOverlay);
function createSshPlayer(entries) {
const outputEntries = entries.filter((e) => e.dir === "output");
const stage = document.getElementById("rec-stage");
const container = el("div", { className: "rec-terminal" });
stage.appendChild(container);
const term = new Terminal({
cursorBlink: false, disableStdin: true, convertEol: false,
fontFamily: "Menlo, Consolas, monospace", fontSize: 13,
theme: { background: "#000000" },
});
const fitAddon = new FitAddon.FitAddon();
term.loadAddon(fitAddon);
term.open(container);
fitAddon.fit();
const playBtn = document.getElementById("rec-playpause-btn");
const seek = document.getElementById("rec-seek");
const timeLabel = document.getElementById("rec-time");
const speedSelect = document.getElementById("rec-speed");
speedSelect.classList.remove("hidden");
const durationMs = outputEntries.length ? outputEntries[outputEntries.length - 1].t * 1000 : 0;
seek.min = "0";
seek.max = String(Math.round(durationMs));
seek.value = "0";
let idx = 0;
let playing = false;
let timer = null;
let speed = Number(speedSelect.value) || 1;
let realStart = 0;
let baseMs = 0;
function updateTimeUi(posMs) {
seek.value = String(Math.round(posMs));
timeLabel.textContent = `${formatMs(posMs)} / ${formatMs(durationMs)}`;
}
function scheduleNext() {
if (!playing) return;
if (idx >= outputEntries.length) {
playing = false;
playBtn.textContent = "Abspielen";
return;
}
const entry = outputEntries[idx];
const targetMs = entry.t * 1000;
const elapsedMs = baseMs + (Date.now() - realStart) * speed;
const delay = Math.max(0, (targetMs - elapsedMs) / speed);
timer = window.setTimeout(() => {
term.write(recB64decode(entry.data));
idx += 1;
updateTimeUi(targetMs);
scheduleNext();
}, delay);
}
function play() {
if (playing || idx >= outputEntries.length) return;
playing = true;
playBtn.textContent = "Pause";
realStart = Date.now();
baseMs = idx > 0 ? outputEntries[idx - 1].t * 1000 : 0;
scheduleNext();
}
function pause() {
playing = false;
playBtn.textContent = "Abspielen";
if (timer) { window.clearTimeout(timer); timer = null; }
}
function seekTo(targetMs) {
pause();
term.reset();
idx = 0;
const targetSec = targetMs / 1000;
while (idx < outputEntries.length && outputEntries[idx].t <= targetSec) {
term.write(recB64decode(outputEntries[idx].data));
idx += 1;
}
updateTimeUi(targetMs);
}
const onPlayPause = () => { if (playing) pause(); else play(); };
const onSeekChange = () => seekTo(Number(seek.value));
const onSpeedChange = () => {
speed = Number(speedSelect.value) || 1;
if (playing) { pause(); play(); }
};
playBtn.textContent = "Abspielen";
playBtn.addEventListener("click", onPlayPause);
seek.addEventListener("change", onSeekChange);
speedSelect.addEventListener("change", onSpeedChange);
updateTimeUi(0);
return {
destroy() {
pause();
playBtn.removeEventListener("click", onPlayPause);
seek.removeEventListener("change", onSeekChange);
speedSelect.removeEventListener("change", onSpeedChange);
speedSelect.classList.add("hidden");
term.dispose();
},
};
}
function createRdpPlayer(entries) {
const stage = document.getElementById("rec-stage");
const container = el("div", {});
stage.appendChild(container);
const guacStream = entries.filter((e) => e.dir === "output").map((e) => e.data).join("");
const blob = new Blob([guacStream], { type: "application/octet-stream" });
const recording = new Guacamole.SessionRecording(blob);
container.appendChild(recording.getDisplay().getElement());
const playBtn = document.getElementById("rec-playpause-btn");
const seek = document.getElementById("rec-seek");
const timeLabel = document.getElementById("rec-time");
let posTimer = null;
let seeking = false;
function updateTimeUi() {
const duration = recording.getDuration();
const durationRounded = Math.round(duration);
if (Number(seek.max) !== durationRounded) seek.max = String(durationRounded);
if (!seeking) seek.value = String(Math.round(recording.getPosition()));
timeLabel.textContent = `${formatMs(recording.getPosition())} / ${formatMs(duration)}`;
}
recording.onload = updateTimeUi;
recording.onprogress = updateTimeUi;
recording.onplay = () => {
playBtn.textContent = "Pause";
if (posTimer) window.clearInterval(posTimer);
posTimer = window.setInterval(updateTimeUi, 200);
};
recording.onpause = () => {
playBtn.textContent = "Abspielen";
if (posTimer) { window.clearInterval(posTimer); posTimer = null; }
updateTimeUi();
};
recording.onerror = (message) => {
showBanner("Fehler bei der RDP-Wiedergabe: " + message, "error");
};
const onPlayPause = () => { if (recording.isPlaying()) recording.pause(); else recording.play(); };
const onSeekStart = () => { seeking = true; };
const onSeekInput = () => {
timeLabel.textContent = `${formatMs(Number(seek.value))} / ${formatMs(recording.getDuration())}`;
};
const onSeekCommit = () => {
recording.seek(Number(seek.value), () => { seeking = false; updateTimeUi(); });
};
playBtn.textContent = "Abspielen";
playBtn.addEventListener("click", onPlayPause);
seek.addEventListener("mousedown", onSeekStart);
seek.addEventListener("touchstart", onSeekStart);
seek.addEventListener("input", onSeekInput);
seek.addEventListener("change", onSeekCommit);
updateTimeUi();
return {
destroy() {
recording.pause();
recording.abort();
if (posTimer) window.clearInterval(posTimer);
playBtn.removeEventListener("click", onPlayPause);
seek.removeEventListener("mousedown", onSeekStart);
seek.removeEventListener("touchstart", onSeekStart);
seek.removeEventListener("input", onSeekInput);
seek.removeEventListener("change", onSeekCommit);
},
};
}
async function showSessionRecording(session) {
const overlay = document.getElementById("rec-overlay");
const title = document.getElementById("rec-title");
const loading = document.getElementById("rec-loading");
const stage = document.getElementById("rec-stage");
const controls = document.getElementById("rec-controls");
const verifyBanner = document.getElementById("rec-verify-banner");
closeRecordingOverlay();
overlay.classList.remove("hidden");
title.textContent = `Wiedergabe -- Sitzung #${session.id} (${session.username} @ ${session.hostname})`;
loading.classList.remove("hidden");
loading.textContent = "Aufzeichnung wird geladen ...";
stage.classList.add("hidden");
controls.classList.add("hidden");
verifyBanner.classList.add("hidden");
try { try {
const result = await getJson(`/admin/sessions/${sessionId}/recording`); const result = await getJson(`/admin/sessions/${session.id}/recording/entries`);
const box = document.getElementById("session-recording-box"); loading.classList.add("hidden");
box.classList.remove("hidden"); stage.classList.remove("hidden");
box.className = `banner ${result.verified ? "ok" : "error"}`; controls.classList.remove("hidden");
box.textContent = result.verified
? `Aufzeichnung #${sessionId}: Integritaet OK, ${result.entry_count} Eintraege.` if (!result.verified) {
: `Aufzeichnung #${sessionId}: WARNUNG -- Hash-Kette gebrochen, moeglicherweise manipuliert!`; verifyBanner.className = "banner error";
verifyBanner.textContent = "WARNUNG: Hash-Kette dieser Aufzeichnung ist gebrochen -- moeglicherweise nachtraeglich veraendert oder beschaedigt. Wiedergabe ist trotzdem moeglich.";
verifyBanner.classList.remove("hidden");
}
if (result.entries.length === 0) {
loading.classList.remove("hidden");
loading.textContent = "Diese Aufzeichnung enthaelt keine Eintraege.";
stage.classList.add("hidden");
controls.classList.add("hidden");
return;
}
activePlayer = result.protocol === "rdp" ? createRdpPlayer(result.entries) : createSshPlayer(result.entries);
} catch (err) { } catch (err) {
showBanner(err.message, "error"); loading.classList.remove("hidden");
loading.textContent = "Fehler beim Laden der Aufzeichnung: " + err.message;
} }
} }

View File

@ -46,6 +46,10 @@
// reicht ihn als Guacamole.Status.message hierher durch. // reicht ihn als Guacamole.Status.message hierher durch.
statusEl.textContent = "Fehler: " + ((err && err.message) || "unbekannt"); statusEl.textContent = "Fehler: " + ((err && err.message) || "unbekannt");
statusEl.classList.add("error"); statusEl.classList.add("error");
// Troubleshooting: Statuscode + Rohobjekt zusaetzlich in die
// Browser-Konsole, fuer den Fall, dass die Klartextmeldung allein nicht
// reicht (z.B. guacd/FreeRDP-interne Codes).
console.error("RDP-Sitzung beendet/Fehler:", err);
}; };
client.onerror = showError; client.onerror = showError;
tunnel.onerror = showError; tunnel.onerror = showError;
@ -91,4 +95,16 @@
document.exitFullscreen(); document.exitFullscreen();
} }
}); });
// Strg+Alt+Entf: der Browser faengt diese Kombination selbst ab (Windows
// reserviert sie systemweit), sie kommt also nie als normales Tastatur-
// Event beim RDP-Ziel an. Abhilfe wie im offiziellen Guacamole-Client:
// die drei Tasten einzeln als synthetische Key-Events senden (X11-
// Keysyms: Ctrl=0xFFE3, Alt=0xFFE9, Delete=0xFFFF), erst alle drei
// herunter- dann in umgekehrter Reihenfolge wieder hochdruecken.
document.getElementById("ctrlaltdel-btn").addEventListener("click", () => {
const keys = [0xffe3, 0xffe9, 0xffff];
for (const keysym of keys) client.sendKeyEvent(1, keysym);
for (const keysym of keys.slice().reverse()) client.sendKeyEvent(0, keysym);
});
})(); })();

View File

@ -37,6 +37,14 @@
return decodeURIComponent(escape(atob(b64))); return decodeURIComponent(escape(atob(b64)));
} }
// Troubleshooting: der Server liefert bei einem Fehlschlag entweder eine
// {"type":"error",...}-Nachricht ODER (wenn die Sitzung nie so weit kam,
// dass eine Nachricht gesendet werden konnte -- z.B. fehlende
// Berechtigung) eine WebSocket-Close-Reason (siehe
// app/ssh_proxy/terminal_ws.py::_reject). Beides wird angezeigt statt
// eines generischen "Verbindung beendet" ohne jeden Grund.
let lastErrorMessage = null;
const proto = window.location.protocol === "https:" ? "wss:" : "ws:"; const proto = window.location.protocol === "https:" ? "wss:" : "ws:";
const ws = new WebSocket(`${proto}//${window.location.host}/ws/ssh/${hostId}`); const ws = new WebSocket(`${proto}//${window.location.host}/ws/ssh/${hostId}`);
@ -44,15 +52,35 @@
statusEl.textContent = "Verbunden"; statusEl.textContent = "Verbunden";
sendResize(); sendResize();
}); });
ws.addEventListener("close", () => { statusEl.textContent = "Verbindung beendet"; }); ws.addEventListener("close", (ev) => {
ws.addEventListener("error", () => { statusEl.textContent = "Verbindungsfehler"; }); let text;
if (lastErrorMessage) {
text = "Verbindung beendet: " + lastErrorMessage;
} else if (ev.reason) {
text = `Verbindung beendet (Code ${ev.code}): ${ev.reason}`;
} else if (ev.code && ev.code !== 1000) {
text = `Verbindung beendet (Code ${ev.code})`;
} else {
text = "Verbindung beendet";
}
statusEl.textContent = text;
statusEl.classList.add("error");
console.error("SSH-Sitzung beendet:", { code: ev.code, reason: ev.reason, lastErrorMessage });
});
ws.addEventListener("error", (ev) => {
statusEl.textContent = "Verbindungsfehler (WebSocket) -- Details siehe Browser-Konsole";
statusEl.classList.add("error");
console.error("SSH-WebSocket-Fehler:", ev);
});
ws.addEventListener("message", (ev) => { ws.addEventListener("message", (ev) => {
const msg = JSON.parse(ev.data); const msg = JSON.parse(ev.data);
if (msg.type === "output") { if (msg.type === "output") {
term.write(b64decode(msg.data)); term.write(b64decode(msg.data));
} else if (msg.type === "error") { } else if (msg.type === "error") {
lastErrorMessage = msg.message;
statusEl.textContent = "Fehler: " + msg.message; statusEl.textContent = "Fehler: " + msg.message;
statusEl.classList.add("error");
} }
}); });

View File

@ -5,6 +5,7 @@
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
<title>Jumphost Admin</title> <title>Jumphost Admin</title>
<link rel="stylesheet" href="/static/css/app.css"> <link rel="stylesheet" href="/static/css/app.css">
<link rel="stylesheet" href="/static/js/vendor/xterm.css">
</head> </head>
<body> <body>
<div class="topbar"> <div class="topbar">
@ -304,6 +305,23 @@
</div> </div>
<button type="submit">Zuordnen</button> <button type="submit">Zuordnen</button>
</form> </form>
<h3>SSH-Passwort (Alternative zum Schluessel)</h3>
<p class="hint" id="ssh-pw-cred-status"></p>
<p class="hint">Wird nur verwendet, wenn diesem Host KEIN SSH-Key zugeordnet ist -- ein zugeordneter Schluessel hat immer Vorrang.</p>
<form id="host-ssh-pw-form" class="form-grid">
<div>
<label for="hspw-username">Benutzername</label>
<input type="text" id="hspw-username" required maxlength="128" autocomplete="off">
</div>
<div class="full-width">
<label for="hspw-password">SSH-Passwort setzen/aendern</label>
<input type="text" id="hspw-password" required maxlength="512">
</div>
<p class="hint full-width">Passwort wird AES-256-GCM-verschluesselt gespeichert und nie wieder ausgegeben; beim Aendern bitte beide Felder ausfuellen.</p>
<button type="submit">Speichern</button>
<button type="button" class="btn-danger" id="hspw-delete-btn">Loeschen</button>
</form>
</div> </div>
<div id="host-detail-rdp" class="hidden"> <div id="host-detail-rdp" class="hidden">
<h3>RDP-Zugangsdaten</h3> <h3>RDP-Zugangsdaten</h3>
@ -323,6 +341,7 @@
</div> </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> <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> <button type="submit">Speichern</button>
<button type="button" class="btn-danger" id="hrc-delete-btn">Loeschen</button>
</form> </form>
</div> </div>
</div> </div>
@ -340,11 +359,6 @@
<div> <div>
<label for="skc-username">Benutzername am Zielsystem</label> <label for="skc-username">Benutzername am Zielsystem</label>
<input type="text" id="skc-username" maxlength="128" autocomplete="off"> <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">
</div> </div>
<div> <div>
<label for="skc-type">Key-Typ</label> <label for="skc-type">Key-Typ</label>
@ -359,6 +373,12 @@
<label for="skc-tenant">Mandant</label> <label for="skc-tenant">Mandant</label>
<select id="skc-tenant" required></select> <select id="skc-tenant" required></select>
</div> </div>
<div class="full-width">
<div class="ft-btn-row">
<button type="button" class="btn-secondary btn-small" id="skc-generate-btn">Schluessel automatisch generieren</button>
<span class="hint" id="skc-generate-hint">Erzeugt ein neues Schluesselpaar serverseitig und fuellt die beiden Felder unten aus (fuer ca-cert nicht moeglich). Der private Schluessel wird NICHT gespeichert, bevor "Anlegen" gedrueckt wird.</span>
</div>
</div>
<div class="full-width"> <div class="full-width">
<label for="skc-private">Privater Schluessel (PEM)</label> <label for="skc-private">Privater Schluessel (PEM)</label>
<textarea id="skc-private" required></textarea> <textarea id="skc-private" required></textarea>
@ -377,7 +397,7 @@
<h3>Vorhandene SSH-Keys</h3> <h3>Vorhandene SSH-Keys</h3>
<div class="table-wrap"> <div class="table-wrap">
<table class="data-table" id="ssh-keys-table"> <table class="data-table" id="ssh-keys-table">
<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> <thead><tr><th>ID</th><th>Label</th><th>Benutzer</th><th>Typ</th><th>Mandant</th><th>Passphrase</th><th>Erstellt</th><th>Zuletzt ersetzt</th><th></th></tr></thead>
<tbody></tbody> <tbody></tbody>
</table> </table>
</div> </div>
@ -394,11 +414,7 @@
<label for="ske-username">Benutzername am Zielsystem</label> <label for="ske-username">Benutzername am Zielsystem</label>
<input type="text" id="ske-username" maxlength="128" autocomplete="off"> <input type="text" id="ske-username" maxlength="128" autocomplete="off">
</div> </div>
<div> <button type="submit">Speichern (Label/Benutzername)</button>
<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 (Label/Benutzername/Besitzer)</button>
</form> </form>
<h3>Passphrase</h3> <h3>Passphrase</h3>
<p class="hint" id="ske-passphrase-state"></p> <p class="hint" id="ske-passphrase-state"></p>
@ -411,8 +427,8 @@
<button type="submit">Passphrase speichern</button> <button type="submit">Passphrase speichern</button>
<button type="button" id="skp-clear-btn" class="btn-secondary">Passphrase entfernen</button> <button type="button" id="skp-clear-btn" class="btn-secondary">Passphrase entfernen</button>
</form> </form>
<h3>Schluessel rotieren</h3> <h3>Neuen Schluessel fuer diesen Eintrag speichern</h3>
<p class="hint">Ersetzt das Schluesselmaterial vollstaendig (neuer privater + oeffentlicher Schluessel). Bereits zugeordnete Hosts nutzen danach automatisch den neuen Schluessel.</p> <p class="hint">Ersetzt das Schluesselmaterial dieses Eintrags vollstaendig (auch "Rotieren" genannt) -- es entsteht KEIN neuer Eintrag, bereits zugeordnete Hosts nutzen danach automatisch den neuen Schluessel.</p>
<form id="ssh-key-rotate-form" class="form-grid"> <form id="ssh-key-rotate-form" class="form-grid">
<div> <div>
<label for="skr-type">Key-Typ</label> <label for="skr-type">Key-Typ</label>
@ -435,7 +451,7 @@
<label for="skr-passphrase">Passphrase des neuen Schluessels (optional)</label> <label for="skr-passphrase">Passphrase des neuen Schluessels (optional)</label>
<input type="password" id="skr-passphrase" maxlength="1024" autocomplete="new-password"> <input type="password" id="skr-passphrase" maxlength="1024" autocomplete="new-password">
</div> </div>
<button type="submit" class="btn-secondary">Rotieren</button> <button type="submit" class="btn-secondary">Neuen Schluessel speichern</button>
</form> </form>
</div> </div>
@ -626,7 +642,6 @@
<input type="checkbox" id="sessions-active-only" checked> <input type="checkbox" id="sessions-active-only" checked>
<label for="sessions-active-only">Nur aktive Sitzungen</label> <label for="sessions-active-only">Nur aktive Sitzungen</label>
</div> </div>
<div id="session-recording-box" class="hidden"></div>
<div class="table-wrap"> <div class="table-wrap">
<table class="data-table" id="sessions-table"> <table class="data-table" id="sessions-table">
<thead><tr><th>ID</th><th>Benutzer</th><th>Host</th><th>Protokoll</th><th>Start</th><th>Status</th><th>Client-IP</th><th></th></tr></thead> <thead><tr><th>ID</th><th>Benutzer</th><th>Host</th><th>Protokoll</th><th>Start</th><th>Status</th><th>Client-IP</th><th></th></tr></thead>
@ -669,6 +684,32 @@
</div> </div>
</div> </div>
<!-- ================= Sitzungs-Wiedergabe (Overlay) ================= -->
<div class="ft-overlay hidden" id="rec-overlay">
<div class="ft-panel rec-panel">
<h2 id="rec-title">Wiedergabe
<button type="button" class="btn-secondary btn-small" id="rec-close-btn">Schliessen</button>
</h2>
<div id="rec-verify-banner" class="hidden"></div>
<div id="rec-loading" class="hint">Aufzeichnung wird geladen ...</div>
<div id="rec-stage" class="rec-stage hidden"></div>
<div class="rec-controls hidden" id="rec-controls">
<button type="button" class="btn-secondary btn-small" id="rec-playpause-btn">Abspielen</button>
<input type="range" id="rec-seek" min="0" max="0" value="0" step="1">
<span class="hint" id="rec-time">0:00 / 0:00</span>
<select id="rec-speed" class="hidden">
<option value="0.5">0.5x</option>
<option value="1" selected>1x</option>
<option value="2">2x</option>
<option value="4">4x</option>
</select>
</div>
</div>
</div>
<script src="/static/js/vendor/xterm.js"></script>
<script src="/static/js/vendor/xterm-addon-fit.js"></script>
<script src="/static/js/vendor/guacamole-common.js"></script>
<script src="/static/js/admin.js"></script> <script src="/static/js/admin.js"></script>
</body> </body>
</html> </html>

View File

@ -10,6 +10,7 @@
<div class="session-shell" id="session-shell" data-host-id="{{ host_id }}"> <div class="session-shell" id="session-shell" data-host-id="{{ host_id }}">
<div class="session-toolbar"> <div class="session-toolbar">
<button id="fullscreen-btn">Vollbild</button> <button id="fullscreen-btn">Vollbild</button>
<button id="ctrlaltdel-btn">Strg+Alt+Entf</button>
<span class="spacer"></span> <span class="spacer"></span>
<span class="status" id="status">Verbinde ...</span> <span class="status" id="status">Verbinde ...</span>
</div> </div>

420
tests/test_phase13.py Normal file
View File

@ -0,0 +1,420 @@
"""
Tests fuer Phase 13 (diese Session -- Sammel-Feedback aus dem Live-Test):
1) RDP-Zugangsdaten muessen loeschbar sein: DELETE /admin/hosts/{id}/rdp-credentials
(bereits vorhandener Endpunkt -- wird hier nur mitverifiziert, da neu vom
Admin-UI aus verlinkt).
2) SSH-Passwort als Alternative zum SSH-Key (Migration 0011): PUT/DELETE
/admin/hosts/{id}/ssh-password, Spiegelung in GET /admin/hosts/{id} UND
im schlanken GET /admin/hosts/{id}/credentials (RBAC-faehig).
5) "Besitzer (User-ID)"-Feld bei SSH-Keys entfernt: owner_user_id darf in
KEINER Antwort von POST/GET/PUT /admin/ssh-keys(...) mehr auftauchen.
7) "Rotieren" -> klareres Wording ist eine reine UI-Textaenderung
(templates/admin.html/static/js/admin.js) ohne eigenen Endpunkt --
der Rotations-MECHANISMUS selbst ist bereits durch test_phase12.py und
die update_ssh_key-Tests hier mitabgedeckt.
8) SSH-Key automatisch generieren: POST /admin/ssh-keys/generate fuer alle
drei unterstuetzten Typen sowie 400 fuer den nicht unterstuetzten Typ
("ca-cert" kann nicht "generiert" werden -- ein CA-Zertifikat setzt eine
bereits existierende eigene CA voraus).
9) Troubleshooting bei fehlgeschlagenen Verbindungen ist ein reiner
Frontend-/WebSocket-Close-Reason-Fix (static/js/terminal.js,
static/js/rdp.js, app/ssh_proxy/terminal_ws.py::_reject) ohne eigenen
HTTP-Endpunkt -- dafuer siehe die manuelle Sandbox-Verifikation
(HTML/JS-Konsistenzpruefung) statt eines pytest-Tests hier.
10) Strg+Alt+Entf-Button ist eine reine Frontend-Aenderung
(templates/rdp.html/static/js/rdp.js) ohne Backend-Anteil.
Sitzungs-Wiedergabe (volle grafische RDP-Wiedergabe + SSH-Textwiedergabe):
GET /admin/sessions/{id}/recording/entries -- nur require_global_admin
(Mandanten-Admins bekommen 403, genau wie beim bereits bestehenden
GET /admin/sessions/{id}/recording), liefert die entschluesselten/rohen
Eintraege NICHT den Klartext einer Passphrase o.ae., sondern ausschliesslich
die bereits im Klartext aufgezeichneten Terminal-/Guacamole-Stroeme (siehe
Konzept 6.5: die Aufzeichnung selbst enthaelt nie Anmeldedaten, sondern nur
Ein-/Ausgabe der Sitzung) -- UND erzeugt bei jedem Aufruf einen eigenen
Audit-Log-Eintrag ('session_recording_viewed'), weil das Ansehen einer
Mitschnitt-Wiedergabe selbst ein auditierenswerter Zugriff ist.
Wie in test_phase9.py/test_phase12.py: echte SQLite-DB + echte Migrationen
ueber den httpx-'client'-Fixture-Pfad (conftest.py), kein laufender SSH-/RDP-
Server noetig. Diese Datei setzt (wie test_phase9.py) fastapi/httpx/aiosqlite/
asyncssh voraus und wurde in DIESER Sandbox mangels PyPI-Netzzugang nicht mit
echtem pytest ausgefuehrt (siehe Sandbox-Einschraenkung, Projektnotiz) --
Aufbau/Assertions wurden stattdessen von Hand gegen den tatsaechlichen Code in
app/admin/routes.py, app/models/schemas.py und app/recordings/recorder.py
geprueft. Bitte lokal mit `pytest -q` laufen lassen.
"""
from __future__ import annotations
import json
import pytest
async def _create_user(conn, username: str, password: str, *, is_admin: bool = False) -> int:
from app.security.passwords import hash_password
cursor = await conn.execute(
"INSERT INTO users (username, password_hash, is_admin, must_change_password) "
"VALUES (?, ?, ?, 0)",
(username, hash_password(password), int(is_admin)),
)
await conn.commit()
return cursor.lastrowid
async def _login_full(client, username: str, password: str) -> str:
import pyotp
resp = await client.post("/auth/login", json={"username": username, "password": password})
assert resp.status_code == 200, resp.text
pending = resp.json()["pending_token"]
resp = await client.post("/auth/totp/enroll/start", json={"pending_token": pending})
assert resp.status_code == 200, resp.text
provisioning_uri = resp.json()["provisioning_uri"]
secret = dict(part.split("=") for part in provisioning_uri.split("?", 1)[1].split("&"))["secret"]
code = pyotp.TOTP(secret).now()
resp = await client.post("/auth/totp/enroll/confirm", json={"pending_token": pending, "code": code})
assert resp.status_code == 200, resp.text
return resp.cookies.get("jh_session")
async def _setup_hostgroup_and_host(conn, *, group_name="p13-group", hostname="p13-host", protocol="ssh"):
cursor = await conn.execute("INSERT INTO host_groups (name) VALUES (?)", (group_name,))
hg_id = cursor.lastrowid
cursor = await conn.execute(
"INSERT INTO hosts (host_group_id, hostname, address, protocol, port, os_type) "
"VALUES (?, ?, '10.13.0.1', ?, ?, ?)",
(hg_id, hostname, protocol, 22 if protocol == "ssh" else 3389,
"linux" if protocol == "ssh" else "windows"),
)
await conn.commit()
return hg_id, cursor.lastrowid
# ---------------------------------------------------------------------------
# 1) RDP-Zugangsdaten: Loeschen
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_rdp_credentials_delete(client):
from app.db import get_db
conn = get_db()
await _create_user(conn, "rd_admin", "Correct-Horse-Battery-Staple-R1", is_admin=True)
hg_id, host_id = await _setup_hostgroup_and_host(conn, group_name="rd-group", hostname="rd-host", protocol="rdp")
await _login_full(client, "rd_admin", "Correct-Horse-Battery-Staple-R1")
resp = await client.put(f"/admin/hosts/{host_id}/rdp-credentials",
json={"username": "Administrator", "password": "s3hr-geheim!!"})
assert resp.status_code == 200, resp.text
resp = await client.get(f"/admin/hosts/{host_id}/credentials")
assert resp.json()["rdp_credentials_set"] is True
resp = await client.delete(f"/admin/hosts/{host_id}/rdp-credentials")
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 False
assert resp.json()["rdp_credentials_username"] is None
# ---------------------------------------------------------------------------
# 2) SSH-Passwort als Alternative zum SSH-Key
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_ssh_password_credentials_set_and_delete(client):
from app.db import get_db
conn = get_db()
await _create_user(conn, "sp_admin", "Correct-Horse-Battery-Staple-P1", is_admin=True)
hg_id, host_id = await _setup_hostgroup_and_host(conn, group_name="sp-group", hostname="sp-host")
await _login_full(client, "sp_admin", "Correct-Horse-Battery-Staple-P1")
# Leerer Benutzername wird abgelehnt (analog RDP/SSH-Key).
resp = await client.put(f"/admin/hosts/{host_id}/ssh-password", json={"username": " ", "password": "geheim123"})
assert resp.status_code == 400, resp.text
resp = await client.put(f"/admin/hosts/{host_id}/ssh-password",
json={"username": "l4u", "password": "sehr-geheimes-passwort"})
assert resp.status_code == 200, resp.text
# Spiegelung im vollen Host-Datensatz (Admin-only)...
resp = await client.get(f"/admin/hosts/{host_id}")
assert resp.status_code == 200, resp.text
body = resp.json()
assert body["ssh_password_credentials_set"] is True
assert body["ssh_password_credentials_username"] == "l4u"
# ...UND im schlanken RBAC-faehigen Endpunkt.
resp = await client.get(f"/admin/hosts/{host_id}/credentials")
assert resp.status_code == 200, resp.text
body = resp.json()
assert body["ssh_password_credentials_set"] is True
assert body["ssh_password_credentials_username"] == "l4u"
# Der Klartext des Passworts wird in KEINER Antwort zurueckgegeben.
assert "password" not in json.dumps(body)
assert "sehr-geheimes-passwort" not in json.dumps(body)
# Erneutes Setzen ist ein Upsert (ON CONFLICT), kein Duplikat/Fehler.
resp = await client.put(f"/admin/hosts/{host_id}/ssh-password",
json={"username": "root", "password": "anderes-passwort-123"})
assert resp.status_code == 200, resp.text
resp = await client.get(f"/admin/hosts/{host_id}/credentials")
assert resp.json()["ssh_password_credentials_username"] == "root"
resp = await client.delete(f"/admin/hosts/{host_id}/ssh-password")
assert resp.status_code == 200, resp.text
resp = await client.get(f"/admin/hosts/{host_id}/credentials")
assert resp.json()["ssh_password_credentials_set"] is False
assert resp.json()["ssh_password_credentials_username"] is None
@pytest.mark.asyncio
async def test_ssh_password_credentials_via_credentials_manage_role(client):
"""credentials_manage erlaubt Nicht-Admins auch das SSH-Passwort zu setzen
(gleiche RBAC-Erweiterung wie fuer RDP/SSH-Key, siehe test_phase9.py)."""
from app.db import get_db
conn = get_db()
admin_id = await _create_user(conn, "sp2_admin", "Correct-Horse-Battery-Staple-P2", is_admin=True)
holder_id = await _create_user(conn, "sp2_holder", "Correct-Horse-Battery-Staple-P3")
hg_id, host_id = await _setup_hostgroup_and_host(conn, group_name="sp2-group", hostname="sp2-host")
await _login_full(client, "sp2_admin", "Correct-Horse-Battery-Staple-P2")
resp = await client.post(
"/admin/roles/grant",
json={"user_id": holder_id, "host_group_id": hg_id, "role_names": ["credentials_manage"]},
)
assert resp.status_code == 200, resp.text
client.cookies.clear()
await _login_full(client, "sp2_holder", "Correct-Horse-Battery-Staple-P3")
resp = await client.put(f"/admin/hosts/{host_id}/ssh-password", json={"username": "l4u", "password": "abc12345"})
assert resp.status_code == 200, resp.text
resp = await client.delete(f"/admin/hosts/{host_id}/ssh-password")
assert resp.status_code == 200, resp.text
# ---------------------------------------------------------------------------
# 5) "Besitzer"-Feld ist vollstaendig entfernt
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_ssh_key_owner_field_is_gone(client):
from app.db import get_db
conn = get_db()
await _create_user(conn, "ow_admin", "Correct-Horse-Battery-Staple-O1", is_admin=True)
await _login_full(client, "ow_admin", "Correct-Horse-Battery-Staple-O1")
resp = await client.post(
"/admin/ssh-keys",
json={
"label": "ohne-besitzer", "key_type": "ed25519", "username": "l4u",
"private_key_pem": _ED25519_TEST_KEY_PEM, "public_key": "ssh-ed25519 AAAAtest",
},
)
assert resp.status_code == 201, resp.text
assert "owner_user_id" not in resp.json()
key_id = resp.json()["id"]
resp = await client.get("/admin/ssh-keys")
assert resp.status_code == 200, resp.text
rows = resp.json()
assert len(rows) == 1
assert "owner_user_id" not in rows[0]
# Die 9 tatsaechlich noch vorhandenen Felder (Admin-Tabelle hat 9 Spalten,
# siehe static/js/admin.js::refreshSshKeys()).
assert set(rows[0]) == {
"id", "label", "key_type", "created_at", "rotated_at", "expires_at",
"tenant_id", "tenant_name", "has_passphrase", "username",
} | {"username"} # (Mengen sind idempotent -- nur zur Lesbarkeit doppelt genannt)
resp = await client.put(f"/admin/ssh-keys/{key_id}", json={"label": "umbenannt", "owner_user_id": 999})
assert resp.status_code == 200, resp.text
# Ein unbekanntes Feld im Request wird von Pydantic (extra="ignore" per
# Default) klaglos verworfen -- es darf insbesondere KEINEN Fehler geben
# und keine owner_user_id-Spalte mehr existieren, in die geschrieben
# werden koennte (siehe Migration -- das Feld wurde nie in einer eigenen
# Migration entfernt, weil es nie eine eigene Spalte hatte, sondern nur
# eine tote FK-Spalte namens owner_user_id in Migration 0001; das UPDATE
# in update_ssh_key() adressiert sie nirgends mehr).
resp = await client.get("/admin/ssh-keys")
assert "owner_user_id" not in resp.json()[0]
# ---------------------------------------------------------------------------
# 8) SSH-Key automatisch generieren
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
@pytest.mark.parametrize("key_type", ["ed25519", "rsa-3072", "rsa-4096"])
async def test_generate_ssh_key_supported_types(client, key_type):
from app.db import get_db
conn = get_db()
await _create_user(conn, f"gen_admin_{key_type.replace('-', '_')}", "Correct-Horse-Battery-Staple-G1", is_admin=True)
await _login_full(client, f"gen_admin_{key_type.replace('-', '_')}", "Correct-Horse-Battery-Staple-G1")
resp = await client.post("/admin/ssh-keys/generate", json={"key_type": key_type})
assert resp.status_code == 200, resp.text
body = resp.json()
assert body["key_type"] == key_type
assert "PRIVATE KEY" in body["private_key_pem"]
assert body["public_key"].startswith("ssh-")
# Erzeugtes Material muss tatsaechlich als SSH-Key uebernehmbar sein --
# end-to-end-Beweis, dass "generieren" und "speichern" zusammenspielen.
resp = await client.post(
"/admin/ssh-keys",
json={
"label": f"generiert-{key_type}", "key_type": key_type, "username": "l4u",
"private_key_pem": body["private_key_pem"], "public_key": body["public_key"],
},
)
assert resp.status_code == 201, resp.text
@pytest.mark.asyncio
async def test_generate_ssh_key_rejects_ca_cert(client):
from app.db import get_db
conn = get_db()
await _create_user(conn, "gen_admin_ca", "Correct-Horse-Battery-Staple-G2", is_admin=True)
await _login_full(client, "gen_admin_ca", "Correct-Horse-Battery-Staple-G2")
resp = await client.post("/admin/ssh-keys/generate", json={"key_type": "ca-cert"})
# Pydantic weist den Literal-Wert schon vor dem Handler zurueck (422),
# NICHT das im Handler behandelte generische ValueError->400 -- beides
# bedeutet aber "kein 500 / keine stillschweigend falsche Antwort".
assert resp.status_code == 422, resp.text
# ---------------------------------------------------------------------------
# Sitzungs-Wiedergabe: GET /admin/sessions/{id}/recording/entries
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_session_recording_entries_requires_global_admin(client):
from app.db import get_db
conn = get_db()
# Mandanten-Admin (Tenant-gebunden ueber tenant_admins, kein globaler
# Super-Admin) darf NICHT -- dieser Endpunkt liefert vollstaendigen
# Klartext-Mitschnitt und ist bewusst auf require_global_admin
# beschraenkt (siehe Docstring des Endpunkts in app/admin/routes.py).
# Aufbau eines Mandanten-Admins wie in tests/test_tenants.py: ein
# Super-Admin legt einen Mandanten an und traegt einen (sonst nicht
# privilegierten) Benutzer als dessen Tenant-Admin ein.
super_id = await _create_user(conn, "rec_bootstrap_super", "Correct-Horse-Battery-Staple-N0", is_admin=True)
await _login_full(client, "rec_bootstrap_super", "Correct-Horse-Battery-Staple-N0")
resp = await client.post("/admin/tenants", json={"name": "Kunde Rec"})
assert resp.status_code == 201, resp.text
tenant_id = resp.json()["id"]
tenant_admin_id = await _create_user(conn, "rec_tenant_admin", "Correct-Horse-Battery-Staple-N1")
resp = await client.post(f"/admin/tenants/{tenant_id}/admins", json={"user_id": tenant_admin_id})
assert resp.status_code == 201, resp.text
client.cookies.clear()
hg_id, host_id = await _setup_hostgroup_and_host(conn, group_name="rec-group", hostname="rec-host")
user_id = await _create_user(conn, "rec_user", "Correct-Horse-Battery-Staple-N2")
cursor = await conn.execute(
"INSERT INTO sessions (user_id, host_id, protocol, client_ip) VALUES (?, ?, 'ssh', '9.9.9.9')",
(user_id, host_id),
)
session_id = cursor.lastrowid
await conn.commit()
await _login_full(client, "rec_tenant_admin", "Correct-Horse-Battery-Staple-N1")
resp = await client.get(f"/admin/sessions/{session_id}/recording/entries")
assert resp.status_code == 403, resp.text
@pytest.mark.asyncio
async def test_session_recording_entries_ssh_playback_and_audit(client):
from app.db import get_db
from app.recordings.recorder import SessionRecorder
conn = get_db()
await _create_user(conn, "rec_super", "Correct-Horse-Battery-Staple-N3", is_admin=True)
user_id = await _create_user(conn, "rec_user2", "Correct-Horse-Battery-Staple-N4")
hg_id, host_id = await _setup_hostgroup_and_host(conn, group_name="rec2-group", hostname="rec2-host")
cursor = await conn.execute(
"INSERT INTO sessions (user_id, host_id, protocol, client_ip) VALUES (?, ?, 'ssh', '9.9.9.9')",
(user_id, host_id),
)
session_id = cursor.lastrowid
await conn.commit()
# Echter SessionRecorder statt Fake -- schreibt dieselbe hash-verkettete
# JSONL-Datei, die terminal_ws.py/ws_tunnel.py im Betrieb erzeugen.
recorder = SessionRecorder(session_id)
recorder.record("input", "ls\n")
recorder.record("output", "total 0\n")
recorder.record("output", "$ ")
recorder.close()
await conn.execute("UPDATE sessions SET recording_path = ? WHERE id = ?", (str(recorder.path), session_id))
await conn.commit()
await _login_full(client, "rec_super", "Correct-Horse-Battery-Staple-N3")
resp = await client.get(f"/admin/sessions/{session_id}/recording/entries")
assert resp.status_code == 200, resp.text
body = resp.json()
assert body["session_id"] == session_id
assert body["protocol"] == "ssh"
assert body["verified"] is True
assert len(body["entries"]) == 3
assert [e["dir"] for e in body["entries"]] == ["input", "output", "output"]
assert body["entries"][1]["data"] == "total 0\n"
# Jeder Aufruf erzeugt einen eigenen, gesonderten Audit-Log-Eintrag.
resp = await client.get("/admin/audit-log")
assert resp.status_code == 200, resp.text
events = [e["event_type"] for e in resp.json()]
assert "session_recording_viewed" in events
@pytest.mark.asyncio
async def test_session_recording_entries_404_without_recording(client):
from app.db import get_db
conn = get_db()
await _create_user(conn, "rec_super2", "Correct-Horse-Battery-Staple-N5", is_admin=True)
user_id = await _create_user(conn, "rec_user3", "Correct-Horse-Battery-Staple-N6")
hg_id, host_id = await _setup_hostgroup_and_host(conn, group_name="rec3-group", hostname="rec3-host")
cursor = await conn.execute(
"INSERT INTO sessions (user_id, host_id, protocol, client_ip) VALUES (?, ?, 'ssh', '9.9.9.9')",
(user_id, host_id),
)
session_id = cursor.lastrowid
await conn.commit()
await _login_full(client, "rec_super2", "Correct-Horse-Battery-Staple-N5")
resp = await client.get(f"/admin/sessions/{session_id}/recording/entries")
assert resp.status_code == 404, resp.text
# ---------------------------------------------------------------------------
# Testschluessel (nur fuer diese Datei -- kein echtes Geheimnis).
# ---------------------------------------------------------------------------
_ED25519_TEST_KEY_PEM = None
def _generate_test_key_pem() -> str:
from cryptography.hazmat.primitives import serialization
from cryptography.hazmat.primitives.asymmetric import ed25519
return ed25519.Ed25519PrivateKey.generate().private_bytes(
serialization.Encoding.PEM,
serialization.PrivateFormat.OpenSSH,
serialization.NoEncryption(),
).decode()
_ED25519_TEST_KEY_PEM = _generate_test_key_pem()