connect fix 3
This commit is contained in:
78
README.md
78
README.md
@ -252,13 +252,89 @@ Mandanten-Admin sein zu muessen (neue Dependency
|
|||||||
`GET /admin/hosts/{id}/credentials`, Dashboard zeigt einen "Zugangsdaten"-
|
`GET /admin/hosts/{id}/credentials`, Dashboard zeigt einen "Zugangsdaten"-
|
||||||
Knopf bei Hosts mit dieser Rolle).
|
Knopf bei Hosts mit dieser Rolle).
|
||||||
|
|
||||||
|
## Phase 10: SSH-Key-Passphrasen und funktionsfaehige RDP-Sitzungen
|
||||||
|
|
||||||
|
Behebt die Ursachen dafuer, dass weder SSH- noch RDP-Sitzungen zustande kamen.
|
||||||
|
|
||||||
|
**SSH -- passphrasegeschuetzte Schluessel (Migration 0009,
|
||||||
|
`ssh_keys.passphrase_enc`).** `asyncssh.import_private_key()` wurde ohne
|
||||||
|
Passphrase aufgerufen; ein mit `ssh-keygen` ueblich erzeugter, verschluesselter
|
||||||
|
Schluessel liess sich damit prinzipiell nicht laden
|
||||||
|
(`KeyImportError: Passphrase must be specified to import encrypted private
|
||||||
|
keys`). Weil dieser Fehler ein `ValueError` und *kein* `asyncssh.Error` ist,
|
||||||
|
lief er an allen Fehlerbehandlungen der WebSocket-Route vorbei -- der Browser
|
||||||
|
sah nur ein wortloses Verbindungsende. Neu:
|
||||||
|
|
||||||
|
* Beim Anlegen und Rotieren eines Schluessels kann eine Passphrase angegeben
|
||||||
|
werden. Sie wird wie das Schluesselmaterial selbst mit dem KEK
|
||||||
|
(AES-256-GCM) verschluesselt gespeichert und ueber keinen Endpunkt jemals
|
||||||
|
zurueckgegeben -- die Liste zeigt nur `has_passphrase`.
|
||||||
|
* Zu einem **bereits hinterlegten** Schluessel laesst sich die Passphrase
|
||||||
|
nachtragen (Adminbereich -> Zugangsdaten -> SSH-Key bearbeiten). Sie wird
|
||||||
|
dabei sofort gegen das gespeicherte Schluesselmaterial geprueft; passt sie
|
||||||
|
nicht, wird nichts gespeichert.
|
||||||
|
* Jeder Upload wird sofort gegen asyncssh validiert: ein unbrauchbarer
|
||||||
|
Schluessel wird mit HTTP 400 und Klartextbegruendung abgelehnt, statt erst
|
||||||
|
beim ersten Verbindungsversuch eines Benutzers aufzufallen.
|
||||||
|
* Scheitert das Laden trotzdem, meldet die Sitzung jetzt `PrivateKeyUnusableError`
|
||||||
|
mit verstaendlichem Text an Terminal und Dateitransfer statt kommentarlos
|
||||||
|
abzubrechen.
|
||||||
|
|
||||||
|
**RDP -- vier unabhaengige Fehler, die jede Sitzung verhinderten.**
|
||||||
|
|
||||||
|
1. `static/js/rdp.js` haengte die Verbindungsparameter an die Tunnel-URL an --
|
||||||
|
`Guacamole.WebSocketTunnel.connect(data)` haengt aber selbst noch
|
||||||
|
`"?" + data` an. Daraus wurde `...&dpi=96?undefined`; FastAPI wies den
|
||||||
|
WebSocket wegen der ungueltigen Query noch vor dem Routenhandler ab, und
|
||||||
|
im Verbindungslog erschien **kein einziger Eintrag**. Die Parameter werden
|
||||||
|
jetzt an `connect()` uebergeben.
|
||||||
|
2. guacamole-common-js oeffnet den Socket immer mit dem Subprotokoll
|
||||||
|
`guacamole`. Der Server bestaetigte es nicht, woraufhin der Browser die
|
||||||
|
Verbindung nach RFC 6455 sofort wieder verwarf. `websocket.accept()` setzt
|
||||||
|
es jetzt.
|
||||||
|
3. `load_host()` selektierte `rdp_username`, `rdp_domain`, `rdp_require_nla`
|
||||||
|
und `clipboard_enabled` nicht -- `build_rdp_params()` baute daraus eine
|
||||||
|
`connect`-Instruktion **ohne Benutzernamen** (Anmeldung am Ziel scheitert,
|
||||||
|
der Client haengt in "Warte auf Server ..."), und die Zwischenablage war
|
||||||
|
unabhaengig von der Hostkonfiguration immer gesperrt. Fehlt jetzt eine der
|
||||||
|
Angaben, meldet der Server das im Klartext statt still eine kaputte
|
||||||
|
Verbindung aufzubauen.
|
||||||
|
4. `ignore-cert` war hart auf `false` verdrahtet. Windows-Ziele ohne eigene
|
||||||
|
PKI weisen sich mit einem selbstsignierten Zertifikat aus, guacd/FreeRDP
|
||||||
|
bricht dann vor dem ersten Bild ab. Neu: `hosts.rdp_ignore_cert`
|
||||||
|
(Migration 0009, Default: ignorieren) mit Checkbox im Host-Formular.
|
||||||
|
|
||||||
|
Ausserdem im Guacamole-Protokoll behoben: die Laengenangaben zaehlen
|
||||||
|
**Zeichen**, nicht Bytes (so ist das Protokoll definiert, und so zaehlen guacd
|
||||||
|
und guacamole-common-js). Zuvor stand dort die UTF-8-Bytelaenge -- ein
|
||||||
|
einziger Umlaut, etwa in einem RDP-Passwort oder in der Zwischenablage,
|
||||||
|
verschob den gesamten nachfolgenden Datenstrom. Tunnelinterne Instruktionen
|
||||||
|
(leerer Opcode: `ping`, Tunnel-UUID) werden nicht mehr an guacd
|
||||||
|
durchgereicht, ein `ping` wird gespiegelt, und der Server sendet die
|
||||||
|
Tunnel-UUID als erste Instruktion -- sonst blieb der Client bis zum ersten
|
||||||
|
Bild in "Warte auf Server ..." und lief bei einem langsamen RDP-Handshake in
|
||||||
|
den 15-Sekunden-Timeout.
|
||||||
|
|
||||||
|
**Sichtbarkeit.** Saemtliche Abbruchpfade *vor* dem eigentlichen
|
||||||
|
Sitzungsbeginn (fehlende Berechtigung, unbekannter Host, falsches Protokoll,
|
||||||
|
fehlendes RDP-Passwort) schlossen den Socket bisher kommentarlos. Sie
|
||||||
|
protokollieren jetzt und geben den Grund als WebSocket-Close-Reason mit, den
|
||||||
|
`rdp.js` direkt anzeigt.
|
||||||
|
|
||||||
|
### Nach dem Deployment zu tun
|
||||||
|
|
||||||
|
1. Migration 0009 laeuft beim Start automatisch.
|
||||||
|
2. Zu jedem bereits hinterlegten, passphrasegeschuetzten SSH-Key die
|
||||||
|
Passphrase nachtragen (Adminbereich -> Zugangsdaten -> SSH-Key bearbeiten).
|
||||||
|
3. Bei RDP-Hosts pruefen, dass ein RDP-Benutzername gesetzt ist.
|
||||||
|
|
||||||
## Tests
|
## Tests
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
pytest -q
|
pytest -q
|
||||||
```
|
```
|
||||||
|
|
||||||
68 Tests (vorher 61) decken ab: Argon2id/TOTP-Grundfunktionen, Audit-Hash-Chain
|
78 Tests (vorher 68; `tests/test_phase10.py` kam hinzu) decken ab: Argon2id/TOTP-Grundfunktionen, Audit-Hash-Chain
|
||||||
(inkl. Manipulationserkennung und Trigger-Durchsetzung), RBAC-Logik inkl.
|
(inkl. Manipulationserkennung und Trigger-Durchsetzung), RBAC-Logik inkl.
|
||||||
Ablaufdaten, den vollstaendigen Login-Flow (Passwort -> TOTP-Enrollment ->
|
Ablaufdaten, den vollstaendigen Login-Flow (Passwort -> TOTP-Enrollment ->
|
||||||
Session-Cookie -> geschuetzte Endpunkte) gegen die echte FastAPI-App, 17
|
Session-Cookie -> geschuetzte Endpunkte) gegen die echte FastAPI-App, 17
|
||||||
|
|||||||
BIN
_to_delete/jh.tgz
Normal file
BIN
_to_delete/jh.tgz
Normal file
Binary file not shown.
@ -66,10 +66,16 @@ from app.security.api_tokens import (
|
|||||||
validate_scopes,
|
validate_scopes,
|
||||||
)
|
)
|
||||||
from app.security.audit import verify_chain, write_audit_event
|
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.security.passwords import hash_password
|
||||||
from app.recordings.recorder import verify_recording
|
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
|
from app.tenancy import TenantScope, resolve_host_group_tenant, resolve_host_tenant, tenant_user_ids
|
||||||
|
|
||||||
logger = logging.getLogger("jumphost.admin")
|
logger = logging.getLogger("jumphost.admin")
|
||||||
@ -725,15 +731,15 @@ async def create_host(
|
|||||||
INSERT INTO hosts (
|
INSERT INTO hosts (
|
||||||
host_group_id, hostname, address, protocol, port, os_type,
|
host_group_id, hostname, address, protocol, port, os_type,
|
||||||
ssh_host_key_fingerprint, ssh_username, rdp_username, rdp_domain,
|
ssh_host_key_fingerprint, ssh_username, rdp_username, rdp_domain,
|
||||||
rdp_require_nla, clipboard_enabled, file_transfer_enabled
|
rdp_require_nla, clipboard_enabled, file_transfer_enabled, rdp_ignore_cert
|
||||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
""",
|
""",
|
||||||
(
|
(
|
||||||
payload.host_group_id, payload.hostname, payload.address, payload.protocol,
|
payload.host_group_id, payload.hostname, payload.address, payload.protocol,
|
||||||
payload.port, payload.os_type, payload.ssh_host_key_fingerprint,
|
payload.port, payload.os_type, payload.ssh_host_key_fingerprint,
|
||||||
payload.ssh_username, payload.rdp_username, payload.rdp_domain,
|
payload.ssh_username, payload.rdp_username, payload.rdp_domain,
|
||||||
int(payload.rdp_require_nla), int(payload.clipboard_enabled),
|
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
|
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, "
|
"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.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, "
|
"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 "
|
"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 = ?",
|
"WHERE h.id = ?",
|
||||||
(host_id,),
|
(host_id,),
|
||||||
@ -819,7 +825,8 @@ async def get_host_detail(
|
|||||||
"ssh_username": row[8], "rdp_username": row[9], "rdp_domain": row[10],
|
"ssh_username": row[8], "rdp_username": row[9], "rdp_domain": row[10],
|
||||||
"rdp_require_nla": bool(row[11]), "clipboard_enabled": bool(row[12]),
|
"rdp_require_nla": bool(row[11]), "clipboard_enabled": bool(row[12]),
|
||||||
"file_transfer_enabled": bool(row[13]), "is_active": bool(row[14]),
|
"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_set": rdp_row is not None,
|
||||||
"rdp_credentials_updated_at": rdp_row[0] if rdp_row else None,
|
"rdp_credentials_updated_at": rdp_row[0] if rdp_row else None,
|
||||||
}
|
}
|
||||||
@ -844,6 +851,7 @@ async def update_host(
|
|||||||
bool_field_map = {
|
bool_field_map = {
|
||||||
"rdp_require_nla": "rdp_require_nla", "clipboard_enabled": "clipboard_enabled",
|
"rdp_require_nla": "rdp_require_nla", "clipboard_enabled": "clipboard_enabled",
|
||||||
"file_transfer_enabled": "file_transfer_enabled", "is_active": "is_active",
|
"file_transfer_enabled": "file_transfer_enabled", "is_active": "is_active",
|
||||||
|
"rdp_ignore_cert": "rdp_ignore_cert",
|
||||||
}
|
}
|
||||||
fields, values = [], []
|
fields, values = [], []
|
||||||
payload_dict = payload.model_dump(exclude_unset=True)
|
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 ---------------------------------------------------------
|
# --- 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)
|
@router.post("/ssh-keys", status_code=status.HTTP_201_CREATED)
|
||||||
async def create_ssh_key(
|
async def create_ssh_key(
|
||||||
payload: SshKeyCreateRequest, request: Request,
|
payload: SshKeyCreateRequest, request: Request,
|
||||||
@ -1238,16 +1260,31 @@ async def create_ssh_key(
|
|||||||
conn = get_db()
|
conn = get_db()
|
||||||
scope = _scope(admin)
|
scope = _scope(admin)
|
||||||
tenant_id = await _resolve_write_tenant(conn, scope, payload.tenant_id)
|
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")
|
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(
|
cursor = await conn.execute(
|
||||||
"INSERT INTO ssh_keys (label, owner_user_id, private_key_enc, public_key, key_type, tenant_id) "
|
"INSERT INTO ssh_keys (label, owner_user_id, private_key_enc, public_key, key_type, "
|
||||||
"VALUES (?, ?, ?, ?, ?, ?)",
|
"tenant_id, passphrase_enc) VALUES (?, ?, ?, ?, ?, ?, ?)",
|
||||||
(payload.label, payload.owner_user_id, encrypted, payload.public_key, payload.key_type, tenant_id),
|
(payload.label, payload.owner_user_id, encrypted, payload.public_key, payload.key_type,
|
||||||
|
tenant_id, passphrase_enc),
|
||||||
)
|
)
|
||||||
new_id = cursor.lastrowid
|
new_id = cursor.lastrowid
|
||||||
await write_audit_event(
|
await write_audit_event(
|
||||||
conn, event_type="ssh_key_created", user_id=admin.id, client_ip=_client_ip(request),
|
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()
|
await conn.commit()
|
||||||
return {"id": new_id, "label": payload.label}
|
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")
|
tenant_filter, params = scope.sql_filter("sk.tenant_id")
|
||||||
cursor = await conn.execute(
|
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.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 "
|
"FROM ssh_keys sk JOIN tenants t ON t.id = sk.tenant_id "
|
||||||
f"WHERE 1=1{tenant_filter} ORDER BY sk.id",
|
f"WHERE 1=1{tenant_filter} ORDER BY sk.id",
|
||||||
params,
|
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],
|
"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],
|
"created_at": r[4], "rotated_at": r[5], "expires_at": r[6],
|
||||||
"tenant_id": r[7], "tenant_name": r[8],
|
"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
|
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",
|
"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 = [], []
|
fields, values = [], []
|
||||||
if payload.label is not None:
|
if payload.label is not None:
|
||||||
fields.append("label = ?"); values.append(payload.label)
|
fields.append("label = ?"); values.append(payload.label)
|
||||||
@ -1308,13 +1376,27 @@ async def update_ssh_key(
|
|||||||
fields += ["private_key_enc = ?", "public_key = ?", "key_type = ?",
|
fields += ["private_key_enc = ?", "public_key = ?", "key_type = ?",
|
||||||
"rotated_at = strftime('%Y-%m-%dT%H:%M:%fZ','now')"]
|
"rotated_at = strftime('%Y-%m-%dT%H:%M:%fZ','now')"]
|
||||||
values += [encrypted, payload.public_key, payload.key_type]
|
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:
|
if not fields:
|
||||||
return {"status": "ok", "changed": False}
|
return {"status": "ok", "changed": False}
|
||||||
values.append(key_id)
|
values.append(key_id)
|
||||||
await conn.execute(f"UPDATE ssh_keys SET {', '.join(fields)} WHERE id = ?", values)
|
await conn.execute(f"UPDATE ssh_keys SET {', '.join(fields)} WHERE id = ?", values)
|
||||||
await write_audit_event(
|
await write_audit_event(
|
||||||
conn, event_type="ssh_key_updated", user_id=admin.id, client_ip=_client_ip(request),
|
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()
|
await conn.commit()
|
||||||
return {"status": "ok", "changed": True}
|
return {"status": "ok", "changed": True}
|
||||||
|
|||||||
27
app/db/migrations/0009_key_passphrase_rdp_cert.sql
Normal file
27
app/db/migrations/0009_key_passphrase_rdp_cert.sql
Normal 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;
|
||||||
@ -120,6 +120,11 @@ class HostCreateRequest(BaseModel):
|
|||||||
rdp_require_nla: bool = True
|
rdp_require_nla: bool = True
|
||||||
clipboard_enabled: bool = True
|
clipboard_enabled: bool = True
|
||||||
file_transfer_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")
|
@field_validator("hostname")
|
||||||
@classmethod
|
@classmethod
|
||||||
@ -140,6 +145,7 @@ class HostUpdateRequest(BaseModel):
|
|||||||
rdp_require_nla: bool | None = None
|
rdp_require_nla: bool | None = None
|
||||||
clipboard_enabled: bool | None = None
|
clipboard_enabled: bool | None = None
|
||||||
file_transfer_enabled: bool | None = None
|
file_transfer_enabled: bool | None = None
|
||||||
|
rdp_ignore_cert: bool | None = None
|
||||||
is_active: bool | None = None
|
is_active: bool | None = None
|
||||||
|
|
||||||
@field_validator("hostname")
|
@field_validator("hostname")
|
||||||
@ -208,6 +214,11 @@ class SshKeyCreateRequest(BaseModel):
|
|||||||
public_key: str = Field(min_length=1, max_length=8192)
|
public_key: str = Field(min_length=1, max_length=8192)
|
||||||
key_type: Literal["ed25519", "rsa-3072", "rsa-4096", "ca-cert"]
|
key_type: Literal["ed25519", "rsa-3072", "rsa-4096", "ca-cert"]
|
||||||
tenant_id: int | None = None
|
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):
|
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)
|
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)
|
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
|
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):
|
class ConnectRequest(BaseModel):
|
||||||
|
|||||||
@ -16,6 +16,7 @@ sollte vor Produktivbetrieb dennoch gegen die Ziel-guacd-Version getestet werden
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
|
import codecs
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
logger = logging.getLogger("jumphost.rdp_proxy.guacd")
|
logger = logging.getLogger("jumphost.rdp_proxy.guacd")
|
||||||
@ -26,11 +27,18 @@ class GuacamoleProtocolError(Exception):
|
|||||||
|
|
||||||
|
|
||||||
def encode_instruction(*elements: str) -> str:
|
def encode_instruction(*elements: str) -> str:
|
||||||
parts = []
|
"""Kodiert eine Guacamole-Instruktion.
|
||||||
for element in elements:
|
|
||||||
encoded = element.encode("utf-8")
|
Die Laengenangabe zaehlt ZEICHEN, nicht Bytes -- so ist das Guacamole-
|
||||||
parts.append(f"{len(encoded)}.{element}")
|
Protokoll definiert und so zaehlen sowohl guacd als auch
|
||||||
return ",".join(parts) + ";"
|
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:
|
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
|
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]:
|
async def read_instruction(reader: asyncio.StreamReader) -> list[str]:
|
||||||
elements: list[str] = []
|
elements: list[str] = []
|
||||||
while True:
|
while True:
|
||||||
@ -50,7 +73,7 @@ async def read_instruction(reader: asyncio.StreamReader) -> list[str]:
|
|||||||
length = int(length_bytes)
|
length = int(length_bytes)
|
||||||
except ValueError as exc:
|
except ValueError as exc:
|
||||||
raise GuacamoleProtocolError(f"Ungueltige Laengenangabe: {length_bytes!r}") from 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)
|
elements.append(content)
|
||||||
sep = await reader.readexactly(1)
|
sep = await reader.readexactly(1)
|
||||||
if sep == b";":
|
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.
|
"""Baut die Parameter-Map fuer die connect-Instruktion aus dem Host-Datensatz.
|
||||||
|
|
||||||
Sicherheitsdefaults (siehe Konzept 6.3/6.7): NLA wird erzwungen sofern
|
Sicherheitsdefaults (siehe Konzept 6.3/6.7): NLA wird erzwungen sofern
|
||||||
rdp_require_nla gesetzt ist (Standard), Zertifikatspruefung ist standardmaessig
|
rdp_require_nla gesetzt ist (Standard). Die Zertifikatspruefung ist pro Host
|
||||||
AKTIV (ignore-cert=false) -- bei selbstsignierten Zertifikaten auf den
|
ueber hosts.rdp_ignore_cert schaltbar (Migration 0009, Default: ignorieren) --
|
||||||
Zielsystemen muss dies bewusst pro Host ueberschrieben werden, kein stiller
|
ohne interne PKI praesentiert jedes Windows-Ziel ein selbstsigniertes
|
||||||
Bypass.
|
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 {
|
return {
|
||||||
"hostname": host["address"],
|
"hostname": host["address"],
|
||||||
"port": str(host["port"]),
|
"port": str(host["port"]),
|
||||||
"username": host.get("rdp_username") or "",
|
"username": username,
|
||||||
"password": password,
|
"password": password,
|
||||||
"domain": host.get("rdp_domain") or "",
|
"domain": host.get("rdp_domain") or "",
|
||||||
"security": "nla" if host.get("rdp_require_nla", True) else "any",
|
"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",
|
"disable-audio": "true",
|
||||||
"enable-drive": "true" if host.get("file_transfer_enabled") else "false",
|
"enable-drive": "true" if host.get("file_transfer_enabled") else "false",
|
||||||
"drive-path": f"/var/lib/jumphost/rdp-drives/{host['id']}",
|
"drive-path": f"/var/lib/jumphost/rdp-drives/{host['id']}",
|
||||||
"create-drive-path": "true",
|
"create-drive-path": "true",
|
||||||
"disable-copy": "false" if host.get("clipboard_enabled") else "true",
|
"disable-copy": "false" if clipboard_enabled else "true",
|
||||||
"disable-paste": "false" if host.get("clipboard_enabled") else "true",
|
"disable-paste": "false" if clipboard_enabled else "true",
|
||||||
"resize-method": "display-update",
|
"resize-method": "display-update",
|
||||||
}
|
}
|
||||||
|
|||||||
@ -11,6 +11,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
import logging
|
import logging
|
||||||
|
import uuid as uuid_mod
|
||||||
|
|
||||||
from fastapi import APIRouter, Query, WebSocket, WebSocketDisconnect
|
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")
|
logger = logging.getLogger("jumphost.rdp_proxy.ws")
|
||||||
router = APIRouter()
|
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:
|
async def _guacd_to_ws(tunnel, websocket: WebSocket, recorder: SessionRecorder) -> None:
|
||||||
while True:
|
while True:
|
||||||
@ -49,9 +94,22 @@ async def _ws_to_guacd(tunnel, websocket: WebSocket, recorder: SessionRecorder,
|
|||||||
message = await websocket.receive_text()
|
message = await websocket.receive_text()
|
||||||
try:
|
try:
|
||||||
instr = parse_instruction_text(message)
|
instr = parse_instruction_text(message)
|
||||||
except GuacamoleProtocolError:
|
except (GuacamoleProtocolError, ValueError, IndexError):
|
||||||
continue # ungueltige Clientnachricht ignorieren statt die Verbindung zu killen
|
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":
|
if not clipboard_enabled and instr and instr[0] == "clipboard":
|
||||||
continue # Defense-in-Depth: Clipboard serverseitig blocken
|
continue # Defense-in-Depth: Clipboard serverseitig blocken
|
||||||
|
|
||||||
@ -70,27 +128,50 @@ async def rdp_tunnel(
|
|||||||
):
|
):
|
||||||
user = await get_current_user_ws(websocket)
|
user = await get_current_user_ws(websocket)
|
||||||
if user is None:
|
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)
|
await websocket.close(code=4401)
|
||||||
return
|
return
|
||||||
|
|
||||||
conn = get_db()
|
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(
|
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"
|
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
|
return
|
||||||
|
|
||||||
await websocket.accept()
|
await _accept(websocket)
|
||||||
client_ip = websocket.client.host if websocket.client else "unknown"
|
|
||||||
|
# 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:
|
try:
|
||||||
host = await load_host(conn, host_id)
|
host = await load_host(conn, host_id)
|
||||||
except HostNotConfiguredError as exc:
|
except HostNotConfiguredError as exc:
|
||||||
await websocket.close(code=4404)
|
await _reject(websocket, 4404, str(exc), accepted=True)
|
||||||
return
|
return
|
||||||
|
|
||||||
if host["protocol"] != "rdp":
|
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
|
return
|
||||||
|
|
||||||
cred_cursor = await conn.execute(
|
cred_cursor = await conn.execute(
|
||||||
@ -98,7 +179,12 @@ async def rdp_tunnel(
|
|||||||
)
|
)
|
||||||
cred_row = await cred_cursor.fetchone()
|
cred_row = await cred_cursor.fetchone()
|
||||||
if cred_row is None:
|
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
|
return
|
||||||
|
|
||||||
cursor = await conn.execute(
|
cursor = await conn.execute(
|
||||||
@ -122,6 +208,7 @@ async def rdp_tunnel(
|
|||||||
active_sessions.register(session_id, asyncio.current_task())
|
active_sessions.register(session_id, asyncio.current_task())
|
||||||
|
|
||||||
end_reason = "logout"
|
end_reason = "logout"
|
||||||
|
error_text: str | None = None
|
||||||
tunnel = None
|
tunnel = None
|
||||||
tasks: list[asyncio.Task] = []
|
tasks: list[asyncio.Task] = []
|
||||||
password = None
|
password = None
|
||||||
@ -159,6 +246,11 @@ async def rdp_tunnel(
|
|||||||
except (GuacamoleProtocolError, ConnectionError, OSError) as exc:
|
except (GuacamoleProtocolError, ConnectionError, OSError) as exc:
|
||||||
logger.warning("RDP-Sessionfehler (session_id=%s): %s", session_id, exc)
|
logger.warning("RDP-Sessionfehler (session_id=%s): %s", session_id, exc)
|
||||||
end_reason = "error"
|
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:
|
except asyncio.CancelledError:
|
||||||
# Zwangs-Beendigung durch einen Superadmin ueber die Sessionview
|
# Zwangs-Beendigung durch einen Superadmin ueber die Sessionview
|
||||||
# (POST /admin/sessions/{id}/terminate, siehe app/security/active_sessions.py).
|
# (POST /admin/sessions/{id}/terminate, siehe app/security/active_sessions.py).
|
||||||
@ -172,6 +264,7 @@ async def rdp_tunnel(
|
|||||||
# jede Fehlermeldung/Protokollierung sofort beendete.
|
# jede Fehlermeldung/Protokollierung sofort beendete.
|
||||||
logger.exception("Unerwarteter Fehler in RDP-Sitzung %s: %s", session_id, exc)
|
logger.exception("Unerwarteter Fehler in RDP-Sitzung %s: %s", session_id, exc)
|
||||||
end_reason = "error"
|
end_reason = "error"
|
||||||
|
error_text = f"Interner Fehler: {exc.__class__.__name__}"
|
||||||
finally:
|
finally:
|
||||||
active_sessions.unregister(session_id)
|
active_sessions.unregister(session_id)
|
||||||
del password # Klartext-Passwort so schnell wie moeglich freigeben
|
del password # Klartext-Passwort so schnell wie moeglich freigeben
|
||||||
@ -192,7 +285,12 @@ async def rdp_tunnel(
|
|||||||
)
|
)
|
||||||
await conn.commit()
|
await conn.commit()
|
||||||
try:
|
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:
|
except Exception:
|
||||||
# Cleanup-Pfad: der Session-Datensatz und Audit-Log-Eintrag sind
|
# Cleanup-Pfad: der Session-Datensatz und Audit-Log-Eintrag sind
|
||||||
# zu diesem Zeitpunkt bereits geschrieben; ein bereits vom Client
|
# zu diesem Zeitpunkt bereits geschrieben; ein bereits vom Client
|
||||||
|
|||||||
@ -44,6 +44,85 @@ class HostKeyDiscoveryError(Exception):
|
|||||||
super().__init__(f"Host-Key-Ermittlung fuer Host {host_id} fehlgeschlagen: {reason}")
|
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):
|
class _PinnedHostKeyClient(asyncssh.SSHClient):
|
||||||
"""Erzwingt Strict Host Key Checking gegen einen fest hinterlegten
|
"""Erzwingt Strict Host Key Checking gegen einen fest hinterlegten
|
||||||
SHA-256-Fingerprint. Kein automatisches Trust-on-First-Use (TOFU)."""
|
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:
|
async def load_host(conn: aiosqlite.Connection, host_id: int) -> dict:
|
||||||
cursor = await conn.execute(
|
cursor = await conn.execute(
|
||||||
"SELECT id, hostname, address, port, os_type, protocol, ssh_host_key_fingerprint, "
|
"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,),
|
(host_id,),
|
||||||
)
|
)
|
||||||
row = await cursor.fetchone()
|
row = await cursor.fetchone()
|
||||||
@ -76,13 +157,33 @@ async def load_host(conn: aiosqlite.Connection, host_id: int) -> dict:
|
|||||||
keys = (
|
keys = (
|
||||||
"id", "hostname", "address", "port", "os_type", "protocol",
|
"id", "hostname", "address", "port", "os_type", "protocol",
|
||||||
"ssh_host_key_fingerprint", "ssh_username", "file_transfer_enabled", "host_group_id",
|
"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:
|
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(
|
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 "
|
"JOIN host_ssh_key_map m ON m.ssh_key_id = sk.id "
|
||||||
"WHERE m.host_id = ? LIMIT 1",
|
"WHERE m.host_id = ? LIMIT 1",
|
||||||
(host_id,),
|
(host_id,),
|
||||||
@ -91,11 +192,16 @@ async def load_private_key_for_host(conn: aiosqlite.Connection, host_id: int) ->
|
|||||||
if row is None:
|
if row is None:
|
||||||
raise HostNotConfiguredError(f"Kein SSH-Schluessel fuer Host {host_id} hinterlegt")
|
raise HostNotConfiguredError(f"Kein SSH-Schluessel fuer Host {host_id} hinterlegt")
|
||||||
pem = decrypt_secret(row[0], associated_data=b"ssh_private_key")
|
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:
|
try:
|
||||||
return asyncssh.import_private_key(pem)
|
return import_private_key_material(pem, passphrase)
|
||||||
finally:
|
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 pem
|
||||||
|
del passphrase
|
||||||
|
|
||||||
|
|
||||||
async def connect_to_host(conn: aiosqlite.Connection, host_id: int) -> asyncssh.SSHClientConnection:
|
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
|
from __future__ import annotations
|
||||||
|
|
||||||
import hashlib
|
import hashlib
|
||||||
|
import logging
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, Query, Request, UploadFile, status
|
from fastapi import APIRouter, Depends, HTTPException, Query, Request, UploadFile, status
|
||||||
from fastapi.responses import StreamingResponse
|
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.rbac import user_has_role_for_host
|
||||||
from app.security.audit import write_audit_event
|
from app.security.audit import write_audit_event
|
||||||
from app.security.av_scan import scan_bytes
|
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"])
|
router = APIRouter(prefix="/ssh", tags=["file-transfer"])
|
||||||
|
|
||||||
MAX_UPLOAD_BYTES = 200 * 1024 * 1024 # 200 MiB, ueber Ansible-Variable konfigurierbar (siehe Konzept)
|
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()
|
ssh_conn.close()
|
||||||
except HostNotConfiguredError as exc:
|
except HostNotConfiguredError as exc:
|
||||||
raise HTTPException(status.HTTP_400_BAD_REQUEST, str(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(
|
await _log_transfer(
|
||||||
conn, user=user, host_id=host_id, client_ip=_client_ip(request), direction="upload",
|
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()
|
ssh_conn.close()
|
||||||
except HostNotConfiguredError as exc:
|
except HostNotConfiguredError as exc:
|
||||||
raise HTTPException(status.HTTP_400_BAD_REQUEST, str(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()
|
sha256 = hashlib.sha256(data).hexdigest()
|
||||||
filename = remote_path.rsplit("/", 1)[-1]
|
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.recordings.recorder import SessionRecorder
|
||||||
from app.security import active_sessions
|
from app.security import active_sessions
|
||||||
from app.security.audit import write_audit_event
|
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")
|
logger = logging.getLogger("jumphost.ssh_proxy.ws")
|
||||||
router = APIRouter()
|
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):
|
async def ssh_terminal(websocket: WebSocket, host_id: int):
|
||||||
user = await get_current_user_ws(websocket)
|
user = await get_current_user_ws(websocket)
|
||||||
if user is None:
|
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)
|
await websocket.close(code=4401)
|
||||||
return
|
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(
|
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"
|
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)
|
await websocket.close(code=4403)
|
||||||
return
|
return
|
||||||
|
|
||||||
@ -66,6 +79,7 @@ async def ssh_terminal(websocket: WebSocket, host_id: int):
|
|||||||
try:
|
try:
|
||||||
host = await load_host(conn, host_id)
|
host = await load_host(conn, host_id)
|
||||||
except HostNotConfiguredError as exc:
|
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.send_json({"type": "error", "message": str(exc)})
|
||||||
await websocket.close(code=4404)
|
await websocket.close(code=4404)
|
||||||
return
|
return
|
||||||
@ -125,8 +139,27 @@ async def ssh_terminal(websocket: WebSocket, host_id: int):
|
|||||||
# Best-Effort-Fehlermeldung an einen ggf. bereits getrennten Client;
|
# Best-Effort-Fehlermeldung an einen ggf. bereits getrennten Client;
|
||||||
# der eigentliche Fehler ist bereits oben geloggt (logger.warning).
|
# der eigentliche Fehler ist bereits oben geloggt (logger.warning).
|
||||||
logger.debug("Fehlermeldung konnte nicht mehr an Client gesendet werden", exc_info=True)
|
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"
|
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:
|
except asyncio.CancelledError:
|
||||||
# Zwangs-Beendigung durch einen Superadmin ueber die Sessionview
|
# Zwangs-Beendigung durch einen Superadmin ueber die Sessionview
|
||||||
# (POST /admin/sessions/{id}/terminate, siehe app/security/active_sessions.py).
|
# (POST /admin/sessions/{id}/terminate, siehe app/security/active_sessions.py).
|
||||||
|
|||||||
@ -522,6 +522,7 @@
|
|||||||
document.getElementById("he-clipboard").checked = host.clipboard_enabled;
|
document.getElementById("he-clipboard").checked = host.clipboard_enabled;
|
||||||
document.getElementById("he-filetransfer").checked = host.file_transfer_enabled;
|
document.getElementById("he-filetransfer").checked = host.file_transfer_enabled;
|
||||||
document.getElementById("he-nla").checked = host.rdp_require_nla;
|
document.getElementById("he-nla").checked = host.rdp_require_nla;
|
||||||
|
document.getElementById("he-ignorecert").checked = host.rdp_ignore_cert;
|
||||||
document.getElementById("he-active").checked = host.is_active;
|
document.getElementById("he-active").checked = host.is_active;
|
||||||
|
|
||||||
const isRdp = host.protocol === "rdp";
|
const isRdp = host.protocol === "rdp";
|
||||||
@ -529,6 +530,7 @@
|
|||||||
document.getElementById("he-rdp-username-box").classList.toggle("hidden", !isRdp);
|
document.getElementById("he-rdp-username-box").classList.toggle("hidden", !isRdp);
|
||||||
document.getElementById("he-rdp-domain-box").classList.toggle("hidden", !isRdp);
|
document.getElementById("he-rdp-domain-box").classList.toggle("hidden", !isRdp);
|
||||||
document.getElementById("he-nla-box").classList.toggle("hidden", !isRdp);
|
document.getElementById("he-nla-box").classList.toggle("hidden", !isRdp);
|
||||||
|
document.getElementById("he-ignorecert-box").classList.toggle("hidden", !isRdp);
|
||||||
document.getElementById("host-detail-ssh").classList.toggle("hidden", isRdp);
|
document.getElementById("host-detail-ssh").classList.toggle("hidden", isRdp);
|
||||||
document.getElementById("host-detail-rdp").classList.toggle("hidden", !isRdp);
|
document.getElementById("host-detail-rdp").classList.toggle("hidden", !isRdp);
|
||||||
|
|
||||||
@ -579,6 +581,7 @@
|
|||||||
clipboard_enabled: document.getElementById("he-clipboard").checked,
|
clipboard_enabled: document.getElementById("he-clipboard").checked,
|
||||||
file_transfer_enabled: document.getElementById("he-filetransfer").checked,
|
file_transfer_enabled: document.getElementById("he-filetransfer").checked,
|
||||||
rdp_require_nla: document.getElementById("he-nla").checked,
|
rdp_require_nla: document.getElementById("he-nla").checked,
|
||||||
|
rdp_ignore_cert: document.getElementById("he-ignorecert").checked,
|
||||||
is_active: document.getElementById("he-active").checked,
|
is_active: document.getElementById("he-active").checked,
|
||||||
});
|
});
|
||||||
showBanner("Host aktualisiert.", "ok");
|
showBanner("Host aktualisiert.", "ok");
|
||||||
@ -595,6 +598,8 @@
|
|||||||
document.getElementById("hc-rdp-username-box").classList.toggle("hidden", !isRdp);
|
document.getElementById("hc-rdp-username-box").classList.toggle("hidden", !isRdp);
|
||||||
document.getElementById("hc-rdp-domain-box").classList.toggle("hidden", !isRdp);
|
document.getElementById("hc-rdp-domain-box").classList.toggle("hidden", !isRdp);
|
||||||
document.getElementById("hc-nla-box").classList.toggle("hidden", !isRdp);
|
document.getElementById("hc-nla-box").classList.toggle("hidden", !isRdp);
|
||||||
|
document.getElementById("hc-ignorecert-box").classList.toggle("hidden", !isRdp);
|
||||||
|
document.getElementById("hc-ignorecert-hint").classList.toggle("hidden", !isRdp);
|
||||||
document.getElementById("hc-port").value = isRdp ? "3389" : "22";
|
document.getElementById("hc-port").value = isRdp ? "3389" : "22";
|
||||||
document.getElementById("hc-os-type").value = isRdp ? "windows" : "linux";
|
document.getElementById("hc-os-type").value = isRdp ? "windows" : "linux";
|
||||||
});
|
});
|
||||||
@ -631,6 +636,7 @@
|
|||||||
rdp_username: document.getElementById("hc-rdp-username").value.trim() || null,
|
rdp_username: document.getElementById("hc-rdp-username").value.trim() || null,
|
||||||
rdp_domain: document.getElementById("hc-rdp-domain").value.trim() || null,
|
rdp_domain: document.getElementById("hc-rdp-domain").value.trim() || null,
|
||||||
rdp_require_nla: document.getElementById("hc-nla").checked,
|
rdp_require_nla: document.getElementById("hc-nla").checked,
|
||||||
|
rdp_ignore_cert: document.getElementById("hc-ignorecert").checked,
|
||||||
clipboard_enabled: document.getElementById("hc-clipboard").checked,
|
clipboard_enabled: document.getElementById("hc-clipboard").checked,
|
||||||
file_transfer_enabled: document.getElementById("hc-filetransfer").checked,
|
file_transfer_enabled: document.getElementById("hc-filetransfer").checked,
|
||||||
};
|
};
|
||||||
@ -706,6 +712,7 @@
|
|||||||
el("td", { textContent: k.key_type }),
|
el("td", { textContent: k.key_type }),
|
||||||
el("td", { textContent: k.owner_user_id === null ? "-" : String(k.owner_user_id) }),
|
el("td", { textContent: k.owner_user_id === null ? "-" : String(k.owner_user_id) }),
|
||||||
el("td", { textContent: k.tenant_name }),
|
el("td", { textContent: k.tenant_name }),
|
||||||
|
el("td", { textContent: k.has_passphrase ? "hinterlegt" : "-" }),
|
||||||
el("td", { textContent: k.created_at }),
|
el("td", { textContent: k.created_at }),
|
||||||
el("td", { textContent: k.rotated_at || "-" }),
|
el("td", { textContent: k.rotated_at || "-" }),
|
||||||
el("td", {}, [
|
el("td", {}, [
|
||||||
@ -731,8 +738,46 @@
|
|||||||
document.getElementById("ske-label").value = k.label;
|
document.getElementById("ske-label").value = k.label;
|
||||||
document.getElementById("ske-owner").value = k.owner_user_id === null ? "" : String(k.owner_user_id);
|
document.getElementById("ske-owner").value = k.owner_user_id === null ? "" : String(k.owner_user_id);
|
||||||
document.getElementById("skr-type").value = k.key_type;
|
document.getElementById("skr-type").value = k.key_type;
|
||||||
|
document.getElementById("skp-passphrase").value = "";
|
||||||
|
document.getElementById("ske-passphrase-state").textContent = k.has_passphrase
|
||||||
|
? "Fuer diesen Schluessel ist eine Passphrase hinterlegt."
|
||||||
|
: "Fuer diesen Schluessel ist KEINE Passphrase hinterlegt. Ist der Schluessel verschluesselt, schlaegt jeder Verbindungsversuch fehl.";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function saveSshKeyPassphrase(value) {
|
||||||
|
// Wird gegen das bereits gespeicherte Schluesselmaterial geprueft
|
||||||
|
// (update_ssh_key in app/admin/routes.py); passt sie nicht, antwortet der
|
||||||
|
// Server mit 400 und es wird nichts gespeichert.
|
||||||
|
await sendJson(`/admin/ssh-keys/${editingSshKeyId}`, "PUT", { passphrase: value });
|
||||||
|
await refreshSshKeys();
|
||||||
|
const updated = cachedSshKeys.find((k) => k.id === editingSshKeyId);
|
||||||
|
if (updated) showSshKeyEdit(updated);
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById("ssh-key-passphrase-form").addEventListener("submit", async (ev) => {
|
||||||
|
ev.preventDefault();
|
||||||
|
try {
|
||||||
|
const value = document.getElementById("skp-passphrase").value;
|
||||||
|
if (!value) {
|
||||||
|
showBanner("Bitte eine Passphrase eingeben oder 'Passphrase entfernen' verwenden.", "error");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await saveSshKeyPassphrase(value);
|
||||||
|
showBanner("Passphrase gespeichert und gegen den Schluessel geprueft.", "ok");
|
||||||
|
} catch (err) {
|
||||||
|
showBanner(err.message, "error");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById("skp-clear-btn").addEventListener("click", async () => {
|
||||||
|
try {
|
||||||
|
await saveSshKeyPassphrase(null);
|
||||||
|
showBanner("Passphrase entfernt.", "ok");
|
||||||
|
} catch (err) {
|
||||||
|
showBanner(err.message, "error");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
document.getElementById("ssh-key-edit-form").addEventListener("submit", async (ev) => {
|
document.getElementById("ssh-key-edit-form").addEventListener("submit", async (ev) => {
|
||||||
ev.preventDefault();
|
ev.preventDefault();
|
||||||
try {
|
try {
|
||||||
@ -755,6 +800,7 @@
|
|||||||
key_type: document.getElementById("skr-type").value,
|
key_type: document.getElementById("skr-type").value,
|
||||||
private_key_pem: document.getElementById("skr-private").value,
|
private_key_pem: document.getElementById("skr-private").value,
|
||||||
public_key: document.getElementById("skr-public").value,
|
public_key: document.getElementById("skr-public").value,
|
||||||
|
passphrase: document.getElementById("skr-passphrase").value || null,
|
||||||
});
|
});
|
||||||
showBanner("SSH-Key rotiert.", "ok");
|
showBanner("SSH-Key rotiert.", "ok");
|
||||||
ev.target.reset();
|
ev.target.reset();
|
||||||
@ -775,6 +821,8 @@
|
|||||||
key_type: document.getElementById("skc-type").value,
|
key_type: document.getElementById("skc-type").value,
|
||||||
private_key_pem: document.getElementById("skc-private").value,
|
private_key_pem: document.getElementById("skc-private").value,
|
||||||
public_key: document.getElementById("skc-public").value,
|
public_key: document.getElementById("skc-public").value,
|
||||||
|
// Leeres Feld -> null: der Schluessel gilt dann als unverschluesselt.
|
||||||
|
passphrase: document.getElementById("skc-passphrase").value || null,
|
||||||
tenant_id: tenantSelect.value ? Number(tenantSelect.value) : null,
|
tenant_id: tenantSelect.value ? Number(tenantSelect.value) : null,
|
||||||
};
|
};
|
||||||
await sendJson("/admin/ssh-keys", "POST", payload);
|
await sendJson("/admin/ssh-keys", "POST", payload);
|
||||||
|
|||||||
@ -21,7 +21,15 @@
|
|||||||
const height = Math.round(window.innerHeight - 40);
|
const height = Math.round(window.innerHeight - 40);
|
||||||
const dpi = Math.round(window.devicePixelRatio * 96) || 96;
|
const dpi = Math.round(window.devicePixelRatio * 96) || 96;
|
||||||
|
|
||||||
const tunnelUrl = `${proto}//${window.location.host}/ws/rdp/${hostId}?width=${width}&height=${height}&dpi=${dpi}`;
|
// WICHTIG: die Verbindungsparameter gehoeren NICHT in die Tunnel-URL.
|
||||||
|
// Guacamole.WebSocketTunnel.connect(data) baut die Socket-URL selbst als
|
||||||
|
// `tunnelURL + "?" + data` zusammen. Standen die Parameter schon in der
|
||||||
|
// URL, entstand daraus `...?width=1280&height=800&dpi=96?undefined` -- der
|
||||||
|
// letzte Query-Parameter war damit kein gueltiger Integer mehr, FastAPI
|
||||||
|
// wies den WebSocket noch vor dem Routenhandler ab (HTTP 422) und im
|
||||||
|
// Verbindungslog des Servers tauchte kein einziger Eintrag auf.
|
||||||
|
const tunnelUrl = `${proto}//${window.location.host}/ws/rdp/${hostId}`;
|
||||||
|
const connectParams = `width=${width}&height=${height}&dpi=${dpi}`;
|
||||||
const tunnel = new Guacamole.WebSocketTunnel(tunnelUrl);
|
const tunnel = new Guacamole.WebSocketTunnel(tunnelUrl);
|
||||||
const client = new Guacamole.Client(tunnel);
|
const client = new Guacamole.Client(tunnel);
|
||||||
|
|
||||||
@ -32,9 +40,15 @@
|
|||||||
const labels = ["Idle", "Verbinde ...", "Warte auf Server ...", "Verbunden", "Trenne ...", "Getrennt"];
|
const labels = ["Idle", "Verbinde ...", "Warte auf Server ...", "Verbunden", "Trenne ...", "Getrennt"];
|
||||||
statusEl.textContent = labels[state] || `Status ${state}`;
|
statusEl.textContent = labels[state] || `Status ${state}`;
|
||||||
};
|
};
|
||||||
client.onerror = (err) => {
|
const showError = (err) => {
|
||||||
statusEl.textContent = "Fehler: " + (err.message || "unbekannt");
|
// Der Server gibt den Abbruchgrund als WebSocket-Close-Reason mit
|
||||||
|
// (siehe _reject() in app/rdp_proxy/ws_tunnel.py); guacamole-common-js
|
||||||
|
// reicht ihn als Guacamole.Status.message hierher durch.
|
||||||
|
statusEl.textContent = "Fehler: " + ((err && err.message) || "unbekannt");
|
||||||
|
statusEl.classList.add("error");
|
||||||
};
|
};
|
||||||
|
client.onerror = showError;
|
||||||
|
tunnel.onerror = showError;
|
||||||
|
|
||||||
client.onclipboard = (stream, mimetype) => {
|
client.onclipboard = (stream, mimetype) => {
|
||||||
if (!mimetype.startsWith("text/")) return;
|
if (!mimetype.startsWith("text/")) return;
|
||||||
@ -48,7 +62,7 @@
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
client.connect();
|
client.connect(connectParams);
|
||||||
|
|
||||||
window.addEventListener("beforeunload", () => client.disconnect());
|
window.addEventListener("beforeunload", () => client.disconnect());
|
||||||
|
|
||||||
|
|||||||
@ -250,6 +250,8 @@
|
|||||||
<div class="checkbox-row"><input type="checkbox" id="hc-clipboard" checked><label for="hc-clipboard">Zwischenablage erlaubt</label></div>
|
<div class="checkbox-row"><input type="checkbox" id="hc-clipboard" checked><label for="hc-clipboard">Zwischenablage erlaubt</label></div>
|
||||||
<div class="checkbox-row"><input type="checkbox" id="hc-filetransfer" checked><label for="hc-filetransfer">Dateitransfer erlaubt</label></div>
|
<div class="checkbox-row"><input type="checkbox" id="hc-filetransfer" checked><label for="hc-filetransfer">Dateitransfer erlaubt</label></div>
|
||||||
<div class="checkbox-row hidden" id="hc-nla-box"><input type="checkbox" id="hc-nla" checked><label for="hc-nla">RDP: NLA erforderlich</label></div>
|
<div class="checkbox-row hidden" id="hc-nla-box"><input type="checkbox" id="hc-nla" checked><label for="hc-nla">RDP: NLA erforderlich</label></div>
|
||||||
|
<div class="checkbox-row hidden" id="hc-ignorecert-box"><input type="checkbox" id="hc-ignorecert" checked><label for="hc-ignorecert">RDP: Serverzertifikat nicht pruefen</label></div>
|
||||||
|
<p class="hint full-width hidden" id="hc-ignorecert-hint">Windows-Ziele ohne eigene PKI weisen sich mit einem selbstsignierten Zertifikat aus; guacd bricht die Verbindung sonst ab, bevor ein Bild entsteht. Nur deaktivieren, wenn das Ziel ein Zertifikat aus einer dem Jumphost bekannten CA verwendet.</p>
|
||||||
<button type="submit">Verbindung anlegen</button>
|
<button type="submit">Verbindung anlegen</button>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
@ -299,6 +301,7 @@
|
|||||||
<div class="checkbox-row"><input type="checkbox" id="he-clipboard"><label for="he-clipboard">Zwischenablage erlaubt</label></div>
|
<div class="checkbox-row"><input type="checkbox" id="he-clipboard"><label for="he-clipboard">Zwischenablage erlaubt</label></div>
|
||||||
<div class="checkbox-row"><input type="checkbox" id="he-filetransfer"><label for="he-filetransfer">Dateitransfer erlaubt</label></div>
|
<div class="checkbox-row"><input type="checkbox" id="he-filetransfer"><label for="he-filetransfer">Dateitransfer erlaubt</label></div>
|
||||||
<div class="checkbox-row hidden" id="he-nla-box"><input type="checkbox" id="he-nla"><label for="he-nla">RDP: NLA erforderlich</label></div>
|
<div class="checkbox-row hidden" id="he-nla-box"><input type="checkbox" id="he-nla"><label for="he-nla">RDP: NLA erforderlich</label></div>
|
||||||
|
<div class="checkbox-row hidden" id="he-ignorecert-box"><input type="checkbox" id="he-ignorecert"><label for="he-ignorecert">RDP: Serverzertifikat nicht pruefen</label></div>
|
||||||
<div class="checkbox-row"><input type="checkbox" id="he-active"><label for="he-active">Aktiv (verbindbar)</label></div>
|
<div class="checkbox-row"><input type="checkbox" id="he-active"><label for="he-active">Aktiv (verbindbar)</label></div>
|
||||||
<button type="submit">Speichern</button>
|
<button type="submit">Speichern</button>
|
||||||
</form>
|
</form>
|
||||||
@ -372,12 +375,17 @@
|
|||||||
<label for="skc-public">Oeffentlicher Schluessel</label>
|
<label for="skc-public">Oeffentlicher Schluessel</label>
|
||||||
<textarea id="skc-public" required></textarea>
|
<textarea id="skc-public" required></textarea>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="full-width">
|
||||||
|
<label for="skc-passphrase">Passphrase (nur bei verschluesseltem Schluessel)</label>
|
||||||
|
<input type="password" id="skc-passphrase" maxlength="1024" autocomplete="new-password">
|
||||||
|
<p class="hint">Beginnt der private Schluessel mit "-----BEGIN OPENSSH PRIVATE KEY-----" und wurde er mit einer Passphrase erzeugt, muss sie hier hinterlegt werden -- sonst laesst sich der Schluessel beim Verbindungsaufbau nicht laden. Sie wird wie das Schluesselmaterial selbst AES-256-GCM-verschluesselt gespeichert und niemals wieder ausgegeben. Leer lassen, wenn der Schluessel unverschluesselt ist.</p>
|
||||||
|
</div>
|
||||||
<button type="submit">Anlegen</button>
|
<button type="submit">Anlegen</button>
|
||||||
</form>
|
</form>
|
||||||
<h3>Vorhandene SSH-Keys</h3>
|
<h3>Vorhandene SSH-Keys</h3>
|
||||||
<div class="table-wrap">
|
<div class="table-wrap">
|
||||||
<table class="data-table" id="ssh-keys-table">
|
<table class="data-table" id="ssh-keys-table">
|
||||||
<thead><tr><th>ID</th><th>Label</th><th>Typ</th><th>Besitzer</th><th>Mandant</th><th>Erstellt</th><th>Rotiert</th><th></th></tr></thead>
|
<thead><tr><th>ID</th><th>Label</th><th>Typ</th><th>Besitzer</th><th>Mandant</th><th>Passphrase</th><th>Erstellt</th><th>Rotiert</th><th></th></tr></thead>
|
||||||
<tbody></tbody>
|
<tbody></tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
@ -396,6 +404,17 @@
|
|||||||
</div>
|
</div>
|
||||||
<button type="submit">Speichern (nur Label/Besitzer)</button>
|
<button type="submit">Speichern (nur Label/Besitzer)</button>
|
||||||
</form>
|
</form>
|
||||||
|
<h3>Passphrase</h3>
|
||||||
|
<p class="hint" id="ske-passphrase-state"></p>
|
||||||
|
<p class="hint">Traegt die Passphrase zu dem bereits gespeicherten Schluessel nach, ohne ihn neu hochzuladen. Sie wird sofort gegen das hinterlegte Schluesselmaterial geprueft -- passt sie nicht, wird nichts gespeichert. Leeres Feld + "Passphrase entfernen" loescht eine hinterlegte Passphrase (nur sinnvoll bei unverschluesseltem Schluessel).</p>
|
||||||
|
<form id="ssh-key-passphrase-form" class="form-grid">
|
||||||
|
<div class="full-width">
|
||||||
|
<label for="skp-passphrase">Passphrase</label>
|
||||||
|
<input type="password" id="skp-passphrase" maxlength="1024" autocomplete="new-password">
|
||||||
|
</div>
|
||||||
|
<button type="submit">Passphrase speichern</button>
|
||||||
|
<button type="button" id="skp-clear-btn" class="btn-secondary">Passphrase entfernen</button>
|
||||||
|
</form>
|
||||||
<h3>Schluessel rotieren</h3>
|
<h3>Schluessel rotieren</h3>
|
||||||
<p class="hint">Ersetzt das Schluesselmaterial vollstaendig (neuer privater + oeffentlicher Schluessel). Bereits zugeordnete Hosts nutzen danach automatisch den neuen Schluessel.</p>
|
<p class="hint">Ersetzt das Schluesselmaterial vollstaendig (neuer privater + oeffentlicher Schluessel). Bereits zugeordnete Hosts nutzen danach automatisch den neuen Schluessel.</p>
|
||||||
<form id="ssh-key-rotate-form" class="form-grid">
|
<form id="ssh-key-rotate-form" class="form-grid">
|
||||||
@ -416,6 +435,10 @@
|
|||||||
<label for="skr-public">Neuer oeffentlicher Schluessel</label>
|
<label for="skr-public">Neuer oeffentlicher Schluessel</label>
|
||||||
<textarea id="skr-public" required></textarea>
|
<textarea id="skr-public" required></textarea>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="full-width">
|
||||||
|
<label for="skr-passphrase">Passphrase des neuen Schluessels (optional)</label>
|
||||||
|
<input type="password" id="skr-passphrase" maxlength="1024" autocomplete="new-password">
|
||||||
|
</div>
|
||||||
<button type="submit" class="btn-secondary">Rotieren</button>
|
<button type="submit" class="btn-secondary">Rotieren</button>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
313
tests/test_phase10.py
Normal file
313
tests/test_phase10.py
Normal file
@ -0,0 +1,313 @@
|
|||||||
|
"""
|
||||||
|
Tests fuer die in "Phase 10" behobenen Ursachen dafuer, dass weder SSH- noch
|
||||||
|
RDP-Sitzungen zustande kamen:
|
||||||
|
|
||||||
|
1) SSH: der hinterlegte private Schluessel war passphrasegeschuetzt.
|
||||||
|
asyncssh.import_private_key() wurde ohne Passphrase aufgerufen und warf
|
||||||
|
KeyImportError("Passphrase must be specified to import encrypted private
|
||||||
|
keys") -- ein ValueError, KEIN asyncssh.Error, der deshalb an allen
|
||||||
|
Fehlerbehandlungen vorbei aus der WS-Route hinauslief. Jetzt: Passphrase
|
||||||
|
wird KEK-verschluesselt mitgespeichert (Migration 0009), beim Laden
|
||||||
|
uebergeben und beim Anlegen/Rotieren/Nachtragen sofort geprueft (400).
|
||||||
|
2) RDP: load_host() selektierte rdp_username/rdp_domain/rdp_require_nla/
|
||||||
|
clipboard_enabled gar nicht -- build_rdp_params() baute daraus eine
|
||||||
|
connect-Instruktion ohne Benutzernamen. Zusaetzlich war ignore-cert hart
|
||||||
|
auf "false" verdrahtet (jetzt hosts.rdp_ignore_cert, Default an).
|
||||||
|
3) Guacamole-Protokoll: Laengenangaben zaehlen ZEICHEN, nicht Bytes -- ein
|
||||||
|
Umlaut (etwa im RDP-Passwort) verschob sonst den gesamten Datenstrom.
|
||||||
|
|
||||||
|
Die WebSocket-Routen selbst lassen sich mit diesem Testsetup (httpx
|
||||||
|
ASGITransport, kein WS-Support) nicht end-to-end fahren; geprueft werden hier
|
||||||
|
die Admin-Endpunkte, die DB-Schicht und die reine Protokoll-/Parameterlogik.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from cryptography.hazmat.primitives import serialization
|
||||||
|
from cryptography.hazmat.primitives.asymmetric import ed25519
|
||||||
|
|
||||||
|
PASSPHRASE = "Testpassphrase-2026"
|
||||||
|
|
||||||
|
|
||||||
|
def _plain_key_pem() -> str:
|
||||||
|
key = ed25519.Ed25519PrivateKey.generate()
|
||||||
|
return key.private_bytes(
|
||||||
|
serialization.Encoding.PEM, serialization.PrivateFormat.OpenSSH,
|
||||||
|
serialization.NoEncryption(),
|
||||||
|
).decode()
|
||||||
|
|
||||||
|
|
||||||
|
def _encrypted_key_pem(passphrase: str = PASSPHRASE) -> str:
|
||||||
|
"""Passphrasegeschuetzter Schluessel.
|
||||||
|
|
||||||
|
Bevorzugt das OpenSSH-Format (das, was `ssh-keygen` erzeugt und was der
|
||||||
|
gemeldete Fehler betraf). Dessen Verschluesselung setzt das optionale
|
||||||
|
bcrypt-Modul voraus; fehlt es, wird auf PKCS#8 ausgewichen -- asyncssh
|
||||||
|
liest beide, und der hier gepruefte Codepfad (fehlende bzw. falsche
|
||||||
|
Passphrase) ist fuer beide identisch."""
|
||||||
|
from cryptography.exceptions import UnsupportedAlgorithm
|
||||||
|
|
||||||
|
key = ed25519.Ed25519PrivateKey.generate()
|
||||||
|
enc = serialization.BestAvailableEncryption(passphrase.encode())
|
||||||
|
try:
|
||||||
|
return key.private_bytes(
|
||||||
|
serialization.Encoding.PEM, serialization.PrivateFormat.OpenSSH, enc
|
||||||
|
).decode()
|
||||||
|
except UnsupportedAlgorithm: # pragma: no cover - haengt an der Umgebung
|
||||||
|
return key.private_bytes(
|
||||||
|
serialization.Encoding.PEM, serialization.PrivateFormat.PKCS8, enc
|
||||||
|
).decode()
|
||||||
|
|
||||||
|
|
||||||
|
async def _create_user(conn, username: str, password: str, *, is_admin: bool = False) -> int:
|
||||||
|
from app.security.passwords import hash_password
|
||||||
|
|
||||||
|
cursor = await conn.execute(
|
||||||
|
"INSERT INTO users (username, password_hash, is_admin, must_change_password) "
|
||||||
|
"VALUES (?, ?, ?, 0)",
|
||||||
|
(username, hash_password(password), int(is_admin)),
|
||||||
|
)
|
||||||
|
await conn.commit()
|
||||||
|
return cursor.lastrowid
|
||||||
|
|
||||||
|
|
||||||
|
async def _login_full(client, username: str, password: str) -> str:
|
||||||
|
import pyotp
|
||||||
|
|
||||||
|
resp = await client.post("/auth/login", json={"username": username, "password": password})
|
||||||
|
assert resp.status_code == 200, resp.text
|
||||||
|
pending = resp.json()["pending_token"]
|
||||||
|
resp = await client.post("/auth/totp/enroll/start", json={"pending_token": pending})
|
||||||
|
assert resp.status_code == 200, resp.text
|
||||||
|
provisioning_uri = resp.json()["provisioning_uri"]
|
||||||
|
secret = dict(part.split("=") for part in provisioning_uri.split("?", 1)[1].split("&"))["secret"]
|
||||||
|
code = pyotp.TOTP(secret).now()
|
||||||
|
resp = await client.post("/auth/totp/enroll/confirm", json={"pending_token": pending, "code": code})
|
||||||
|
assert resp.status_code == 200, resp.text
|
||||||
|
return resp.cookies.get("jh_session")
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 1) SSH-Schluessel mit Passphrase
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_create_ssh_key_rejects_encrypted_key_without_passphrase(client):
|
||||||
|
from app.db import get_db
|
||||||
|
|
||||||
|
conn = get_db()
|
||||||
|
await _create_user(conn, "p10_admin1", "Correct-Horse-Battery-Staple-A1", is_admin=True)
|
||||||
|
await _login_full(client, "p10_admin1", "Correct-Horse-Battery-Staple-A1")
|
||||||
|
|
||||||
|
resp = await client.post("/admin/ssh-keys", json={
|
||||||
|
"label": "verschluesselt-ohne-passphrase",
|
||||||
|
"private_key_pem": _encrypted_key_pem(),
|
||||||
|
"public_key": "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5 test",
|
||||||
|
"key_type": "ed25519",
|
||||||
|
})
|
||||||
|
# Vorher: 201 -- der Fehler fiel erst beim ersten Verbindungsversuch auf.
|
||||||
|
assert resp.status_code == 400, resp.text
|
||||||
|
assert "passphrasegeschuetzt" in resp.json()["detail"].lower()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_create_ssh_key_with_correct_passphrase_succeeds_and_is_stored_encrypted(client):
|
||||||
|
from app.db import get_db
|
||||||
|
from app.security.crypto import decrypt_secret
|
||||||
|
|
||||||
|
conn = get_db()
|
||||||
|
await _create_user(conn, "p10_admin2", "Correct-Horse-Battery-Staple-A2", is_admin=True)
|
||||||
|
await _login_full(client, "p10_admin2", "Correct-Horse-Battery-Staple-A2")
|
||||||
|
|
||||||
|
resp = await client.post("/admin/ssh-keys", json={
|
||||||
|
"label": "verschluesselt-mit-passphrase",
|
||||||
|
"private_key_pem": _encrypted_key_pem(),
|
||||||
|
"public_key": "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5 test",
|
||||||
|
"key_type": "ed25519",
|
||||||
|
"passphrase": PASSPHRASE,
|
||||||
|
})
|
||||||
|
assert resp.status_code == 201, resp.text
|
||||||
|
key_id = resp.json()["id"]
|
||||||
|
|
||||||
|
row = await (await conn.execute(
|
||||||
|
"SELECT passphrase_enc FROM ssh_keys WHERE id = ?", (key_id,)
|
||||||
|
)).fetchone()
|
||||||
|
assert row[0] is not None, "Passphrase wurde nicht gespeichert"
|
||||||
|
assert PASSPHRASE.encode() not in row[0], "Passphrase liegt im Klartext in der DB"
|
||||||
|
assert decrypt_secret(row[0], associated_data=b"ssh_key_passphrase") == PASSPHRASE.encode()
|
||||||
|
|
||||||
|
# Die Liste verraet nur, DASS eine Passphrase hinterlegt ist.
|
||||||
|
listing = (await client.get("/admin/ssh-keys")).json()
|
||||||
|
entry = next(k for k in listing if k["id"] == key_id)
|
||||||
|
assert entry["has_passphrase"] is True
|
||||||
|
assert "passphrase" not in entry, "Endpunkt darf die Passphrase selbst nie zurueckgeben"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_wrong_passphrase_is_rejected_and_correct_one_can_be_added_later(client):
|
||||||
|
from app.db import get_db
|
||||||
|
|
||||||
|
conn = get_db()
|
||||||
|
await _create_user(conn, "p10_admin3", "Correct-Horse-Battery-Staple-A3", is_admin=True)
|
||||||
|
await _login_full(client, "p10_admin3", "Correct-Horse-Battery-Staple-A3")
|
||||||
|
|
||||||
|
pem = _encrypted_key_pem()
|
||||||
|
resp = await client.post("/admin/ssh-keys", json={
|
||||||
|
"label": "nachtragen", "private_key_pem": pem,
|
||||||
|
"public_key": "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5 test",
|
||||||
|
"key_type": "ed25519", "passphrase": PASSPHRASE,
|
||||||
|
})
|
||||||
|
assert resp.status_code == 201, resp.text
|
||||||
|
key_id = resp.json()["id"]
|
||||||
|
|
||||||
|
# Falsche Passphrase nachtragen -> abgelehnt, nichts geaendert
|
||||||
|
resp = await client.put(f"/admin/ssh-keys/{key_id}", json={"passphrase": "falsch"})
|
||||||
|
assert resp.status_code == 400, resp.text
|
||||||
|
|
||||||
|
# Korrekte Passphrase nachtragen -> akzeptiert
|
||||||
|
resp = await client.put(f"/admin/ssh-keys/{key_id}", json={"passphrase": PASSPHRASE})
|
||||||
|
assert resp.status_code == 200, resp.text
|
||||||
|
|
||||||
|
# Ein Update ohne das Feld darf die Passphrase NICHT loeschen
|
||||||
|
resp = await client.put(f"/admin/ssh-keys/{key_id}", json={"label": "umbenannt"})
|
||||||
|
assert resp.status_code == 200, resp.text
|
||||||
|
row = await (await conn.execute(
|
||||||
|
"SELECT passphrase_enc FROM ssh_keys WHERE id = ?", (key_id,)
|
||||||
|
)).fetchone()
|
||||||
|
assert row[0] is not None, "Label-Update hat die Passphrase geloescht"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_load_private_key_for_host_uses_stored_passphrase(client):
|
||||||
|
"""Der konkret gemeldete Fehlerfall, komplett ueber die DB-Schicht."""
|
||||||
|
from app.db import get_db
|
||||||
|
from app.ssh_proxy.proxy import PrivateKeyUnusableError, load_private_key_for_host
|
||||||
|
from app.security.crypto import encrypt_secret
|
||||||
|
|
||||||
|
conn = get_db()
|
||||||
|
cursor = await conn.execute("INSERT INTO host_groups (name) VALUES ('p10-group')")
|
||||||
|
hg_id = cursor.lastrowid
|
||||||
|
cursor = await conn.execute(
|
||||||
|
"INSERT INTO hosts (host_group_id, hostname, address, protocol, port, os_type, ssh_username) "
|
||||||
|
"VALUES (?, 'p10-host', '10.10.0.1', 'ssh', 22, 'linux', 'root')", (hg_id,),
|
||||||
|
)
|
||||||
|
host_id = cursor.lastrowid
|
||||||
|
|
||||||
|
pem = _encrypted_key_pem()
|
||||||
|
cursor = await conn.execute(
|
||||||
|
"INSERT INTO ssh_keys (label, private_key_enc, public_key, key_type, passphrase_enc) "
|
||||||
|
"VALUES ('k', ?, 'pub', 'ed25519', ?)",
|
||||||
|
(encrypt_secret(pem.encode(), associated_data=b"ssh_private_key"),
|
||||||
|
encrypt_secret(PASSPHRASE.encode(), associated_data=b"ssh_key_passphrase")),
|
||||||
|
)
|
||||||
|
key_id = cursor.lastrowid
|
||||||
|
await conn.execute(
|
||||||
|
"INSERT INTO host_ssh_key_map (host_id, ssh_key_id) VALUES (?, ?)", (host_id, key_id)
|
||||||
|
)
|
||||||
|
await conn.commit()
|
||||||
|
|
||||||
|
# Mit hinterlegter Passphrase laedt der Schluessel.
|
||||||
|
assert await load_private_key_for_host(conn, host_id) is not None
|
||||||
|
|
||||||
|
# Ohne sie: klare Meldung statt eines nach aussen durchschlagenden
|
||||||
|
# KeyImportError (das war der gemeldete Abbruch ohne Fehlermeldung).
|
||||||
|
await conn.execute("UPDATE ssh_keys SET passphrase_enc = NULL WHERE id = ?", (key_id,))
|
||||||
|
await conn.commit()
|
||||||
|
with pytest.raises(PrivateKeyUnusableError) as excinfo:
|
||||||
|
await load_private_key_for_host(conn, host_id)
|
||||||
|
assert "passphrasegeschuetzt" in str(excinfo.value).lower()
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 2) RDP-Verbindungsparameter
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_load_host_returns_rdp_columns(client):
|
||||||
|
"""load_host() muss genau die Felder liefern, die build_rdp_params() liest."""
|
||||||
|
from app.db import get_db
|
||||||
|
from app.ssh_proxy.proxy import load_host
|
||||||
|
|
||||||
|
conn = get_db()
|
||||||
|
cursor = await conn.execute("INSERT INTO host_groups (name) VALUES ('p10-rdp-group')")
|
||||||
|
hg_id = cursor.lastrowid
|
||||||
|
cursor = await conn.execute(
|
||||||
|
"INSERT INTO hosts (host_group_id, hostname, address, protocol, port, os_type, "
|
||||||
|
"rdp_username, rdp_domain) VALUES (?, 'win-ts01', '10.10.0.5', 'rdp', 3389, 'windows', "
|
||||||
|
"'Administrator', 'CORP')", (hg_id,),
|
||||||
|
)
|
||||||
|
await conn.commit()
|
||||||
|
host = await load_host(conn, cursor.lastrowid)
|
||||||
|
|
||||||
|
for field in ("rdp_username", "rdp_domain", "rdp_require_nla", "clipboard_enabled",
|
||||||
|
"rdp_ignore_cert"):
|
||||||
|
assert field in host, f"load_host() liefert '{field}' nicht"
|
||||||
|
assert host["rdp_username"] == "Administrator"
|
||||||
|
assert host["rdp_domain"] == "CORP"
|
||||||
|
assert host["rdp_ignore_cert"] is True # Default aus Migration 0009
|
||||||
|
|
||||||
|
|
||||||
|
def test_build_rdp_params_passes_username_and_cert_policy():
|
||||||
|
from app.rdp_proxy.guacd_client import GuacamoleProtocolError, build_rdp_params
|
||||||
|
|
||||||
|
host = {
|
||||||
|
"id": 1, "hostname": "win-ts01", "address": "10.10.0.5", "port": 3389,
|
||||||
|
"file_transfer_enabled": True, "rdp_username": "Administrator",
|
||||||
|
"rdp_domain": "CORP", "rdp_require_nla": True, "clipboard_enabled": True,
|
||||||
|
"rdp_ignore_cert": True,
|
||||||
|
}
|
||||||
|
params = build_rdp_params(host, "geheim")
|
||||||
|
assert params["username"] == "Administrator"
|
||||||
|
assert params["domain"] == "CORP"
|
||||||
|
assert params["security"] == "nla"
|
||||||
|
assert params["ignore-cert"] == "true"
|
||||||
|
assert params["disable-copy"] == "false"
|
||||||
|
|
||||||
|
strict = build_rdp_params(dict(host, rdp_ignore_cert=False, clipboard_enabled=False), "geheim")
|
||||||
|
assert strict["ignore-cert"] == "false"
|
||||||
|
assert strict["disable-copy"] == "true" and strict["disable-paste"] == "true"
|
||||||
|
|
||||||
|
# Kein Benutzername -> klare Meldung statt stiller Fehlanmeldung am Ziel
|
||||||
|
with pytest.raises(GuacamoleProtocolError):
|
||||||
|
build_rdp_params(dict(host, rdp_username=""), "geheim")
|
||||||
|
|
||||||
|
# Unvollstaendiger Hostdatensatz (der alte load_host()-Zustand)
|
||||||
|
with pytest.raises(GuacamoleProtocolError):
|
||||||
|
build_rdp_params({k: host[k] for k in ("id", "hostname", "address", "port",
|
||||||
|
"file_transfer_enabled")}, "geheim")
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 3) Guacamole-Protokoll: Laengen zaehlen Zeichen, nicht Bytes
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def test_instruction_length_counts_characters_not_bytes():
|
||||||
|
from app.rdp_proxy.guacd_client import encode_instruction, parse_instruction_text
|
||||||
|
|
||||||
|
# "Grüße" sind 5 Zeichen, aber 7 Bytes in UTF-8.
|
||||||
|
text = encode_instruction("clipboard", "Grüße")
|
||||||
|
assert text == "9.clipboard,5.Grüße;", text
|
||||||
|
assert parse_instruction_text(text) == ["clipboard", "Grüße"]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_read_instruction_reads_multibyte_characters():
|
||||||
|
import asyncio
|
||||||
|
|
||||||
|
from app.rdp_proxy.guacd_client import encode_instruction, read_instruction
|
||||||
|
|
||||||
|
reader = asyncio.StreamReader()
|
||||||
|
reader.feed_data(encode_instruction("name", "Bürö-Süd").encode("utf-8"))
|
||||||
|
reader.feed_eof()
|
||||||
|
assert await read_instruction(reader) == ["name", "Bürö-Süd"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_internal_opcode_instruction_is_parseable():
|
||||||
|
"""guacamole-common-js sendet ping/UUID mit leerem Opcode ('0.,...')."""
|
||||||
|
from app.rdp_proxy.guacd_client import parse_instruction_text
|
||||||
|
from app.rdp_proxy.ws_tunnel import INTERNAL_DATA_OPCODE
|
||||||
|
|
||||||
|
parsed = parse_instruction_text("0.,4.ping,13.1755721410123;")
|
||||||
|
assert parsed[0] == INTERNAL_DATA_OPCODE
|
||||||
|
assert parsed[1] == "ping"
|
||||||
Reference in New Issue
Block a user