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)
|
||||
username = resolve_ssh_username(host, credential_username)
|
||||
|
||||
# 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(
|
||||
|
||||
Reference in New Issue
Block a user