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,
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-