more stuff
This commit is contained in:
@ -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