connectiopn fix round 2 1

This commit is contained in:
2026-08-21 14:18:27 +02:00
parent ad728ba96c
commit 6b2c5ac8a2
11 changed files with 544 additions and 135 deletions

View File

@ -44,7 +44,8 @@ from app.models.schemas import (
HostGroupCreateRequest,
HostGroupUpdateRequest,
HostUpdateRequest,
RdpCredentialsRequest,
RdpCredentialCreateRequest,
RdpCredentialUpdateRequest,
RoleGrantRequest,
RoleRevokeRequest,
SshKeyCreateRequest,
@ -819,8 +820,14 @@ async def get_host_detail(
(host_id,),
)
ssh_keys = [{"id": k[0], "label": k[1], "username": k[2]} for k in await keys_cursor.fetchall()]
# Migration 0012: das zugewiesene RDP-Zugangsdaten-Objekt (falls
# vorhanden) statt eines 1:1-Datensatzes am Host -- siehe
# host_rdp_credential_map.
rdp_row = await (await conn.execute(
"SELECT updated_at, username, domain FROM rdp_credentials WHERE host_id = ?", (host_id,)
"SELECT rc.id, rc.label, rc.username, rc.domain, COALESCE(rc.rotated_at, rc.created_at) "
"FROM host_rdp_credential_map m JOIN rdp_credentials rc ON rc.id = m.rdp_credential_id "
"WHERE m.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,)
@ -834,11 +841,13 @@ async def get_host_detail(
"tenant_id": row[15], "tenant_name": row[16], "rdp_ignore_cert": bool(row[17]),
"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_id": rdp_row[0] if rdp_row else None,
"rdp_credentials_label": rdp_row[1] if rdp_row else None,
# Benutzername/Domaene gehoeren seit Migration 0010 zu den
# Zugangsdaten; sie werden hier nur zur Anzeige mitgeliefert.
"rdp_credentials_username": rdp_row[1] if rdp_row else None,
"rdp_credentials_domain": rdp_row[2] if rdp_row else None,
# Zugangsdaten (jetzt: dem zugewiesenen Objekt); nur zur Anzeige.
"rdp_credentials_username": rdp_row[2] if rdp_row else None,
"rdp_credentials_domain": rdp_row[3] if rdp_row else None,
"rdp_credentials_updated_at": rdp_row[4] 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,
@ -908,7 +917,20 @@ async def delete_host(
)).fetchone()
if has_sessions is None:
await conn.execute("DELETE FROM host_ssh_key_map WHERE host_id = ?", (host_id,))
await conn.execute("DELETE FROM rdp_credentials WHERE host_id = ?", (host_id,))
# Migration 0012: rdp_credentials hat seit der Umstellung auf
# wiederverwendbare Zugangsdaten-Objekte KEINE host_id-Spalte
# mehr -- nur noch die Zuordnungstabelle referenziert den Host
# (mit ON DELETE CASCADE, dieser Aufruf ist also strenggenommen
# redundant, aber explizit wie die anderen Zeilen hier gehalten).
await conn.execute("DELETE FROM host_rdp_credential_map WHERE host_id = ?", (host_id,))
# Nebenbefund beim Anpassen dieser Stelle: ssh_password_credentials
# (Migration 0011) fehlte hier komplett -- ohne ON DELETE CASCADE
# haette ein harter Loeschversuch mit gesetztem SSH-Passwort bei
# aktivem foreign_keys=ON (app/db.py) mit einem FK-Fehler gescheitert
# (dann automatisch auf Soft-Delete zurueckgefallen, siehe unten --
# also kein sichtbarer 500er, aber ein hartes Loeschen war fuer
# solche Hosts faktisch nie moeglich).
await conn.execute("DELETE FROM ssh_password_credentials WHERE host_id = ?", (host_id,))
await conn.execute("DELETE FROM hosts WHERE id = ?", (host_id,))
hard_deleted = True
if not hard_deleted:
@ -934,7 +956,17 @@ async def discover_host_key(
Bugfix: discover_and_store_host_key() konnte bisher ein unbehandeltes
asyncssh/OSError durchreichen -> FastAPI antwortete mit 500 statt einer
verwertbaren Fehlermeldung (siehe app/ssh_proxy/proxy.py). Jetzt sauber
auf 502 (Verbindung fehlgeschlagen) bzw. 400 (kein SSH-Host) gemappt."""
auf 502 (Verbindung fehlgeschlagen) bzw. 400 (kein SSH-Host) gemappt.
Bugfix 2: das (asyncssh.Error, OSError)-except in proxy.py deckte nicht
jede Art von Fehlschlag beim Key-Exchange ab (z.B. asyncio.TimeoutError
vor Python 3.11 -- kein OSError), sodass der 500er trotz obigem Fix
weiterhin auftrat. proxy.py faengt den externen Aufruf jetzt breiter ab;
zusaetzlich hier ein Catch-all als zweite Verteidigungslinie, damit ein
verbleibender unerwarteter Fehler (z.B. in load_host oder beim
Audit-Log-Schreiben) wenigstens mit vollem Traceback geloggt wird statt
als nackte 500 ohne jede Spur zu verschwinden -- analog zum
Exception-Catch-all in terminal_ws.py/ws_tunnel.py (Phase 9)."""
conn = get_db()
await _assert_host_in_scope(conn, _scope(admin), host_id)
try:
@ -946,6 +978,13 @@ async def discover_host_key(
status.HTTP_502_BAD_GATEWAY,
f"Host-Key konnte nicht ermittelt werden -- Ziel nicht erreichbar: {exc.reason}",
) from exc
except Exception as exc:
logger.exception("Unerwarteter Fehler bei Host-Key-Ermittlung fuer Host %s", host_id)
raise HTTPException(
status.HTTP_502_BAD_GATEWAY,
f"Host-Key konnte nicht ermittelt werden -- unerwarteter Fehler: "
f"{type(exc).__name__}: {exc}",
) from exc
await write_audit_event(
conn, event_type="host_key_discovered_trust_decision", user_id=admin.id,
client_ip=_client_ip(request), details={"host_id": host_id, "fingerprint": fingerprint},
@ -954,61 +993,61 @@ async def discover_host_key(
return {"host_id": host_id, "fingerprint": fingerprint}
@router.put("/hosts/{host_id}/rdp-credentials")
async def set_rdp_credentials(
host_id: int, payload: RdpCredentialsRequest, request: Request,
@router.post("/hosts/{host_id}/rdp-credentials/{credential_id}")
async def assign_rdp_credential_to_host(
host_id: int, credential_id: int, request: Request,
admin: CurrentUser = Depends(
require_admin_scope_or_host_role("hosts", "write", ("credentials_manage",))
require_admin_scope_or_host_role("rdp_credentials", "write", ("credentials_manage",))
),
):
"""Speichert/rotiert das RDP-Passwort fuer einen Host, verschluesselt mit
dem KEK (eigener AAD-Kontext, siehe app/security/crypto.py). Zugriff:
Admin/Mandanten-Admin/Token ODER ein Nicht-Admin mit Rolle
'credentials_manage' auf der Hostgruppe dieses Hosts (siehe RBAC-
Erweiterung 'Credentials ins RBAC-Modell')."""
"""Weist einem Host EIN bereits bestehendes RDP-Zugangsdaten-Objekt zu
(Migration 0012) -- ersetzt eine zuvor zugewiesene Zuordnung, falls
vorhanden (INSERT OR REPLACE, host_id ist Primaerschluessel der
Zuordnungstabelle). Das Anlegen des Zugangsdaten-Objekts selbst passiert
NICHT mehr hier, sondern ueber POST /admin/rdp-credentials (Reiter
"Zugangsdaten") -- exakt dieselbe Trennung wie bei SSH-Keys
(map_ssh_key_to_host)."""
conn = get_db()
if admin.is_any_admin:
await _assert_host_in_scope(conn, _scope(admin), host_id)
username = (payload.username or "").strip() or None
if username is None:
raise HTTPException(
status.HTTP_400_BAD_REQUEST,
"Bitte den Windows-Benutzernamen mit angeben -- er gehoert zu den "
"Zugangsdaten und wird fuer die Anmeldung am Ziel gebraucht.",
)
domain = (payload.domain or "").strip() or None
encrypted = encrypt_secret(payload.password.encode(), associated_data=b"rdp_password")
scope = _scope(admin)
await _assert_host_in_scope(conn, scope, host_id)
await _assert_rdp_credential_in_scope(conn, scope, credential_id)
else:
cred_row = await (
await conn.execute("SELECT 1 FROM rdp_credentials WHERE id = ?", (credential_id,))
).fetchone()
if cred_row is None:
raise HTTPException(status.HTTP_404_NOT_FOUND, "RDP-Zugangsdaten nicht gefunden")
await conn.execute(
"INSERT INTO rdp_credentials (host_id, password_enc, username, domain, updated_at) "
"VALUES (?, ?, ?, ?, strftime('%Y-%m-%dT%H:%M:%fZ','now')) "
"ON CONFLICT(host_id) DO UPDATE SET password_enc = excluded.password_enc, "
"username = excluded.username, domain = excluded.domain, "
"updated_at = excluded.updated_at",
(host_id, encrypted, username, domain),
"INSERT INTO host_rdp_credential_map (host_id, rdp_credential_id) VALUES (?, ?) "
"ON CONFLICT(host_id) DO UPDATE SET rdp_credential_id = excluded.rdp_credential_id",
(host_id, credential_id),
)
await write_audit_event(
conn, event_type="rdp_credentials_set", user_id=admin.id, client_ip=_client_ip(request),
# Benutzername/Domaene sind keine Geheimnisse (das Passwort schon) und
# gehoeren ins Audit-Log.
details={"host_id": host_id, "username": username, "domain": domain},
conn, event_type="rdp_credential_mapped", user_id=admin.id, client_ip=_client_ip(request),
details={"host_id": host_id, "rdp_credential_id": credential_id},
)
await conn.commit()
return {"status": "ok"}
@router.delete("/hosts/{host_id}/rdp-credentials")
async def delete_rdp_credentials(
async def unassign_rdp_credential_from_host(
host_id: int, request: Request,
admin: CurrentUser = Depends(
require_admin_scope_or_host_role("hosts", "write", ("credentials_manage",))
require_admin_scope_or_host_role("rdp_credentials", "write", ("credentials_manage",))
),
):
"""Entfernt NUR die Zuordnung zu diesem Host -- das Zugangsdaten-Objekt
selbst bleibt bestehen und kann weiterhin anderen Hosts zugewiesen sein
bzw. spaeter erneut zugewiesen werden. Loeschen des Objekts selbst:
DELETE /admin/rdp-credentials/{id}."""
conn = get_db()
if admin.is_any_admin:
await _assert_host_in_scope(conn, _scope(admin), host_id)
await conn.execute("DELETE FROM rdp_credentials WHERE host_id = ?", (host_id,))
await conn.execute("DELETE FROM host_rdp_credential_map WHERE host_id = ?", (host_id,))
await write_audit_event(
conn, event_type="rdp_credentials_deleted", user_id=admin.id, client_ip=_client_ip(request),
conn, event_type="rdp_credential_unmapped", user_id=admin.id, client_ip=_client_ip(request),
details={"host_id": host_id},
)
await conn.commit()
@ -1095,7 +1134,10 @@ async def get_host_credentials(
)
ssh_keys = [{"id": k[0], "label": k[1], "username": k[2]} for k in await keys_cursor.fetchall()]
rdp_row = await (await conn.execute(
"SELECT updated_at, username, domain FROM rdp_credentials WHERE host_id = ?", (host_id,)
"SELECT rc.id, rc.label, rc.username, rc.domain, COALESCE(rc.rotated_at, rc.created_at) "
"FROM host_rdp_credential_map m JOIN rdp_credentials rc ON rc.id = m.rdp_credential_id "
"WHERE m.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,)
@ -1103,40 +1145,150 @@ async def get_host_credentials(
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,
"rdp_credentials_id": rdp_row[0] if rdp_row else None,
"rdp_credentials_label": rdp_row[1] if rdp_row else None,
"rdp_credentials_username": rdp_row[2] if rdp_row else None,
"rdp_credentials_domain": rdp_row[3] if rdp_row else None,
"rdp_credentials_updated_at": rdp_row[4] 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,
}
@router.get("/rdp-credentials")
async def list_rdp_credentials(admin: CurrentUser = Depends(require_admin_or_scope("hosts", "read"))):
"""Uebersicht aller RDP/Windows-Hosts fuer den 'Zugangsdaten'-Tab: welche
haben bereits ein Passwort hinterlegt, wann zuletzt gesetzt."""
# --- RDP-Zugangsdatenverwaltung (Migration 0012) ------------------------------
#
# Eigenstaendige, wiederverwendbare Objekte -- strukturell und in der
# Endpunktaufteilung bewusst identisch zur SSH-Keyverwaltung weiter unten
# (create/list/update/delete + Zuordnung/Entfernung am Host), damit sich
# beide Zugangsdaten-Arten im Reiter "Zugangsdaten" gleich bedienen.
@router.post("/rdp-credentials", status_code=status.HTTP_201_CREATED)
async def create_rdp_credential(
payload: RdpCredentialCreateRequest, request: Request,
admin: CurrentUser = Depends(require_admin_or_scope("rdp_credentials", "write")),
):
conn = get_db()
scope = _scope(admin)
tenant_filter, params = scope.sql_filter("hg.tenant_id")
tenant_id = await _resolve_write_tenant(conn, scope, payload.tenant_id)
encrypted = encrypt_secret(payload.password.encode(), associated_data=b"rdp_password")
domain = (payload.domain or "").strip() or None
cursor = await conn.execute(
"SELECT h.id, h.hostname, h.address, hg.name, rc.updated_at, rc.username, rc.domain "
"FROM hosts h JOIN host_groups hg ON hg.id = h.host_group_id "
"LEFT JOIN rdp_credentials rc ON rc.host_id = h.id "
f"WHERE h.protocol = 'rdp'{tenant_filter} ORDER BY h.hostname",
"INSERT INTO rdp_credentials (label, username, domain, password_enc, tenant_id) "
"VALUES (?, ?, ?, ?, ?)",
(payload.label, payload.username.strip(), domain, encrypted, tenant_id),
)
new_id = cursor.lastrowid
await write_audit_event(
conn, event_type="rdp_credential_created", user_id=admin.id, client_ip=_client_ip(request),
details={"id": new_id, "label": payload.label, "username": payload.username, "domain": domain},
)
await conn.commit()
return {"id": new_id, "label": payload.label}
@router.get("/rdp-credentials")
async def list_rdp_credentials(admin: CurrentUser = Depends(require_admin_or_scope("rdp_credentials", "read"))):
"""Alle RDP-Zugangsdaten-Objekte fuer den 'Zugangsdaten'-Tab, inklusive
der Hosts, denen das jeweilige Objekt aktuell zugewiesen ist -- ein
Objekt kann mehreren Hosts zugewiesen sein (siehe Migration 0012)."""
conn = get_db()
scope = _scope(admin)
tenant_filter, params = scope.sql_filter("rc.tenant_id")
cursor = await conn.execute(
"SELECT rc.id, rc.label, rc.username, rc.domain, rc.tenant_id, t.name, "
"rc.created_at, rc.rotated_at "
"FROM rdp_credentials rc JOIN tenants t ON t.id = rc.tenant_id "
f"WHERE 1=1{tenant_filter} ORDER BY rc.id",
params,
)
rows = await cursor.fetchall()
hosts_cursor = await conn.execute(
"SELECT m.rdp_credential_id, h.id, h.hostname FROM host_rdp_credential_map m "
"JOIN hosts h ON h.id = m.host_id"
)
assigned: dict[int, list[dict]] = {}
for cred_id, host_id, hostname in await hosts_cursor.fetchall():
assigned.setdefault(cred_id, []).append({"id": host_id, "hostname": hostname})
return [
{
"host_id": r[0], "hostname": r[1], "address": r[2], "host_group_name": r[3],
"credentials_set": r[4] is not None, "updated_at": r[4],
"username": r[5], "domain": r[6],
"id": r[0], "label": r[1], "username": r[2], "domain": r[3],
"tenant_id": r[4], "tenant_name": r[5],
"created_at": r[6], "rotated_at": r[7],
"assigned_hosts": assigned.get(r[0], []),
}
for r in rows
]
async def _assert_rdp_credential_in_scope(conn, scope: TenantScope, credential_id: int) -> None:
row = await (
await conn.execute("SELECT tenant_id FROM rdp_credentials WHERE id = ?", (credential_id,))
).fetchone()
if row is None:
raise HTTPException(status.HTTP_404_NOT_FOUND, "RDP-Zugangsdaten nicht gefunden")
scope.check(row[0])
@router.put("/rdp-credentials/{credential_id}")
async def update_rdp_credential(
credential_id: int, payload: RdpCredentialUpdateRequest, request: Request,
admin: CurrentUser = Depends(require_admin_or_scope("rdp_credentials", "write")),
):
conn = get_db()
await _assert_rdp_credential_in_scope(conn, _scope(admin), credential_id)
fields, values = [], []
if payload.label is not None:
fields.append("label = ?"); values.append(payload.label)
if payload.username is not None:
fields.append("username = ?"); values.append(payload.username.strip())
if "domain" in payload.model_fields_set:
fields.append("domain = ?"); values.append((payload.domain or "").strip() or None)
rotating = payload.password is not None
if rotating:
fields += ["password_enc = ?", "rotated_at = strftime('%Y-%m-%dT%H:%M:%fZ','now')"]
values.append(encrypt_secret(payload.password.encode(), associated_data=b"rdp_password"))
if not fields:
return {"status": "ok", "changed": False}
values.append(credential_id)
await conn.execute(f"UPDATE rdp_credentials SET {', '.join(fields)} WHERE id = ?", values)
await write_audit_event(
conn, event_type="rdp_credential_updated", user_id=admin.id, client_ip=_client_ip(request),
details={
"id": credential_id, "rotated": rotating,
"label_changed": payload.label is not None,
"username_changed": payload.username is not None,
"domain_changed": "domain" in payload.model_fields_set,
},
)
await conn.commit()
return {"status": "ok", "changed": True}
@router.delete("/rdp-credentials/{credential_id}")
async def delete_rdp_credential(
credential_id: int, request: Request,
admin: CurrentUser = Depends(require_admin_or_scope("rdp_credentials", "write")),
):
"""host_rdp_credential_map verweist bewusst OHNE ON DELETE auf
rdp_credentials(id) -- Zuordnungen werden hier explizit mit entfernt
(samt Vermerk, welche Hosts betroffen waren), analog delete_ssh_key."""
conn = get_db()
await _assert_rdp_credential_in_scope(conn, _scope(admin), credential_id)
affected = await (await conn.execute(
"SELECT host_id FROM host_rdp_credential_map WHERE rdp_credential_id = ?", (credential_id,)
)).fetchall()
await conn.execute("DELETE FROM host_rdp_credential_map WHERE rdp_credential_id = ?", (credential_id,))
await conn.execute("DELETE FROM rdp_credentials WHERE id = ?", (credential_id,))
await write_audit_event(
conn, event_type="rdp_credential_deleted", user_id=admin.id, client_ip=_client_ip(request),
details={"id": credential_id, "unmapped_host_ids": [r[0] for r in affected]},
)
await conn.commit()
return {"status": "ok", "unmapped_host_ids": [r[0] for r in affected]}
# --- Rollenvergabe (an einzelne User) -----------------------------------------
async def _role_id(conn, role_name: str) -> int:

View File

@ -0,0 +1,63 @@
-- Migration 0012: RDP-Zugangsdaten als eigenstaendige, wiederverwendbare
-- Objekte -- analog zu ssh_keys/host_ssh_key_map, und aus demselben Grund:
-- bisher gab es GENAU einen RDP-Zugangsdatensatz pro Host
-- (rdp_credentials.host_id war PRIMARY KEY), angelegt direkt im
-- Serverformular ("Server" -> Host-Detail -> "RDP-Zugangsdaten"). Auf
-- ausdruecklichen Wunsch des Users: "RDP-Zugangsdaten auch unter
-- Zugangsdaten UND dem Server zuweisbar, nicht im Serverobjekt zu
-- erstellen" -- ein Zugangsdatensatz (Label/Benutzer/Domaene/Passwort)
-- laesst sich jetzt EINMAL im Reiter "Zugangsdaten" anlegen und danach
-- MEHREREN Hosts zuweisen, genau wie ein SSH-Key.
-- Alte 1:1-Tabelle bleibt als Datenquelle fuer die Uebernahme unten
-- erhalten, wird aber umbenannt, damit der Name "rdp_credentials" frei wird
-- fuer das neue, eigenstaendige Objekt (Konvention dieses Projekts: Altes
-- bleibt lesbar erhalten statt geloescht zu werden, siehe z.B.
-- hosts.ssh_username/rdp_username in Migration 0010).
ALTER TABLE rdp_credentials RENAME TO rdp_credentials_legacy;
CREATE TABLE IF NOT EXISTS rdp_credentials (
id INTEGER PRIMARY KEY,
label TEXT NOT NULL,
username TEXT NOT NULL,
domain TEXT,
password_enc BLOB NOT NULL,
tenant_id INTEGER NOT NULL REFERENCES tenants(id),
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')),
rotated_at TEXT
);
CREATE INDEX IF NOT EXISTS idx_rdp_credentials_tenant ON rdp_credentials(tenant_id);
-- Genau EIN zugewiesener Zugangsdatensatz je Host (PRIMARY KEY = host_id) --
-- aber derselbe Zugangsdatensatz darf in mehreren Zeilen (= mehreren Hosts)
-- auftauchen. Das ist die eigentliche Wiederverwendbarkeit, die diese
-- Migration einfuehrt.
CREATE TABLE IF NOT EXISTS host_rdp_credential_map (
host_id INTEGER PRIMARY KEY REFERENCES hosts(id) ON DELETE CASCADE,
rdp_credential_id INTEGER NOT NULL REFERENCES rdp_credentials(id)
);
-- Datenuebernahme: jeder bisherige 1:1-Datensatz wird zu einem eigenen
-- Zugangsdaten-Objekt (Label aus dem Hostnamen abgeleitet, da die alte
-- Tabelle keinen eigenen Namen kennt) und dem jeweiligen Host zugeordnet.
-- Die Korrelation zwischen den beiden folgenden INSERTs laeuft bewusst ueber
-- password_enc statt ueber eine temporaere ID-Spalte: jede Verschluesselung
-- verwendet einen frischen Zufalls-Nonce (siehe app/security/crypto.py),
-- zwei Zeilen der Alttabelle koennen also nie denselben password_enc-Wert
-- haben -- der Ruecksprung von rdp_credentials auf rdp_credentials_legacy
-- ist damit eindeutig.
INSERT INTO rdp_credentials (label, username, domain, password_enc, tenant_id, created_at)
SELECT 'Migriert: ' || h.hostname,
COALESCE(NULLIF(TRIM(rcl.username), ''), '(kein Benutzername)'),
rcl.domain,
rcl.password_enc,
COALESCE(hg.tenant_id, 1),
rcl.updated_at
FROM rdp_credentials_legacy rcl
JOIN hosts h ON h.id = rcl.host_id
LEFT JOIN host_groups hg ON hg.id = h.host_group_id;
INSERT INTO host_rdp_credential_map (host_id, rdp_credential_id)
SELECT rcl.host_id, rc.id
FROM rdp_credentials_legacy rcl
JOIN rdp_credentials rc ON rc.password_enc = rcl.password_enc;

View File

@ -255,12 +255,30 @@ class ConnectRequest(BaseModel):
host_id: int
class RdpCredentialsRequest(BaseModel):
password: str = Field(min_length=1, max_length=512)
# Benutzername und (optional) Domaene der Windows-Anmeldung. Seit
# Migration 0010 Teil der Zugangsdaten statt des Hosts.
username: str | None = Field(default=None, max_length=128)
class RdpCredentialCreateRequest(BaseModel):
"""Migration 0012: RDP-Zugangsdaten sind ein eigenstaendiges,
wiederverwendbares Objekt (analog SshKeyCreateRequest), das im Reiter
'Zugangsdaten' angelegt und danach beliebigen Hosts zugewiesen wird --
NICHT mehr direkt im Serverformular erzeugt."""
label: str = Field(min_length=1, max_length=128)
username: str = Field(min_length=1, max_length=128)
domain: str | None = Field(default=None, max_length=128)
password: str = Field(min_length=1, max_length=512)
tenant_id: int | None = None
class RdpCredentialUpdateRequest(BaseModel):
"""Label/Benutzername/Domaene sind jederzeit aenderbar. Eine Rotation des
Passworts ist bewusst ein SEPARATER Aufruf mit nur 'password' gesetzt
(siehe update_rdp_credential in admin/routes.py) -- analog zur
getrennten Passphrase-Aenderung bei SSH-Keys, damit ein Label-Update
nicht versehentlich das Passwort mitanfasst."""
label: str | None = Field(default=None, min_length=1, max_length=128)
username: str | None = Field(default=None, min_length=1, max_length=128)
# Wird nur beachtet, wenn das Feld im Request vorkommt: Weglassen laesst
# die Domaene unveraendert, explizites null/"" entfernt sie.
domain: str | None = Field(default=None, max_length=128)
password: str | None = Field(default=None, min_length=1, max_length=512)
class SshPasswordCredentialsRequest(BaseModel):

View File

@ -176,9 +176,13 @@ async def rdp_tunnel(
# username/domain stehen seit Migration 0010 bei den Zugangsdaten und nicht
# mehr am Host -- der Benutzername gehoert zur Anmeldung, nicht zur
# Beschreibung des Zielsystems.
# Beschreibung des Zielsystems. Seit Migration 0012 sind RDP-Zugangsdaten
# zudem ein eigenstaendiges, wiederverwendbares Objekt (analog SSH-Keys),
# das einem Host ueber host_rdp_credential_map zugewiesen wird, statt
# 1:1 am Host zu haengen.
cred_cursor = await conn.execute(
"SELECT password_enc, username, domain FROM rdp_credentials WHERE host_id = ?",
"SELECT rc.password_enc, rc.username, rc.domain FROM host_rdp_credential_map m "
"JOIN rdp_credentials rc ON rc.id = m.rdp_credential_id WHERE m.host_id = ?",
(host_id,),
)
cred_row = await cred_cursor.fetchone()

View File

@ -22,6 +22,11 @@ SCOPE_RESOURCES = (
"host_groups",
"hosts",
"ssh_keys",
# Migration 0012: RDP-Zugangsdaten sind jetzt eigenstaendige,
# wiederverwendbare Objekte (analog ssh_keys) statt eines 1:1-Datensatzes
# am Host -- verdienen deshalb aus demselben Grund wie ssh_keys einen
# eigenen Scope statt ueber "hosts" mitzulaufen.
"rdp_credentials",
"roles",
"user_groups",
"audit",

View File

@ -404,7 +404,14 @@ async def _verified_host_key(
observed_key = await asyncssh.get_server_host_key(
host["address"], port=host["port"], connect_timeout=10
)
except (asyncssh.Error, OSError) as exc:
except Exception as exc:
# Bewusst dieselbe Verbreiterung wie in discover_and_store_host_key():
# (asyncssh.Error, OSError) allein fing nicht jede Art von Fehlschlag
# dieses externen Aufrufs ab (z.B. asyncio.TimeoutError vor Python
# 3.11, kein OSError). Ein hier unbehandelter Fehler wuerde ungefiltert
# bis in terminal_ws.py/sftp.py durchreichen -- SSH_SETUP_ERRORS
# erkennt HostKeyDiscoveryError dort explizit und zeigt eine saubere
# deutsche Meldung statt eines generischen Sitzungsabbruchs.
raise HostKeyDiscoveryError(host["id"], str(exc)) from exc
if observed_key is None:
raise HostKeyDiscoveryError(host["id"], "Das Ziel hat keinen Host-Key gesendet")
@ -526,7 +533,22 @@ async def discover_and_store_host_key(
Jetzt wird asyncssh.get_server_host_key() verwendet: das fuehrt nur den
Key-Exchange durch, versucht gar keine Anmeldung und liefert den
Schluessel direkt zurueck. Ein Auth-Fehler kann die Ermittlung damit
nicht mehr verhindern."""
nicht mehr verhindern.
Bugfix 2 (weiterhin gemeldeter 500er nach obigem Fix): das
except-(asyncssh.Error, OSError) fing NICHT jede Art von Fehlschlag ab,
die asyncssh.get_server_host_key() tatsaechlich werfen kann -- reproduziert
mit einer eigenstaendigen Exception-Klasse, wie sie z.B. asyncio.TimeoutError
vor Python 3.11 war (Exception-Unterklasse, KEIN OSError) oder wie sie bei
einem Abbruch waehrend des Key-Exchange auftreten kann (z.B. ein ValueError
aus der Paketdekodierung). So ein Fehler ritt bislang unbehandelt durch bis
zu FastAPI -> HTTP 500 ohne jede Meldung, obwohl es sich in der Sache um
exakt denselben Fall handelt wie ein OSError (Ziel nicht erreichbar/
Protokollfehler waehrend der Ermittlung). Jetzt wird deshalb bewusst JEDE
Ausnahme aus diesem einzelnen, klar abgegrenzten externen Aufruf abgefangen
(asyncio.CancelledError ist als BaseException davon ohnehin nicht
betroffen -- dieselbe Ueberlegung wie beim Exception-Catch-all der
SSH/RDP-WS-Routen, siehe terminal_ws.py/ws_tunnel.py)."""
host = await load_host(conn, host_id)
if host["protocol"] != "ssh":
raise HostNotConfiguredError("Host-Key-Ermittlung ist nur fuer SSH-Ziele moeglich")
@ -535,7 +557,7 @@ async def discover_and_store_host_key(
key = await asyncssh.get_server_host_key(
host["address"], port=host["port"], connect_timeout=10
)
except (asyncssh.Error, OSError) as exc:
except Exception as exc:
logger.warning("Host-Key-Ermittlung fuer Host %s fehlgeschlagen: %s", host_id, exc)
raise HostKeyDiscoveryError(host_id, str(exc)) from exc
if key is None: