more stuff
This commit is contained in:
@ -48,7 +48,9 @@ from app.models.schemas import (
|
||||
RoleGrantRequest,
|
||||
RoleRevokeRequest,
|
||||
SshKeyCreateRequest,
|
||||
SshKeyGenerateRequest,
|
||||
SshKeyUpdateRequest,
|
||||
SshPasswordCredentialsRequest,
|
||||
TenantAdminAssignRequest,
|
||||
TenantCreateRequest,
|
||||
TenantUpdateRequest,
|
||||
@ -74,6 +76,7 @@ from app.ssh_proxy.proxy import (
|
||||
HostNotConfiguredError,
|
||||
PrivateKeyUnusableError,
|
||||
discover_and_store_host_key,
|
||||
generate_key_material,
|
||||
import_private_key_material,
|
||||
)
|
||||
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(
|
||||
"SELECT updated_at, username, domain FROM rdp_credentials WHERE host_id = ?", (host_id,)
|
||||
)).fetchone()
|
||||
ssh_pw_row = await (await conn.execute(
|
||||
"SELECT updated_at, username FROM ssh_password_credentials WHERE host_id = ?", (host_id,)
|
||||
)).fetchone()
|
||||
return {
|
||||
"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],
|
||||
@ -833,6 +839,10 @@ async def get_host_detail(
|
||||
# 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,
|
||||
# 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"}
|
||||
|
||||
|
||||
@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")
|
||||
async def get_host_credentials(
|
||||
host_id: int,
|
||||
@ -1034,12 +1097,18 @@ async def get_host_credentials(
|
||||
rdp_row = await (await conn.execute(
|
||||
"SELECT updated_at, username, domain FROM rdp_credentials WHERE host_id = ?", (host_id,)
|
||||
)).fetchone()
|
||||
ssh_pw_row = await (await conn.execute(
|
||||
"SELECT updated_at, username FROM ssh_password_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,
|
||||
"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
|
||||
)
|
||||
cursor = await conn.execute(
|
||||
"INSERT INTO ssh_keys (label, owner_user_id, private_key_enc, public_key, key_type, "
|
||||
"tenant_id, passphrase_enc, username) VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
|
||||
(payload.label, payload.owner_user_id, encrypted, payload.public_key, payload.key_type,
|
||||
"INSERT INTO ssh_keys (label, private_key_enc, public_key, key_type, "
|
||||
"tenant_id, passphrase_enc, username) VALUES (?, ?, ?, ?, ?, ?, ?)",
|
||||
(payload.label, encrypted, payload.public_key, payload.key_type,
|
||||
tenant_id, passphrase_enc, (payload.username or "").strip() or None),
|
||||
)
|
||||
new_id = cursor.lastrowid
|
||||
@ -1311,13 +1380,30 @@ async def create_ssh_key(
|
||||
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")
|
||||
async def list_ssh_keys(admin: CurrentUser = Depends(require_admin_or_scope("ssh_keys", "read"))):
|
||||
conn = get_db()
|
||||
scope = _scope(admin)
|
||||
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, "
|
||||
"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 "
|
||||
"FROM ssh_keys sk JOIN tenants t ON t.id = sk.tenant_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()
|
||||
return [
|
||||
{
|
||||
"id": r[0], "label": r[1], "owner_user_id": r[2], "key_type": r[3],
|
||||
"created_at": r[4], "rotated_at": r[5], "expires_at": r[6],
|
||||
"tenant_id": r[7], "tenant_name": r[8],
|
||||
"id": r[0], "label": r[1], "key_type": r[2],
|
||||
"created_at": r[3], "rotated_at": r[4], "expires_at": r[5],
|
||||
"tenant_id": r[6], "tenant_name": r[7],
|
||||
# Nur die Tatsache, NIE die Passphrase selbst -- kein Endpunkt
|
||||
# dieser Anwendung gibt jemals Klartext-Geheimnisse zurueck.
|
||||
"has_passphrase": bool(r[9]),
|
||||
"has_passphrase": bool(r[8]),
|
||||
# Anmeldename des Zielsystems (Migration 0010). Kein Geheimnis --
|
||||
# er wird angezeigt, damit erkennbar ist, welcher Zugang das ist.
|
||||
"username": r[10],
|
||||
"username": r[9],
|
||||
}
|
||||
for r in rows
|
||||
]
|
||||
@ -1393,8 +1479,6 @@ async def update_ssh_key(
|
||||
fields, values = [], []
|
||||
if payload.label is not None:
|
||||
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.
|
||||
@ -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}
|
||||
|
||||
|
||||
@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
|
||||
# diesem /admin-Router, sondern als eigener Top-Level-Router in
|
||||
# app/admin/log_ws.py -- siehe dort fuer den Grund (nginx-Reverse-Proxy-
|
||||
|
||||
13
app/db/migrations/0011_ssh_password_credentials.sql
Normal file
13
app/db/migrations/0011_ssh_password_credentials.sql
Normal 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'))
|
||||
);
|
||||
@ -216,7 +216,6 @@ class ApiTokenCreateRequest(BaseModel):
|
||||
|
||||
class SshKeyCreateRequest(BaseModel):
|
||||
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)
|
||||
public_key: str = Field(min_length=1, max_length=8192)
|
||||
key_type: Literal["ed25519", "rsa-3072", "rsa-4096", "ca-cert"]
|
||||
@ -234,7 +233,6 @@ class SshKeyCreateRequest(BaseModel):
|
||||
|
||||
class SshKeyUpdateRequest(BaseModel):
|
||||
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,
|
||||
# rotated_at wird aktualisiert). Alle drei Felder muessen dann zusammen
|
||||
# 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.
|
||||
username: 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"]
|
||||
|
||||
@ -181,6 +181,29 @@ def import_private_key_material(
|
||||
) 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
|
||||
#: Benutzer im Klartext gezeigt werden duerfen (keine Geheimnisse, nur
|
||||
#: 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
|
||||
|
||||
|
||||
async def load_ssh_credential_for_host(
|
||||
async def load_ssh_key_credential_for_host(
|
||||
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
|
||||
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
|
||||
(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()
|
||||
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")
|
||||
passphrase = (
|
||||
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
|
||||
|
||||
|
||||
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:
|
||||
"""Rueckwaertskompatible Fassung ohne Benutzername (siehe
|
||||
load_ssh_credential_for_host)."""
|
||||
key, _username = await load_ssh_credential_for_host(conn, host_id)
|
||||
load_ssh_key_credential_for_host). Wirft HostNotConfiguredError, wenn kein
|
||||
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
|
||||
|
||||
|
||||
@ -323,7 +374,8 @@ def resolve_ssh_username(host: dict, credential_username: str | None) -> str:
|
||||
if not username:
|
||||
raise HostNotConfiguredError(
|
||||
"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
|
||||
|
||||
@ -393,10 +445,33 @@ async def connect_to_host(conn: aiosqlite.Connection, host_id: int) -> asyncssh.
|
||||
raise HostNotConfiguredError("Host ist kein SSH-Ziel")
|
||||
|
||||
# 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)
|
||||
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)
|
||||
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"])
|
||||
|
||||
try:
|
||||
@ -404,14 +479,17 @@ async def connect_to_host(conn: aiosqlite.Connection, host_id: int) -> asyncssh.
|
||||
host["address"],
|
||||
port=host["port"],
|
||||
username=username,
|
||||
client_keys=[private_key],
|
||||
known_hosts=None,
|
||||
client_factory=client_factory,
|
||||
connect_timeout=10,
|
||||
**auth_kwargs,
|
||||
)
|
||||
except asyncssh.Error as exc:
|
||||
logger.warning("SSH-Verbindungsfehler zu Host %s: %s", host_id, exc)
|
||||
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
|
||||
# sein wie der eben gepruefte. Faengt den (sehr schmalen) Fall ab, dass
|
||||
|
||||
@ -39,6 +39,23 @@ MAX_SESSION_SECONDS = 8 * 3600
|
||||
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):
|
||||
try:
|
||||
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(
|
||||
conn, user_id=user.id, host_id=host_id, role_name="ssh_connect"
|
||||
):
|
||||
logger.warning(
|
||||
"SSH-Verbindung abgelehnt: Benutzer %s hat keine Berechtigung 'ssh_connect' fuer Host %s",
|
||||
user.username, host_id,
|
||||
await _reject(
|
||||
websocket, 4403,
|
||||
f"Keine Berechtigung 'ssh_connect' fuer Host {host_id}",
|
||||
accepted=False,
|
||||
)
|
||||
await websocket.close(code=4403)
|
||||
return
|
||||
|
||||
await websocket.accept()
|
||||
@ -81,9 +98,11 @@ async def ssh_terminal(websocket: WebSocket, host_id: int):
|
||||
try:
|
||||
host = await load_host(conn, host_id)
|
||||
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.close(code=4404)
|
||||
await _reject(websocket, 4404, str(exc), accepted=True)
|
||||
return
|
||||
|
||||
cursor = await conn.execute(
|
||||
|
||||
@ -224,3 +224,21 @@ pre.log-view {
|
||||
}
|
||||
.ft-list-row .name { flex: 1; word-break: break-all; }
|
||||
.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; }
|
||||
|
||||
@ -520,10 +520,13 @@
|
||||
if (named.length) {
|
||||
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) {
|
||||
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) {
|
||||
@ -581,6 +584,11 @@
|
||||
const keySelect = document.getElementById("hkm-key-select");
|
||||
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
|
||||
? `RDP-Zugangsdaten gesetzt fuer "${host.rdp_credentials_username || "(kein Benutzername)"}" (zuletzt aktualisiert: ${host.rdp_credentials_updated_at}).`
|
||||
: "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)
|
||||
// ---------------------------------------------------------------------
|
||||
@ -727,7 +769,6 @@
|
||||
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 }),
|
||||
el("td", { textContent: k.has_passphrase ? "hinterlegt" : "-" }),
|
||||
el("td", { textContent: k.created_at }),
|
||||
@ -754,7 +795,6 @@
|
||||
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 = "";
|
||||
document.getElementById("ske-passphrase-state").textContent = k.has_passphrase
|
||||
@ -799,13 +839,11 @@
|
||||
document.getElementById("ssh-key-edit-form").addEventListener("submit", async (ev) => {
|
||||
ev.preventDefault();
|
||||
try {
|
||||
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");
|
||||
await refreshSshKeys();
|
||||
@ -823,7 +861,7 @@
|
||||
public_key: document.getElementById("skr-public").value,
|
||||
passphrase: document.getElementById("skr-passphrase").value || null,
|
||||
});
|
||||
showBanner("SSH-Key rotiert.", "ok");
|
||||
showBanner("Neues Schluesselmaterial gespeichert.", "ok");
|
||||
ev.target.reset();
|
||||
await refreshSshKeys();
|
||||
} 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) => {
|
||||
ev.preventDefault();
|
||||
try {
|
||||
const ownerRaw = document.getElementById("skc-owner").value.trim();
|
||||
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,
|
||||
public_key: document.getElementById("skc-public").value,
|
||||
@ -1238,7 +1290,7 @@
|
||||
}
|
||||
if (s.has_recording) {
|
||||
actions.appendChild(
|
||||
actionButton("Aufzeichnung", "btn-secondary", () => showSessionRecording(s.id))
|
||||
actionButton("Wiedergabe", "btn-secondary", () => showSessionRecording(s))
|
||||
);
|
||||
}
|
||||
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 {
|
||||
const result = await getJson(`/admin/sessions/${sessionId}/recording`);
|
||||
const box = document.getElementById("session-recording-box");
|
||||
box.classList.remove("hidden");
|
||||
box.className = `banner ${result.verified ? "ok" : "error"}`;
|
||||
box.textContent = result.verified
|
||||
? `Aufzeichnung #${sessionId}: Integritaet OK, ${result.entry_count} Eintraege.`
|
||||
: `Aufzeichnung #${sessionId}: WARNUNG -- Hash-Kette gebrochen, moeglicherweise manipuliert!`;
|
||||
const result = await getJson(`/admin/sessions/${session.id}/recording/entries`);
|
||||
loading.classList.add("hidden");
|
||||
stage.classList.remove("hidden");
|
||||
controls.classList.remove("hidden");
|
||||
|
||||
if (!result.verified) {
|
||||
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) {
|
||||
showBanner(err.message, "error");
|
||||
loading.classList.remove("hidden");
|
||||
loading.textContent = "Fehler beim Laden der Aufzeichnung: " + err.message;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -46,6 +46,10 @@
|
||||
// reicht ihn als Guacamole.Status.message hierher durch.
|
||||
statusEl.textContent = "Fehler: " + ((err && err.message) || "unbekannt");
|
||||
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;
|
||||
tunnel.onerror = showError;
|
||||
@ -91,4 +95,16 @@
|
||||
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);
|
||||
});
|
||||
})();
|
||||
|
||||
@ -37,6 +37,14 @@
|
||||
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 ws = new WebSocket(`${proto}//${window.location.host}/ws/ssh/${hostId}`);
|
||||
|
||||
@ -44,15 +52,35 @@
|
||||
statusEl.textContent = "Verbunden";
|
||||
sendResize();
|
||||
});
|
||||
ws.addEventListener("close", () => { statusEl.textContent = "Verbindung beendet"; });
|
||||
ws.addEventListener("error", () => { statusEl.textContent = "Verbindungsfehler"; });
|
||||
ws.addEventListener("close", (ev) => {
|
||||
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) => {
|
||||
const msg = JSON.parse(ev.data);
|
||||
if (msg.type === "output") {
|
||||
term.write(b64decode(msg.data));
|
||||
} else if (msg.type === "error") {
|
||||
lastErrorMessage = msg.message;
|
||||
statusEl.textContent = "Fehler: " + msg.message;
|
||||
statusEl.classList.add("error");
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@ -5,6 +5,7 @@
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Jumphost Admin</title>
|
||||
<link rel="stylesheet" href="/static/css/app.css">
|
||||
<link rel="stylesheet" href="/static/js/vendor/xterm.css">
|
||||
</head>
|
||||
<body>
|
||||
<div class="topbar">
|
||||
@ -304,6 +305,23 @@
|
||||
</div>
|
||||
<button type="submit">Zuordnen</button>
|
||||
</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 id="host-detail-rdp" class="hidden">
|
||||
<h3>RDP-Zugangsdaten</h3>
|
||||
@ -323,6 +341,7 @@
|
||||
</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>
|
||||
<button type="button" class="btn-danger" id="hrc-delete-btn">Loeschen</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
@ -340,11 +359,6 @@
|
||||
<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">
|
||||
</div>
|
||||
<div>
|
||||
<label for="skc-type">Key-Typ</label>
|
||||
@ -359,6 +373,12 @@
|
||||
<label for="skc-tenant">Mandant</label>
|
||||
<select id="skc-tenant" required></select>
|
||||
</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">
|
||||
<label for="skc-private">Privater Schluessel (PEM)</label>
|
||||
<textarea id="skc-private" required></textarea>
|
||||
@ -377,7 +397,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>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>
|
||||
</table>
|
||||
</div>
|
||||
@ -394,11 +414,7 @@
|
||||
<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 (Label/Benutzername/Besitzer)</button>
|
||||
<button type="submit">Speichern (Label/Benutzername)</button>
|
||||
</form>
|
||||
<h3>Passphrase</h3>
|
||||
<p class="hint" id="ske-passphrase-state"></p>
|
||||
@ -411,8 +427,8 @@
|
||||
<button type="submit">Passphrase speichern</button>
|
||||
<button type="button" id="skp-clear-btn" class="btn-secondary">Passphrase entfernen</button>
|
||||
</form>
|
||||
<h3>Schluessel rotieren</h3>
|
||||
<p class="hint">Ersetzt das Schluesselmaterial vollstaendig (neuer privater + oeffentlicher Schluessel). Bereits zugeordnete Hosts nutzen danach automatisch den neuen Schluessel.</p>
|
||||
<h3>Neuen Schluessel fuer diesen Eintrag speichern</h3>
|
||||
<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">
|
||||
<div>
|
||||
<label for="skr-type">Key-Typ</label>
|
||||
@ -435,7 +451,7 @@
|
||||
<label for="skr-passphrase">Passphrase des neuen Schluessels (optional)</label>
|
||||
<input type="password" id="skr-passphrase" maxlength="1024" autocomplete="new-password">
|
||||
</div>
|
||||
<button type="submit" class="btn-secondary">Rotieren</button>
|
||||
<button type="submit" class="btn-secondary">Neuen Schluessel speichern</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
@ -626,7 +642,6 @@
|
||||
<input type="checkbox" id="sessions-active-only" checked>
|
||||
<label for="sessions-active-only">Nur aktive Sitzungen</label>
|
||||
</div>
|
||||
<div id="session-recording-box" class="hidden"></div>
|
||||
<div class="table-wrap">
|
||||
<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>
|
||||
@ -669,6 +684,32 @@
|
||||
</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>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@ -10,6 +10,7 @@
|
||||
<div class="session-shell" id="session-shell" data-host-id="{{ host_id }}">
|
||||
<div class="session-toolbar">
|
||||
<button id="fullscreen-btn">Vollbild</button>
|
||||
<button id="ctrlaltdel-btn">Strg+Alt+Entf</button>
|
||||
<span class="spacer"></span>
|
||||
<span class="status" id="status">Verbinde ...</span>
|
||||
</div>
|
||||
|
||||
420
tests/test_phase13.py
Normal file
420
tests/test_phase13.py
Normal 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()
|
||||
Reference in New Issue
Block a user