connect fix 3
This commit is contained in:
@ -44,6 +44,85 @@ class HostKeyDiscoveryError(Exception):
|
||||
super().__init__(f"Host-Key-Ermittlung fuer Host {host_id} fehlgeschlagen: {reason}")
|
||||
|
||||
|
||||
class PrivateKeyUnusableError(Exception):
|
||||
"""Der hinterlegte private Schluessel laesst sich nicht laden.
|
||||
|
||||
Haeufigster Fall (und der Grund, warum es diese Klasse gibt): der
|
||||
Schluessel ist passphrasegeschuetzt, aber es ist keine -- oder die
|
||||
falsche -- Passphrase hinterlegt. asyncssh wirft dann
|
||||
KeyImportError("Passphrase must be specified to import encrypted
|
||||
private keys"), was ein ValueError und damit KEIN asyncssh.Error ist --
|
||||
es rauschte deshalb an saemtlichen Fehlerbehandlungen der WS-Routen
|
||||
vorbei und die Sitzung brach ohne verwertbare Meldung ab. Diese
|
||||
Exception traegt stattdessen einen fuer den Benutzer verstaendlichen
|
||||
deutschen Text, den die WS-Routen direkt an den Client durchreichen.
|
||||
"""
|
||||
|
||||
def __init__(self, message: str) -> None:
|
||||
super().__init__(message)
|
||||
|
||||
|
||||
def import_private_key_material(
|
||||
pem: bytes | str, passphrase: bytes | str | None = None
|
||||
) -> asyncssh.SSHKey:
|
||||
"""Importiert Schluesselmaterial und uebersetzt asyncssh-Importfehler in
|
||||
eine PrivateKeyUnusableError mit klarer Ursachenbeschreibung.
|
||||
|
||||
Wird an ZWEI Stellen verwendet, bewusst mit identischer Semantik:
|
||||
* beim Verbindungsaufbau (load_private_key_for_host),
|
||||
* bereits beim Anlegen/Rotieren eines Schluessels im Adminbereich
|
||||
(app/admin/routes.py), damit ein unbrauchbarer Schluessel sofort
|
||||
mit HTTP 400 abgelehnt wird, statt erst beim ersten Verbindungs-
|
||||
versuch eines Benutzers aufzufallen.
|
||||
"""
|
||||
if isinstance(pem, str):
|
||||
pem = pem.encode()
|
||||
if isinstance(passphrase, str):
|
||||
passphrase = passphrase.encode()
|
||||
if passphrase == b"":
|
||||
passphrase = None
|
||||
|
||||
try:
|
||||
return asyncssh.import_private_key(pem, passphrase)
|
||||
except ValueError as exc:
|
||||
# Bewusst ValueError statt der konkreten asyncssh-Klassen:
|
||||
# KeyImportError, KeyEncryptionError und KeyGenerationError sind in
|
||||
# asyncssh allesamt ValueError-Unterklassen, ihre Namen und die
|
||||
# genaue Aufteilung unterscheiden sich aber zwischen asyncssh-
|
||||
# Versionen. Ein Zugriff auf einen in der installierten Version
|
||||
# nicht vorhandenen Klassennamen wuerde hier beim Import des Moduls
|
||||
# knallen -- diese Fassung ist gegen solche Versionsunterschiede
|
||||
# immun. asyncssh.Error (Protokollfehler) ist KEIN ValueError und
|
||||
# wird hier korrekterweise nicht mitgefangen.
|
||||
text = str(exc)
|
||||
|
||||
if "Passphrase must be specified" in text:
|
||||
raise PrivateKeyUnusableError(
|
||||
"Der private Schluessel ist passphrasegeschuetzt, es ist aber keine "
|
||||
"Passphrase hinterlegt. Die Passphrase im Adminbereich unter "
|
||||
"'SSH-Keys' nachtragen (Schluessel bearbeiten) oder einen "
|
||||
"unverschluesselten Schluessel hinterlegen."
|
||||
) from exc
|
||||
|
||||
if passphrase is not None:
|
||||
if "Incorrect passphrase" in text or "MAC" in text or "decrypt" in text.lower():
|
||||
raise PrivateKeyUnusableError(
|
||||
"Die hinterlegte Passphrase passt nicht zum privaten Schluessel."
|
||||
) from exc
|
||||
# Gegenprobe: manche asyncssh-Versionen quittieren eine Passphrase
|
||||
# zu einem UNverschluesselten Schluessel mit einem Importfehler.
|
||||
# In dem Fall ist nicht der Schluessel kaputt, sondern die
|
||||
# Passphrase ueberfluessig -- also ohne sie erneut versuchen.
|
||||
try:
|
||||
return asyncssh.import_private_key(pem, None)
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
raise PrivateKeyUnusableError(
|
||||
f"Der private Schluessel konnte nicht gelesen werden: {text}"
|
||||
) from exc
|
||||
|
||||
|
||||
class _PinnedHostKeyClient(asyncssh.SSHClient):
|
||||
"""Erzwingt Strict Host Key Checking gegen einen fest hinterlegten
|
||||
SHA-256-Fingerprint. Kein automatisches Trust-on-First-Use (TOFU)."""
|
||||
@ -67,7 +146,9 @@ class _PinnedHostKeyClient(asyncssh.SSHClient):
|
||||
async def load_host(conn: aiosqlite.Connection, host_id: int) -> dict:
|
||||
cursor = await conn.execute(
|
||||
"SELECT id, hostname, address, port, os_type, protocol, ssh_host_key_fingerprint, "
|
||||
"ssh_username, file_transfer_enabled, host_group_id FROM hosts WHERE id = ? AND is_active = 1",
|
||||
"ssh_username, file_transfer_enabled, host_group_id, "
|
||||
"rdp_username, rdp_domain, rdp_require_nla, clipboard_enabled, rdp_ignore_cert "
|
||||
"FROM hosts WHERE id = ? AND is_active = 1",
|
||||
(host_id,),
|
||||
)
|
||||
row = await cursor.fetchone()
|
||||
@ -76,13 +157,33 @@ async def load_host(conn: aiosqlite.Connection, host_id: int) -> dict:
|
||||
keys = (
|
||||
"id", "hostname", "address", "port", "os_type", "protocol",
|
||||
"ssh_host_key_fingerprint", "ssh_username", "file_transfer_enabled", "host_group_id",
|
||||
# Bugfix: die folgenden fuenf Spalten wurden bisher NICHT geladen,
|
||||
# obwohl build_rdp_params() (app/rdp_proxy/guacd_client.py) sie per
|
||||
# host.get(...) ausliest. Ergebnis war eine RDP-connect-Instruktion
|
||||
# ohne Benutzernamen und ohne Domaene -- das Zielsystem lehnt die
|
||||
# Anmeldung dann ab bzw. der Client haengt in "Warte auf Server ...".
|
||||
# Zusaetzlich war clipboard_enabled dadurch immer None (= Clipboard
|
||||
# dauerhaft gesperrt, unabhaengig von der Hostkonfiguration).
|
||||
"rdp_username", "rdp_domain", "rdp_require_nla", "clipboard_enabled",
|
||||
"rdp_ignore_cert",
|
||||
)
|
||||
return dict(zip(keys, row))
|
||||
host = dict(zip(keys, row))
|
||||
for flag in ("file_transfer_enabled", "rdp_require_nla", "clipboard_enabled", "rdp_ignore_cert"):
|
||||
host[flag] = bool(host[flag])
|
||||
return host
|
||||
|
||||
|
||||
async def load_private_key_for_host(conn: aiosqlite.Connection, host_id: int) -> asyncssh.SSHKey:
|
||||
"""Laedt den dem Host zugeordneten Schluessel und entschluesselt ihn.
|
||||
|
||||
Ist zu dem Schluessel eine Passphrase hinterlegt (ssh_keys.passphrase_enc,
|
||||
Migration 0009), wird sie ebenfalls entschluesselt und an asyncssh
|
||||
uebergeben. Ohne diesen Schritt scheiterte der Import jedes
|
||||
passphrasegeschuetzten Schluessels grundsaetzlich -- siehe
|
||||
PrivateKeyUnusableError.
|
||||
"""
|
||||
cursor = await conn.execute(
|
||||
"SELECT sk.private_key_enc FROM ssh_keys sk "
|
||||
"SELECT sk.private_key_enc, sk.passphrase_enc FROM ssh_keys sk "
|
||||
"JOIN host_ssh_key_map m ON m.ssh_key_id = sk.id "
|
||||
"WHERE m.host_id = ? LIMIT 1",
|
||||
(host_id,),
|
||||
@ -91,11 +192,16 @@ async def load_private_key_for_host(conn: aiosqlite.Connection, host_id: int) ->
|
||||
if row is None:
|
||||
raise HostNotConfiguredError(f"Kein SSH-Schluessel fuer Host {host_id} hinterlegt")
|
||||
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
|
||||
)
|
||||
try:
|
||||
return asyncssh.import_private_key(pem)
|
||||
return import_private_key_material(pem, passphrase)
|
||||
finally:
|
||||
# Bestpraxis: Referenz auf den Klartext-PEM-Bytes so schnell wie moeglich loslassen.
|
||||
# Bestpraxis: Referenzen auf das Klartextmaterial so schnell wie
|
||||
# moeglich loslassen (Konzept 6.4).
|
||||
del pem
|
||||
del passphrase
|
||||
|
||||
|
||||
async def connect_to_host(conn: aiosqlite.Connection, host_id: int) -> asyncssh.SSHClientConnection:
|
||||
|
||||
@ -6,6 +6,7 @@ Groessenlimit, Sha256-Hashing und optionaler AV-Scan sind Pflicht (Konzept
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, Request, UploadFile, status
|
||||
from fastapi.responses import StreamingResponse
|
||||
@ -15,8 +16,14 @@ from app.db import get_db
|
||||
from app.rbac import user_has_role_for_host
|
||||
from app.security.audit import write_audit_event
|
||||
from app.security.av_scan import scan_bytes
|
||||
from app.ssh_proxy.proxy import HostNotConfiguredError, connect_to_host, load_host
|
||||
from app.ssh_proxy.proxy import (
|
||||
HostNotConfiguredError,
|
||||
PrivateKeyUnusableError,
|
||||
connect_to_host,
|
||||
load_host,
|
||||
)
|
||||
|
||||
logger = logging.getLogger("jumphost.ssh_proxy.sftp")
|
||||
router = APIRouter(prefix="/ssh", tags=["file-transfer"])
|
||||
|
||||
MAX_UPLOAD_BYTES = 200 * 1024 * 1024 # 200 MiB, ueber Ansible-Variable konfigurierbar (siehe Konzept)
|
||||
@ -97,6 +104,13 @@ async def upload_file(
|
||||
ssh_conn.close()
|
||||
except HostNotConfiguredError as exc:
|
||||
raise HTTPException(status.HTTP_400_BAD_REQUEST, str(exc))
|
||||
except PrivateKeyUnusableError as exc:
|
||||
# Gleiche Ursache wie bei einer scheiternden Terminalsitzung (siehe
|
||||
# app/ssh_proxy/terminal_ws.py): passphrasegeschuetzter Schluessel
|
||||
# ohne hinterlegte Passphrase. Hier als 400 mit Klartext statt als
|
||||
# unbehandelter 500.
|
||||
logger.warning("Dateitransfer fuer Host %s nicht moeglich: %s", host_id, exc)
|
||||
raise HTTPException(status.HTTP_400_BAD_REQUEST, str(exc))
|
||||
|
||||
await _log_transfer(
|
||||
conn, user=user, host_id=host_id, client_ip=_client_ip(request), direction="upload",
|
||||
@ -127,6 +141,13 @@ async def download_file(
|
||||
ssh_conn.close()
|
||||
except HostNotConfiguredError as exc:
|
||||
raise HTTPException(status.HTTP_400_BAD_REQUEST, str(exc))
|
||||
except PrivateKeyUnusableError as exc:
|
||||
# Gleiche Ursache wie bei einer scheiternden Terminalsitzung (siehe
|
||||
# app/ssh_proxy/terminal_ws.py): passphrasegeschuetzter Schluessel
|
||||
# ohne hinterlegte Passphrase. Hier als 400 mit Klartext statt als
|
||||
# unbehandelter 500.
|
||||
logger.warning("Dateitransfer fuer Host %s nicht moeglich: %s", host_id, exc)
|
||||
raise HTTPException(status.HTTP_400_BAD_REQUEST, str(exc))
|
||||
|
||||
sha256 = hashlib.sha256(data).hexdigest()
|
||||
filename = remote_path.rsplit("/", 1)[-1]
|
||||
|
||||
@ -23,7 +23,12 @@ from app.rbac import user_has_role_for_host
|
||||
from app.recordings.recorder import SessionRecorder
|
||||
from app.security import active_sessions
|
||||
from app.security.audit import write_audit_event
|
||||
from app.ssh_proxy.proxy import HostNotConfiguredError, connect_to_host, load_host
|
||||
from app.ssh_proxy.proxy import (
|
||||
HostNotConfiguredError,
|
||||
PrivateKeyUnusableError,
|
||||
connect_to_host,
|
||||
load_host,
|
||||
)
|
||||
|
||||
logger = logging.getLogger("jumphost.ssh_proxy.ws")
|
||||
router = APIRouter()
|
||||
@ -50,6 +55,10 @@ async def _pump_ssh_to_ws(process: asyncssh.SSHClientProcess, websocket: WebSock
|
||||
async def ssh_terminal(websocket: WebSocket, host_id: int):
|
||||
user = await get_current_user_ws(websocket)
|
||||
if user is None:
|
||||
# Auch die Ablehnungen VOR dem Sitzungsbeginn gehoeren protokolliert:
|
||||
# bisher schloss dieser Pfad das Socket kommentarlos, im
|
||||
# Verbindungslog war der Fehlversuch dadurch unsichtbar.
|
||||
logger.warning("SSH-Verbindung abgelehnt: keine gueltige Sitzung (host_id=%s)", host_id)
|
||||
await websocket.close(code=4401)
|
||||
return
|
||||
|
||||
@ -57,6 +66,10 @@ 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 websocket.close(code=4403)
|
||||
return
|
||||
|
||||
@ -66,6 +79,7 @@ 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)
|
||||
await websocket.send_json({"type": "error", "message": str(exc)})
|
||||
await websocket.close(code=4404)
|
||||
return
|
||||
@ -125,8 +139,27 @@ async def ssh_terminal(websocket: WebSocket, host_id: int):
|
||||
# Best-Effort-Fehlermeldung an einen ggf. bereits getrennten Client;
|
||||
# der eigentliche Fehler ist bereits oben geloggt (logger.warning).
|
||||
logger.debug("Fehlermeldung konnte nicht mehr an Client gesendet werden", exc_info=True)
|
||||
except HostNotConfiguredError:
|
||||
except PrivateKeyUnusableError as exc:
|
||||
# Der haeufigste Grund, warum eine SSH-Sitzung nie zustande kam: der
|
||||
# hinterlegte Private Key ist passphrasegeschuetzt und die Passphrase
|
||||
# fehlt oder passt nicht. asyncssh wirft dafuer einen KeyImportError
|
||||
# (ein ValueError, KEIN asyncssh.Error), der frueher an allen
|
||||
# Handlern vorbei bis aus der Route hinauslief -- der Browser sah nur
|
||||
# ein wortloses Verbindungsende. Der Text ist bewusst konkret und
|
||||
# nennt die Stelle im Adminbereich, an der es zu beheben ist.
|
||||
logger.warning("SSH-Sitzung %s: Schluessel unbrauchbar: %s", session_id, exc)
|
||||
end_reason = "error"
|
||||
try:
|
||||
await websocket.send_json({"type": "error", "message": str(exc)})
|
||||
except Exception:
|
||||
logger.debug("Fehlermeldung konnte nicht mehr an Client gesendet werden", exc_info=True)
|
||||
except HostNotConfiguredError as exc:
|
||||
logger.warning("SSH-Sitzung %s abgebrochen: %s", session_id, exc)
|
||||
end_reason = "error"
|
||||
try:
|
||||
await websocket.send_json({"type": "error", "message": str(exc)})
|
||||
except Exception:
|
||||
logger.debug("Fehlermeldung konnte nicht mehr an Client gesendet werden", exc_info=True)
|
||||
except asyncio.CancelledError:
|
||||
# Zwangs-Beendigung durch einen Superadmin ueber die Sessionview
|
||||
# (POST /admin/sessions/{id}/terminate, siehe app/security/active_sessions.py).
|
||||
|
||||
Reference in New Issue
Block a user