connect fix 3

This commit is contained in:
2026-08-20 23:35:42 +02:00
parent fc32b1e4ef
commit bf2a02679d
14 changed files with 956 additions and 49 deletions

View File

@ -66,10 +66,16 @@ from app.security.api_tokens import (
validate_scopes,
)
from app.security.audit import verify_chain, write_audit_event
from app.security.crypto import encrypt_secret
from app.security.crypto import decrypt_secret, encrypt_secret
from app.security.passwords import hash_password
from app.recordings.recorder import verify_recording
from app.ssh_proxy.proxy import HostKeyDiscoveryError, HostNotConfiguredError, discover_and_store_host_key
from app.ssh_proxy.proxy import (
HostKeyDiscoveryError,
HostNotConfiguredError,
PrivateKeyUnusableError,
discover_and_store_host_key,
import_private_key_material,
)
from app.tenancy import TenantScope, resolve_host_group_tenant, resolve_host_tenant, tenant_user_ids
logger = logging.getLogger("jumphost.admin")
@ -725,15 +731,15 @@ async def create_host(
INSERT INTO hosts (
host_group_id, hostname, address, protocol, port, os_type,
ssh_host_key_fingerprint, ssh_username, rdp_username, rdp_domain,
rdp_require_nla, clipboard_enabled, file_transfer_enabled
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
rdp_require_nla, clipboard_enabled, file_transfer_enabled, rdp_ignore_cert
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""",
(
payload.host_group_id, payload.hostname, payload.address, payload.protocol,
payload.port, payload.os_type, payload.ssh_host_key_fingerprint,
payload.ssh_username, payload.rdp_username, payload.rdp_domain,
int(payload.rdp_require_nla), int(payload.clipboard_enabled),
int(payload.file_transfer_enabled),
int(payload.file_transfer_enabled), int(payload.rdp_ignore_cert),
),
)
new_id = cursor.lastrowid
@ -797,7 +803,7 @@ async def get_host_detail(
"SELECT h.id, h.hostname, h.address, h.protocol, h.port, h.os_type, h.host_group_id, "
"h.ssh_host_key_fingerprint, h.ssh_username, h.rdp_username, h.rdp_domain, "
"h.rdp_require_nla, h.clipboard_enabled, h.file_transfer_enabled, h.is_active, "
"hg.tenant_id, t.name "
"hg.tenant_id, t.name, h.rdp_ignore_cert "
"FROM hosts h JOIN host_groups hg ON hg.id = h.host_group_id JOIN tenants t ON t.id = hg.tenant_id "
"WHERE h.id = ?",
(host_id,),
@ -819,7 +825,8 @@ async def get_host_detail(
"ssh_username": row[8], "rdp_username": row[9], "rdp_domain": row[10],
"rdp_require_nla": bool(row[11]), "clipboard_enabled": bool(row[12]),
"file_transfer_enabled": bool(row[13]), "is_active": bool(row[14]),
"tenant_id": row[15], "tenant_name": row[16], "ssh_keys": ssh_keys,
"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,
}
@ -844,6 +851,7 @@ async def update_host(
bool_field_map = {
"rdp_require_nla": "rdp_require_nla", "clipboard_enabled": "clipboard_enabled",
"file_transfer_enabled": "file_transfer_enabled", "is_active": "is_active",
"rdp_ignore_cert": "rdp_ignore_cert",
}
fields, values = [], []
payload_dict = payload.model_dump(exclude_unset=True)
@ -1227,6 +1235,20 @@ async def list_role_names(admin: CurrentUser = Depends(require_admin_or_scope("r
# --- SSH-Keyverwaltung ---------------------------------------------------------
def _validate_private_key(pem: str, passphrase: str | None) -> None:
"""Prueft, ob asyncssh das Material mit der angegebenen Passphrase laden
kann, und uebersetzt einen Fehlschlag in HTTP 400 mit Klartextmeldung.
Der importierte Schluessel wird bewusst NICHT zurueckgegeben und nicht
weiterverwendet -- er dient nur der Pruefung und wird sofort wieder
verworfen (Konzept 6.4: Schluesselmaterial so kurz wie moeglich im
Prozessspeicher)."""
try:
import_private_key_material(pem, passphrase)
except PrivateKeyUnusableError as exc:
raise HTTPException(status.HTTP_400_BAD_REQUEST, str(exc)) from exc
@router.post("/ssh-keys", status_code=status.HTTP_201_CREATED)
async def create_ssh_key(
payload: SshKeyCreateRequest, request: Request,
@ -1238,16 +1260,31 @@ async def create_ssh_key(
conn = get_db()
scope = _scope(admin)
tenant_id = await _resolve_write_tenant(conn, scope, payload.tenant_id)
# Schluessel SOFORT gegen asyncssh pruefen (inkl. Passphrase), statt einen
# unbrauchbaren Schluessel entgegenzunehmen und den Fehler erst beim
# ersten Verbindungsversuch eines Benutzers auftauchen zu lassen -- genau
# so blieb ein passphrasegeschuetzter Schluessel bisher unbemerkt, bis
# die Sitzung im Betrieb abbrach.
_validate_private_key(payload.private_key_pem, payload.passphrase)
encrypted = encrypt_secret(payload.private_key_pem.encode(), associated_data=b"ssh_private_key")
passphrase_enc = (
encrypt_secret(payload.passphrase.encode(), associated_data=b"ssh_key_passphrase")
if payload.passphrase
else None
)
cursor = await conn.execute(
"INSERT INTO ssh_keys (label, owner_user_id, private_key_enc, public_key, key_type, tenant_id) "
"VALUES (?, ?, ?, ?, ?, ?)",
(payload.label, payload.owner_user_id, encrypted, payload.public_key, payload.key_type, tenant_id),
"INSERT INTO ssh_keys (label, owner_user_id, private_key_enc, public_key, key_type, "
"tenant_id, passphrase_enc) VALUES (?, ?, ?, ?, ?, ?, ?)",
(payload.label, payload.owner_user_id, encrypted, payload.public_key, payload.key_type,
tenant_id, passphrase_enc),
)
new_id = cursor.lastrowid
await write_audit_event(
conn, event_type="ssh_key_created", user_id=admin.id, client_ip=_client_ip(request),
details={"id": new_id, "label": payload.label, "key_type": payload.key_type},
details={
"id": new_id, "label": payload.label, "key_type": payload.key_type,
"has_passphrase": bool(payload.passphrase),
},
)
await conn.commit()
return {"id": new_id, "label": payload.label}
@ -1260,7 +1297,7 @@ async def list_ssh_keys(admin: CurrentUser = Depends(require_admin_or_scope("ssh
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, "
"sk.expires_at, sk.tenant_id, t.name "
"sk.expires_at, sk.tenant_id, t.name, (sk.passphrase_enc IS NOT NULL) "
"FROM ssh_keys sk JOIN tenants t ON t.id = sk.tenant_id "
f"WHERE 1=1{tenant_filter} ORDER BY sk.id",
params,
@ -1271,6 +1308,9 @@ async def list_ssh_keys(admin: CurrentUser = Depends(require_admin_or_scope("ssh
"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],
# Nur die Tatsache, NIE die Passphrase selbst -- kein Endpunkt
# dieser Anwendung gibt jemals Klartext-Geheimnisse zurueck.
"has_passphrase": bool(r[9]),
}
for r in rows
]
@ -1298,6 +1338,34 @@ async def update_ssh_key(
"Fuer eine Rotation muessen private_key_pem, public_key UND key_type gemeinsam angegeben werden",
)
# "passphrase" wird nur angefasst, wenn das Feld im Request tatsaechlich
# vorkommt -- ein Update von z.B. nur dem Label darf eine hinterlegte
# Passphrase nicht stillschweigend loeschen. Explizit uebergebenes null
# oder "" entfernt sie dagegen bewusst.
passphrase_given = "passphrase" in payload.model_fields_set
passphrase = payload.passphrase or None
if rotating:
# Neues Material immer sofort pruefen (inkl. der -- ggf. neuen --
# Passphrase), damit ein unbrauchbarer Schluessel gar nicht erst in
# die Datenbank kommt.
_validate_private_key(payload.private_key_pem, passphrase)
elif passphrase_given:
# Nur die Passphrase wird nachgetragen/geaendert: gegen das BEREITS
# gespeicherte Schluesselmaterial pruefen. Das ist der Weg, einen
# bereits hinterlegten, passphrasegeschuetzten Schluessel wieder
# benutzbar zu machen, ohne ihn neu hochzuladen.
row = await (
await conn.execute("SELECT private_key_enc FROM ssh_keys WHERE id = ?", (key_id,))
).fetchone()
if row is None:
raise HTTPException(status.HTTP_404_NOT_FOUND, "SSH-Key nicht gefunden")
stored_pem = decrypt_secret(row[0], associated_data=b"ssh_private_key")
try:
_validate_private_key(stored_pem.decode(), passphrase)
finally:
del stored_pem
fields, values = [], []
if payload.label is not None:
fields.append("label = ?"); values.append(payload.label)
@ -1308,13 +1376,27 @@ async def update_ssh_key(
fields += ["private_key_enc = ?", "public_key = ?", "key_type = ?",
"rotated_at = strftime('%Y-%m-%dT%H:%M:%fZ','now')"]
values += [encrypted, payload.public_key, payload.key_type]
if rotating or passphrase_given:
# Bei einer Rotation wird die Passphrase in JEDEM Fall neu gesetzt --
# eine zum alten Schluessel gehoerende Passphrase darf nicht am neuen
# Schluessel haengenbleiben.
fields.append("passphrase_enc = ?")
values.append(
encrypt_secret(passphrase.encode(), associated_data=b"ssh_key_passphrase")
if passphrase
else None
)
if not fields:
return {"status": "ok", "changed": False}
values.append(key_id)
await conn.execute(f"UPDATE ssh_keys SET {', '.join(fields)} WHERE id = ?", values)
await write_audit_event(
conn, event_type="ssh_key_updated", user_id=admin.id, client_ip=_client_ip(request),
details={"id": key_id, "rotated": rotating},
details={
"id": key_id, "rotated": rotating,
"passphrase_changed": bool(rotating or passphrase_given),
"has_passphrase": bool(passphrase),
},
)
await conn.commit()
return {"status": "ok", "changed": True}

View File

@ -0,0 +1,27 @@
-- Zwei Ursachen fuer nicht zustande kommende Sitzungen, siehe
-- app/ssh_proxy/proxy.py bzw. app/rdp_proxy/guacd_client.py.
--
-- 1) ssh_keys.passphrase_enc
-- asyncssh.import_private_key() wurde bisher IMMER ohne Passphrase
-- aufgerufen. Ein passphrasegeschuetzter Private Key (der Normalfall, wenn
-- ein Admin einen bestehenden Schluessel aus ~/.ssh einfuegt) laesst sich so
-- prinzipiell nicht laden -- jeder Verbindungsversuch endete in
-- asyncssh.public_key.KeyImportError:
-- "Passphrase must be specified to import encrypted private keys"
-- und damit in einer sofort wieder beendeten Sitzung. Die Passphrase wird
-- deshalb ab jetzt beim Anlegen/Rotieren des Schluessels mit erfasst und --
-- exakt wie das Schluesselmaterial selbst und die RDP-Passwoerter -- mit dem
-- KEK (AES-256-GCM, app/security/crypto.py, associated_data=b"ssh_key_passphrase")
-- verschluesselt abgelegt. NULL = Schluessel ist unverschluesselt (bisheriges
-- Verhalten, bleibt unveraendert gueltig).
ALTER TABLE ssh_keys ADD COLUMN passphrase_enc BLOB;
-- 2) hosts.rdp_ignore_cert
-- build_rdp_params() setzte "ignore-cert" hart auf "false". Windows-Ziele
-- praesentieren im Regelfall ein selbstsigniertes RDP-Zertifikat; guacd/
-- FreeRDP bricht den Verbindungsaufbau dann ab, bevor ueberhaupt ein Bild
-- entsteht. Die Entscheidung gehoert pro Host getroffen und nicht ins
-- Codeliteral: Default 1 (ignorieren, entspricht dem Verhalten jedes
-- gaengigen RDP-Clients); wer eine interne PKI betreibt, setzt den Wert pro
-- Host auf 0 und erzwingt damit die Zertifikatspruefung.
ALTER TABLE hosts ADD COLUMN rdp_ignore_cert INTEGER NOT NULL DEFAULT 1;

View File

@ -120,6 +120,11 @@ class HostCreateRequest(BaseModel):
rdp_require_nla: bool = True
clipboard_enabled: bool = True
file_transfer_enabled: bool = True
# Standard: selbstsigniertes RDP-Zertifikat des Ziels akzeptieren. Windows
# praesentiert ohne interne PKI genau so eines; guacd/FreeRDP bricht sonst
# vor dem ersten Bild ab. Wer eine PKI betreibt, setzt den Wert pro Host
# auf False und erzwingt damit die Pruefung.
rdp_ignore_cert: bool = True
@field_validator("hostname")
@classmethod
@ -140,6 +145,7 @@ class HostUpdateRequest(BaseModel):
rdp_require_nla: bool | None = None
clipboard_enabled: bool | None = None
file_transfer_enabled: bool | None = None
rdp_ignore_cert: bool | None = None
is_active: bool | None = None
@field_validator("hostname")
@ -208,6 +214,11 @@ class SshKeyCreateRequest(BaseModel):
public_key: str = Field(min_length=1, max_length=8192)
key_type: Literal["ed25519", "rsa-3072", "rsa-4096", "ca-cert"]
tenant_id: int | None = None
# Passphrase eines verschluesselten Private Keys. Wird -- wie das
# Schluesselmaterial selbst -- mit dem KEK verschluesselt gespeichert
# (ssh_keys.passphrase_enc, Migration 0009) und beim Verbindungsaufbau an
# asyncssh uebergeben. None/"" = Schluessel ist unverschluesselt.
passphrase: str | None = Field(default=None, max_length=1024)
class SshKeyUpdateRequest(BaseModel):
@ -219,6 +230,12 @@ class SshKeyUpdateRequest(BaseModel):
private_key_pem: str | None = Field(default=None, min_length=1, max_length=32_768)
public_key: str | None = Field(default=None, min_length=1, max_length=8192)
key_type: Literal["ed25519", "rsa-3072", "rsa-4096", "ca-cert"] | None = None
# Wird nur beachtet, wenn das Feld im Request vorkommt (siehe
# update_ssh_key in app/admin/routes.py): explizites null/"" entfernt eine
# hinterlegte Passphrase, Weglassen laesst sie unveraendert. Ohne
# gleichzeitige Rotation wird die neue Passphrase gegen das bereits
# gespeicherte Schluesselmaterial geprueft.
passphrase: str | None = Field(default=None, max_length=1024)
class ConnectRequest(BaseModel):

View File

@ -16,6 +16,7 @@ sollte vor Produktivbetrieb dennoch gegen die Ziel-guacd-Version getestet werden
from __future__ import annotations
import asyncio
import codecs
import logging
logger = logging.getLogger("jumphost.rdp_proxy.guacd")
@ -26,11 +27,18 @@ class GuacamoleProtocolError(Exception):
def encode_instruction(*elements: str) -> str:
parts = []
for element in elements:
encoded = element.encode("utf-8")
parts.append(f"{len(encoded)}.{element}")
return ",".join(parts) + ";"
"""Kodiert eine Guacamole-Instruktion.
Die Laengenangabe zaehlt ZEICHEN, nicht Bytes -- so ist das Guacamole-
Protokoll definiert und so zaehlen sowohl guacd als auch
guacamole-common-js. Hier stand frueher die Laenge der UTF-8-Bytes:
fuer reines ASCII identisch, bei jedem Zeichen ausserhalb davon aber zu
gross. Ein einziger Umlaut -- etwa in einem RDP-Passwort, einem
Benutzernamen oder einem Text aus der Zwischenablage -- verschob damit
den gesamten nachfolgenden Datenstrom, und guacd brach die Verbindung
unmittelbar nach der connect-Instruktion wieder ab.
"""
return ",".join(f"{len(element)}.{element}" for element in elements) + ";"
async def _read_until(reader: asyncio.StreamReader, delimiter: bytes) -> bytes:
@ -42,6 +50,21 @@ async def _read_until(reader: asyncio.StreamReader, delimiter: bytes) -> bytes:
buf += b
async def _read_chars(reader: asyncio.StreamReader, count: int) -> str:
"""Liest genau `count` ZEICHEN (nicht Bytes) UTF-8-kodiert vom Stream.
Gegenstueck zur Laengenzaehlung in encode_instruction(): ein
`readexactly(count)` haette bei Mehrbyte-Zeichen zu wenig Bytes gelesen
und den Parser mitten in einem Zeichen stehen lassen."""
decoder = codecs.getincrementaldecoder("utf-8")()
chars: list[str] = []
while len(chars) < count:
chunk = decoder.decode(await reader.readexactly(1))
if chunk:
chars.extend(chunk)
return "".join(chars)
async def read_instruction(reader: asyncio.StreamReader) -> list[str]:
elements: list[str] = []
while True:
@ -50,7 +73,7 @@ async def read_instruction(reader: asyncio.StreamReader) -> list[str]:
length = int(length_bytes)
except ValueError as exc:
raise GuacamoleProtocolError(f"Ungueltige Laengenangabe: {length_bytes!r}") from exc
content = (await reader.readexactly(length)).decode("utf-8")
content = await _read_chars(reader, length)
elements.append(content)
sep = await reader.readexactly(1)
if sep == b";":
@ -143,24 +166,50 @@ def build_rdp_params(host: dict, password: str) -> dict[str, str]:
"""Baut die Parameter-Map fuer die connect-Instruktion aus dem Host-Datensatz.
Sicherheitsdefaults (siehe Konzept 6.3/6.7): NLA wird erzwungen sofern
rdp_require_nla gesetzt ist (Standard), Zertifikatspruefung ist standardmaessig
AKTIV (ignore-cert=false) -- bei selbstsignierten Zertifikaten auf den
Zielsystemen muss dies bewusst pro Host ueberschrieben werden, kein stiller
Bypass.
rdp_require_nla gesetzt ist (Standard). Die Zertifikatspruefung ist pro Host
ueber hosts.rdp_ignore_cert schaltbar (Migration 0009, Default: ignorieren) --
ohne interne PKI praesentiert jedes Windows-Ziel ein selbstsigniertes
Zertifikat, guacd/FreeRDP bricht dann bereits vor dem ersten Bild ab.
WICHTIG: `host` muss aus app.ssh_proxy.proxy.load_host() stammen. Diese
Funktion liest rdp_username/rdp_domain/rdp_require_nla/clipboard_enabled/
rdp_ignore_cert -- genau diese Spalten hat load_host() frueher NICHT
selektiert, wodurch hier still ein leerer Benutzername in die
connect-Instruktion ging und die Anmeldung am Zielsystem zwangslaeufig
scheiterte. Fehlt eine der Spalten jetzt trotzdem, ist das ein
Programmierfehler und soll auffallen statt still zu einer kaputten
Verbindung zu fuehren.
"""
missing = [
key for key in ("rdp_username", "rdp_require_nla", "clipboard_enabled", "rdp_ignore_cert")
if key not in host
]
if missing:
raise GuacamoleProtocolError(
f"Hostdatensatz unvollstaendig, fehlende Felder: {', '.join(missing)}"
)
username = (host.get("rdp_username") or "").strip()
if not username:
raise GuacamoleProtocolError(
"Fuer diesen Host ist kein RDP-Benutzername hinterlegt "
"(Adminbereich -> Server -> RDP-Benutzer)."
)
clipboard_enabled = bool(host.get("clipboard_enabled"))
return {
"hostname": host["address"],
"port": str(host["port"]),
"username": host.get("rdp_username") or "",
"username": username,
"password": password,
"domain": host.get("rdp_domain") or "",
"security": "nla" if host.get("rdp_require_nla", True) else "any",
"ignore-cert": "false",
"ignore-cert": "true" if host.get("rdp_ignore_cert", True) else "false",
"disable-audio": "true",
"enable-drive": "true" if host.get("file_transfer_enabled") else "false",
"drive-path": f"/var/lib/jumphost/rdp-drives/{host['id']}",
"create-drive-path": "true",
"disable-copy": "false" if host.get("clipboard_enabled") else "true",
"disable-paste": "false" if host.get("clipboard_enabled") else "true",
"disable-copy": "false" if clipboard_enabled else "true",
"disable-paste": "false" if clipboard_enabled else "true",
"resize-method": "display-update",
}

View File

@ -11,6 +11,7 @@ from __future__ import annotations
import asyncio
import logging
import uuid as uuid_mod
from fastapi import APIRouter, Query, WebSocket, WebSocketDisconnect
@ -35,6 +36,50 @@ from app.ssh_proxy.proxy import HostNotConfiguredError, load_host
logger = logging.getLogger("jumphost.rdp_proxy.ws")
router = APIRouter()
# Leerer Opcode = tunnelinterne Instruktion des Guacamole-Protokolls
# (Guacamole.Tunnel.INTERNAL_DATA_OPCODE in guacamole-common-js): Tunnel-UUID
# beim Verbindungsaufbau und ping/pong waehrend der Sitzung.
INTERNAL_DATA_OPCODE = ""
# Das vom Browser angeforderte WebSocket-Subprotokoll. guacamole-common-js
# oeffnet den Socket IMMER als `new WebSocket(url, "guacamole")`. Bestaetigt
# der Server das Subprotokoll nicht im Handshake, verwirft der Browser die
# Verbindung nach RFC 6455 sofort wieder -- die Sitzung kam dadurch nie
# zustande und es erreichte kein einziger Frame die Anwendung.
GUACAMOLE_SUBPROTOCOL = "guacamole"
async def _accept(websocket: WebSocket) -> None:
"""Nimmt die Verbindung an und bestaetigt dabei das Subprotokoll, sofern
der Client eines angeboten hat (siehe GUACAMOLE_SUBPROTOCOL)."""
offered = websocket.scope.get("subprotocols") or []
if GUACAMOLE_SUBPROTOCOL in offered:
await websocket.accept(subprotocol=GUACAMOLE_SUBPROTOCOL)
else:
await websocket.accept()
async def _reject(websocket: WebSocket, code: int, reason: str, *, accepted: bool) -> None:
"""Beendet eine Sitzung vor ihrem eigentlichen Beginn -- mit Logeintrag
und mit einem fuer den Benutzer lesbaren Grund.
Bisher endeten alle diese Pfade in einem nackten `websocket.close(code=...)`
ohne jede Protokollierung. Im Verbindungslog war deshalb ueberhaupt nichts
zu sehen, wenn eine RDP-Sitzung an einer dieser Vorbedingungen scheiterte.
Der Grundtext wird als WebSocket-Close-Reason mitgegeben:
guacamole-common-js reicht ihn (siehe close_tunnel()) als
Guacamole.Status.message an client.onerror weiter, wo static/js/rdp.js ihn
direkt anzeigt. Voraussetzung dafuer ist ein zustande gekommener
Handshake -- vor `accept()` sieht der Browser nur einen HTTP-Fehler.
"""
logger.warning("RDP-Verbindung abgelehnt (code=%s): %s", code, reason)
# Close-Reason ist auf 123 Byte begrenzt (RFC 6455).
reason_bytes = reason.encode("utf-8")[:123]
if not accepted:
await _accept(websocket)
await websocket.close(code=code, reason=reason_bytes.decode("utf-8", errors="ignore"))
async def _guacd_to_ws(tunnel, websocket: WebSocket, recorder: SessionRecorder) -> None:
while True:
@ -49,9 +94,22 @@ async def _ws_to_guacd(tunnel, websocket: WebSocket, recorder: SessionRecorder,
message = await websocket.receive_text()
try:
instr = parse_instruction_text(message)
except GuacamoleProtocolError:
except (GuacamoleProtocolError, ValueError, IndexError):
continue # ungueltige Clientnachricht ignorieren statt die Verbindung zu killen
if instr and instr[0] == INTERNAL_DATA_OPCODE:
# Tunnelinterne Instruktion von guacamole-common-js (leerer
# Opcode), z.B. "0.,4.ping,13.<timestamp>;". Diese gehoert dem
# Tunnel, NICHT guacd -- bisher wurde sie unbesehen an guacd
# weitergereicht, das damit nichts anfangen kann. Ein ping wird
# gespiegelt: der Browser bricht den Tunnel nach
# tunnel.receiveTimeout (Standard 15 s) ohne empfangene Daten mit
# "Server timeout" ab, eine stille Sitzung liefe also in einen
# Fehler.
if len(instr) > 1 and instr[1] == "ping":
await websocket.send_text(encode_instruction(*instr))
continue
if not clipboard_enabled and instr and instr[0] == "clipboard":
continue # Defense-in-Depth: Clipboard serverseitig blocken
@ -70,27 +128,50 @@ async def rdp_tunnel(
):
user = await get_current_user_ws(websocket)
if user is None:
# Einziger Pfad, der bewusst OHNE vorheriges accept() schliesst: eine
# nicht authentifizierte Verbindung soll gar nicht erst zustande
# kommen. Alle folgenden Ablehnungen laufen ueber _reject(), damit der
# Benutzer im Browser den tatsaechlichen Grund zu sehen bekommt.
logger.warning("RDP-Verbindung abgelehnt: keine gueltige Sitzung (host_id=%s)", host_id)
await websocket.close(code=4401)
return
conn = get_db()
client_ip = websocket.client.host if websocket.client else "unknown"
if not user.is_admin and not await user_has_role_for_host(
conn, user_id=user.id, host_id=host_id, role_name="rdp_connect"
):
await websocket.close(code=4403)
await _reject(
websocket, 4403,
f"Keine Berechtigung 'rdp_connect' fuer Host {host_id}",
accepted=False,
)
return
await websocket.accept()
client_ip = websocket.client.host if websocket.client else "unknown"
await _accept(websocket)
# Tunnel-UUID als erste Instruktion senden (leerer Opcode, siehe
# INTERNAL_DATA_OPCODE). guacamole-common-js setzt den Tunnel erst beim
# Empfang der ersten Instruktion auf OPEN und startet damit seine
# Timeoutueberwachung neu -- ohne diesen Frame blieb der Client bis zum
# ersten Bild von guacd in "Warte auf Server ..." haengen und lief bei
# einem langsamen RDP-Handshake in den 15-Sekunden-Timeout.
tunnel_uuid = str(uuid_mod.uuid4())
await websocket.send_text(encode_instruction(INTERNAL_DATA_OPCODE, tunnel_uuid))
try:
host = await load_host(conn, host_id)
except HostNotConfiguredError as exc:
await websocket.close(code=4404)
await _reject(websocket, 4404, str(exc), accepted=True)
return
if host["protocol"] != "rdp":
await websocket.close(code=4400)
await _reject(
websocket, 4400,
f"Host {host['hostname']} ist kein RDP-Ziel (protocol={host['protocol']})",
accepted=True,
)
return
cred_cursor = await conn.execute(
@ -98,7 +179,12 @@ async def rdp_tunnel(
)
cred_row = await cred_cursor.fetchone()
if cred_row is None:
await websocket.close(code=4404)
await _reject(
websocket, 4404,
f"Fuer Host {host['hostname']} ist kein RDP-Passwort hinterlegt "
"(Adminbereich -> Zugangsdaten).",
accepted=True,
)
return
cursor = await conn.execute(
@ -122,6 +208,7 @@ async def rdp_tunnel(
active_sessions.register(session_id, asyncio.current_task())
end_reason = "logout"
error_text: str | None = None
tunnel = None
tasks: list[asyncio.Task] = []
password = None
@ -159,6 +246,11 @@ async def rdp_tunnel(
except (GuacamoleProtocolError, ConnectionError, OSError) as exc:
logger.warning("RDP-Sessionfehler (session_id=%s): %s", session_id, exc)
end_reason = "error"
# Grund an den Browser durchreichen (guacamole-common-js zeigt die
# Close-Reason als Guacamole.Status.message an, siehe _reject).
# Betrifft u.a. den haeufigsten Konfigurationsfehler: kein
# RDP-Benutzername am Host, siehe build_rdp_params().
error_text = str(exc) or exc.__class__.__name__
except asyncio.CancelledError:
# Zwangs-Beendigung durch einen Superadmin ueber die Sessionview
# (POST /admin/sessions/{id}/terminate, siehe app/security/active_sessions.py).
@ -172,6 +264,7 @@ async def rdp_tunnel(
# jede Fehlermeldung/Protokollierung sofort beendete.
logger.exception("Unerwarteter Fehler in RDP-Sitzung %s: %s", session_id, exc)
end_reason = "error"
error_text = f"Interner Fehler: {exc.__class__.__name__}"
finally:
active_sessions.unregister(session_id)
del password # Klartext-Passwort so schnell wie moeglich freigeben
@ -192,7 +285,12 @@ async def rdp_tunnel(
)
await conn.commit()
try:
await websocket.close()
if error_text:
await websocket.close(
code=4500, reason=error_text.encode("utf-8")[:123].decode("utf-8", errors="ignore")
)
else:
await websocket.close()
except Exception:
# Cleanup-Pfad: der Session-Datensatz und Audit-Log-Eintrag sind
# zu diesem Zeitpunkt bereits geschrieben; ein bereits vom Client

View File

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

View File

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

View File

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