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

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

View File

@ -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(