connect fix 4
This commit is contained in:
@ -811,13 +811,13 @@ async def get_host_detail(
|
||||
if row is None:
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, "Host nicht gefunden")
|
||||
keys_cursor = await conn.execute(
|
||||
"SELECT sk.id, sk.label FROM host_ssh_key_map m JOIN ssh_keys sk ON sk.id = m.ssh_key_id "
|
||||
"WHERE m.host_id = ?",
|
||||
"SELECT sk.id, sk.label, sk.username FROM host_ssh_key_map m "
|
||||
"JOIN ssh_keys sk ON sk.id = m.ssh_key_id WHERE m.host_id = ?",
|
||||
(host_id,),
|
||||
)
|
||||
ssh_keys = [{"id": k[0], "label": k[1]} for k in await keys_cursor.fetchall()]
|
||||
ssh_keys = [{"id": k[0], "label": k[1], "username": k[2]} for k in await keys_cursor.fetchall()]
|
||||
rdp_row = await (await conn.execute(
|
||||
"SELECT updated_at FROM rdp_credentials WHERE host_id = ?", (host_id,)
|
||||
"SELECT updated_at, username, domain FROM rdp_credentials WHERE host_id = ?", (host_id,)
|
||||
)).fetchone()
|
||||
return {
|
||||
"id": row[0], "hostname": row[1], "address": row[2], "protocol": row[3], "port": row[4],
|
||||
@ -829,6 +829,10 @@ async def get_host_detail(
|
||||
"ssh_keys": ssh_keys,
|
||||
"rdp_credentials_set": rdp_row is not None,
|
||||
"rdp_credentials_updated_at": rdp_row[0] if rdp_row else None,
|
||||
# Benutzername/Domaene gehoeren seit Migration 0010 zu den
|
||||
# Zugangsdaten; sie werden hier nur zur Anzeige mitgeliefert.
|
||||
"rdp_credentials_username": rdp_row[1] if rdp_row else None,
|
||||
"rdp_credentials_domain": rdp_row[2] if rdp_row else None,
|
||||
}
|
||||
|
||||
|
||||
@ -955,17 +959,28 @@ async def set_rdp_credentials(
|
||||
conn = get_db()
|
||||
if admin.is_any_admin:
|
||||
await _assert_host_in_scope(conn, _scope(admin), host_id)
|
||||
username = (payload.username or "").strip() or None
|
||||
if username is None:
|
||||
raise HTTPException(
|
||||
status.HTTP_400_BAD_REQUEST,
|
||||
"Bitte den Windows-Benutzernamen mit angeben -- er gehoert zu den "
|
||||
"Zugangsdaten und wird fuer die Anmeldung am Ziel gebraucht.",
|
||||
)
|
||||
domain = (payload.domain or "").strip() or None
|
||||
encrypted = encrypt_secret(payload.password.encode(), associated_data=b"rdp_password")
|
||||
await conn.execute(
|
||||
"INSERT INTO rdp_credentials (host_id, password_enc, updated_at) "
|
||||
"VALUES (?, ?, strftime('%Y-%m-%dT%H:%M:%fZ','now')) "
|
||||
"INSERT INTO rdp_credentials (host_id, password_enc, username, domain, updated_at) "
|
||||
"VALUES (?, ?, ?, ?, strftime('%Y-%m-%dT%H:%M:%fZ','now')) "
|
||||
"ON CONFLICT(host_id) DO UPDATE SET password_enc = excluded.password_enc, "
|
||||
"username = excluded.username, domain = excluded.domain, "
|
||||
"updated_at = excluded.updated_at",
|
||||
(host_id, encrypted),
|
||||
(host_id, encrypted, username, domain),
|
||||
)
|
||||
await write_audit_event(
|
||||
conn, event_type="rdp_credentials_set", user_id=admin.id, client_ip=_client_ip(request),
|
||||
details={"host_id": host_id},
|
||||
# Benutzername/Domaene sind keine Geheimnisse (das Passwort schon) und
|
||||
# gehoeren ins Audit-Log.
|
||||
details={"host_id": host_id, "username": username, "domain": domain},
|
||||
)
|
||||
await conn.commit()
|
||||
return {"status": "ok"}
|
||||
@ -1011,18 +1026,20 @@ async def get_host_credentials(
|
||||
if host_row is None:
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, "Host nicht gefunden")
|
||||
keys_cursor = await conn.execute(
|
||||
"SELECT sk.id, sk.label FROM host_ssh_key_map m JOIN ssh_keys sk ON sk.id = m.ssh_key_id "
|
||||
"WHERE m.host_id = ?",
|
||||
"SELECT sk.id, sk.label, sk.username FROM host_ssh_key_map m "
|
||||
"JOIN ssh_keys sk ON sk.id = m.ssh_key_id WHERE m.host_id = ?",
|
||||
(host_id,),
|
||||
)
|
||||
ssh_keys = [{"id": k[0], "label": k[1]} for k in await keys_cursor.fetchall()]
|
||||
ssh_keys = [{"id": k[0], "label": k[1], "username": k[2]} for k in await keys_cursor.fetchall()]
|
||||
rdp_row = await (await conn.execute(
|
||||
"SELECT updated_at FROM rdp_credentials WHERE host_id = ?", (host_id,)
|
||||
"SELECT updated_at, username, domain FROM rdp_credentials WHERE host_id = ?", (host_id,)
|
||||
)).fetchone()
|
||||
return {
|
||||
"host_id": host_id, "ssh_keys": ssh_keys,
|
||||
"rdp_credentials_set": rdp_row is not None,
|
||||
"rdp_credentials_updated_at": rdp_row[0] if rdp_row else None,
|
||||
"rdp_credentials_username": rdp_row[1] if rdp_row else None,
|
||||
"rdp_credentials_domain": rdp_row[2] if rdp_row else None,
|
||||
}
|
||||
|
||||
|
||||
@ -1034,7 +1051,7 @@ async def list_rdp_credentials(admin: CurrentUser = Depends(require_admin_or_sco
|
||||
scope = _scope(admin)
|
||||
tenant_filter, params = scope.sql_filter("hg.tenant_id")
|
||||
cursor = await conn.execute(
|
||||
"SELECT h.id, h.hostname, h.address, hg.name, rc.updated_at "
|
||||
"SELECT h.id, h.hostname, h.address, hg.name, rc.updated_at, rc.username, rc.domain "
|
||||
"FROM hosts h JOIN host_groups hg ON hg.id = h.host_group_id "
|
||||
"LEFT JOIN rdp_credentials rc ON rc.host_id = h.id "
|
||||
f"WHERE h.protocol = 'rdp'{tenant_filter} ORDER BY h.hostname",
|
||||
@ -1045,6 +1062,7 @@ async def list_rdp_credentials(admin: CurrentUser = Depends(require_admin_or_sco
|
||||
{
|
||||
"host_id": r[0], "hostname": r[1], "address": r[2], "host_group_name": r[3],
|
||||
"credentials_set": r[4] is not None, "updated_at": r[4],
|
||||
"username": r[5], "domain": r[6],
|
||||
}
|
||||
for r in rows
|
||||
]
|
||||
@ -1274,9 +1292,9 @@ async def create_ssh_key(
|
||||
)
|
||||
cursor = await conn.execute(
|
||||
"INSERT INTO ssh_keys (label, owner_user_id, private_key_enc, public_key, key_type, "
|
||||
"tenant_id, passphrase_enc) VALUES (?, ?, ?, ?, ?, ?, ?)",
|
||||
"tenant_id, passphrase_enc, username) VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
|
||||
(payload.label, payload.owner_user_id, encrypted, payload.public_key, payload.key_type,
|
||||
tenant_id, passphrase_enc),
|
||||
tenant_id, passphrase_enc, (payload.username or "").strip() or None),
|
||||
)
|
||||
new_id = cursor.lastrowid
|
||||
await write_audit_event(
|
||||
@ -1284,6 +1302,9 @@ async def create_ssh_key(
|
||||
details={
|
||||
"id": new_id, "label": payload.label, "key_type": payload.key_type,
|
||||
"has_passphrase": bool(payload.passphrase),
|
||||
# Der Anmeldename ist kein Geheimnis und im Audit-Log ausdruecklich
|
||||
# erwuenscht: er beantwortet "wer hat sich womit angemeldet".
|
||||
"username": (payload.username or "").strip() or None,
|
||||
},
|
||||
)
|
||||
await conn.commit()
|
||||
@ -1297,7 +1318,7 @@ async def list_ssh_keys(admin: CurrentUser = Depends(require_admin_or_scope("ssh
|
||||
tenant_filter, params = scope.sql_filter("sk.tenant_id")
|
||||
cursor = await conn.execute(
|
||||
"SELECT sk.id, sk.label, sk.owner_user_id, sk.key_type, sk.created_at, sk.rotated_at, "
|
||||
"sk.expires_at, sk.tenant_id, t.name, (sk.passphrase_enc IS NOT NULL) "
|
||||
"sk.expires_at, sk.tenant_id, t.name, (sk.passphrase_enc IS NOT NULL), sk.username "
|
||||
"FROM ssh_keys sk JOIN tenants t ON t.id = sk.tenant_id "
|
||||
f"WHERE 1=1{tenant_filter} ORDER BY sk.id",
|
||||
params,
|
||||
@ -1311,6 +1332,9 @@ async def list_ssh_keys(admin: CurrentUser = Depends(require_admin_or_scope("ssh
|
||||
# Nur die Tatsache, NIE die Passphrase selbst -- kein Endpunkt
|
||||
# dieser Anwendung gibt jemals Klartext-Geheimnisse zurueck.
|
||||
"has_passphrase": bool(r[9]),
|
||||
# Anmeldename des Zielsystems (Migration 0010). Kein Geheimnis --
|
||||
# er wird angezeigt, damit erkennbar ist, welcher Zugang das ist.
|
||||
"username": r[10],
|
||||
}
|
||||
for r in rows
|
||||
]
|
||||
@ -1371,6 +1395,10 @@ async def update_ssh_key(
|
||||
fields.append("label = ?"); values.append(payload.label)
|
||||
if "owner_user_id" in payload.model_fields_set:
|
||||
fields.append("owner_user_id = ?"); values.append(payload.owner_user_id)
|
||||
if "username" in payload.model_fields_set:
|
||||
# Gleiche Semantik wie bei passphrase: Weglassen = unveraendert,
|
||||
# explizites null/"" = entfernen.
|
||||
fields.append("username = ?"); values.append((payload.username or "").strip() or None)
|
||||
if rotating:
|
||||
encrypted = encrypt_secret(payload.private_key_pem.encode(), associated_data=b"ssh_private_key")
|
||||
fields += ["private_key_enc = ?", "public_key = ?", "key_type = ?",
|
||||
@ -1396,6 +1424,7 @@ async def update_ssh_key(
|
||||
"id": key_id, "rotated": rotating,
|
||||
"passphrase_changed": bool(rotating or passphrase_given),
|
||||
"has_passphrase": bool(passphrase),
|
||||
"username_changed": "username" in payload.model_fields_set,
|
||||
},
|
||||
)
|
||||
await conn.commit()
|
||||
|
||||
55
app/db/migrations/0010_credential_usernames.sql
Normal file
55
app/db/migrations/0010_credential_usernames.sql
Normal file
@ -0,0 +1,55 @@
|
||||
-- Phase 12: Benutzernamen gehoeren zu den ZUGANGSDATEN, nicht zum Host.
|
||||
--
|
||||
-- Bisher standen ssh_username bzw. rdp_username/rdp_domain in der Tabelle
|
||||
-- hosts -- also beim "Server". Fachlich falsch: der Benutzername ist Teil der
|
||||
-- Anmeldung (er gehoert zum Schluessel bzw. zum Passwort), nicht zur
|
||||
-- Beschreibung des Zielsystems. Ab jetzt:
|
||||
-- * SSH: ssh_keys.username (ein Benutzername je Schluessel)
|
||||
-- * RDP: rdp_credentials.username / .domain (je Host-Passwortsatz)
|
||||
-- Die alten hosts-Spalten bleiben im Schema (SQLite-Spalten zu entfernen
|
||||
-- erzwingt einen Tabellen-Rebuild, siehe die ausfuehrliche Begruendung in
|
||||
-- 0006_tenants.sql) und werden nur noch als Fallback fuer Datensaetze
|
||||
-- gelesen, die vor dieser Migration angelegt und noch nicht umgestellt
|
||||
-- wurden. Neue Schreibpfade fassen sie nicht mehr an, die Admin-Oberflaeche
|
||||
-- zeigt sie nicht mehr an.
|
||||
|
||||
ALTER TABLE ssh_keys ADD COLUMN username TEXT;
|
||||
ALTER TABLE rdp_credentials ADD COLUMN username TEXT;
|
||||
ALTER TABLE rdp_credentials ADD COLUMN domain TEXT;
|
||||
|
||||
-- Uebernahme der bestehenden Werte:
|
||||
--
|
||||
-- SSH: nur wenn ALLE Hosts, denen der Schluessel zugeordnet ist, denselben
|
||||
-- Benutzernamen tragen (COUNT(DISTINCT ...) = 1). Waeren es mehrere, waere
|
||||
-- jede automatische Wahl geraten -- solche Schluessel bleiben leer und
|
||||
-- greifen weiter auf den Host-Fallback zurueck, bis ein Admin den
|
||||
-- Benutzernamen am Schluessel setzt (die Oberflaeche weist darauf hin).
|
||||
UPDATE ssh_keys
|
||||
SET username = (
|
||||
SELECT h.ssh_username
|
||||
FROM host_ssh_key_map m JOIN hosts h ON h.id = m.host_id
|
||||
WHERE m.ssh_key_id = ssh_keys.id
|
||||
AND h.ssh_username IS NOT NULL AND TRIM(h.ssh_username) <> ''
|
||||
ORDER BY h.id LIMIT 1)
|
||||
WHERE username IS NULL
|
||||
AND (SELECT COUNT(DISTINCT h.ssh_username)
|
||||
FROM host_ssh_key_map m JOIN hosts h ON h.id = m.host_id
|
||||
WHERE m.ssh_key_id = ssh_keys.id
|
||||
AND h.ssh_username IS NOT NULL AND TRIM(h.ssh_username) <> '') = 1;
|
||||
|
||||
-- RDP: eindeutig, weil rdp_credentials ohnehin genau einen Datensatz pro Host
|
||||
-- haelt (host_id ist Primaerschluessel).
|
||||
UPDATE rdp_credentials
|
||||
SET username = (SELECT h.rdp_username FROM hosts h WHERE h.id = rdp_credentials.host_id),
|
||||
domain = (SELECT h.rdp_domain FROM hosts h WHERE h.id = rdp_credentials.host_id)
|
||||
WHERE username IS NULL;
|
||||
|
||||
-- hosts.ssh_host_key: der VOLLSTAENDIGE Host-Key im OpenSSH-Format, nicht nur
|
||||
-- sein Fingerprint. Grund: mit dem Fingerprint allein laesst sich eine
|
||||
-- Verbindung nicht vorab pinnen -- man sieht den Schluessel erst, wenn der
|
||||
-- Server ihn praesentiert hat. Mit dem gespeicherten Schluessel kann der
|
||||
-- Jumphost den vom Ziel angebotenen Schluessel VOR der Anmeldung byteweise
|
||||
-- vergleichen (siehe app/ssh_proxy/proxy.py). Wird beim "Host-Key ermitteln"
|
||||
-- mitgeschrieben und fuer Altbestand beim naechsten erfolgreichen
|
||||
-- Verbindungsaufbau nachgetragen.
|
||||
ALTER TABLE hosts ADD COLUMN ssh_host_key TEXT;
|
||||
22
app/main.py
22
app/main.py
@ -21,10 +21,31 @@ from app.catalog.routes import router as catalog_router
|
||||
from app.db import close_db, init_db
|
||||
from app.rdp_proxy.ws_tunnel import router as rdp_ws_router
|
||||
from app.security import log_stream
|
||||
from app.ssh_proxy.proxy import bcrypt_kdf_available
|
||||
from app.ssh_proxy.sftp import router as sftp_router
|
||||
from app.ssh_proxy.terminal_ws import router as ssh_ws_router
|
||||
|
||||
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(name)s: %(message)s")
|
||||
logger = logging.getLogger("jumphost.main")
|
||||
|
||||
|
||||
def _check_optional_dependencies() -> None:
|
||||
"""Meldet fehlende, formal optionale Laufzeitabhaengigkeiten beim Start.
|
||||
|
||||
Bislang fiel das Fehlen von 'bcrypt' erst auf, wenn ein Benutzer eine
|
||||
SSH-Sitzung startete -- und dann als englische asyncssh-Meldung, die nach
|
||||
einem defekten Schluessel aussah. Die Warnung laeuft absichtlich NACH
|
||||
log_stream.install(), damit sie auch im Live-Verbindungslog des
|
||||
Adminbereichs sichtbar ist.
|
||||
"""
|
||||
if not bcrypt_kdf_available():
|
||||
logger.error(
|
||||
"Das Python-Modul 'bcrypt' fehlt im Virtualenv. Passphrasegeschuetzte "
|
||||
"SSH-Schluessel im OpenSSH-Format (ssh-keygen-Standard) koennen damit "
|
||||
"NICHT geladen werden. Behebung: pip install -r requirements.txt "
|
||||
"(bzw. Ansible-Rolle python_runtime erneut ausrollen) und Dienst "
|
||||
"neu starten."
|
||||
)
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
@ -33,6 +54,7 @@ async def lifespan(app: FastAPI):
|
||||
# app/security/log_stream.py) -- MUSS vor init_db() installiert werden,
|
||||
# damit auch fruehe Startmeldungen im Puffer landen.
|
||||
log_stream.install()
|
||||
_check_optional_dependencies()
|
||||
await init_db()
|
||||
yield
|
||||
await close_db()
|
||||
|
||||
@ -114,9 +114,15 @@ class HostCreateRequest(BaseModel):
|
||||
port: int = Field(gt=0, le=65535)
|
||||
os_type: Literal["linux", "windows"]
|
||||
ssh_host_key_fingerprint: str | None = Field(default=None, max_length=512)
|
||||
ssh_username: str | None = Field(default=None, max_length=128)
|
||||
rdp_username: str | None = Field(default=None, max_length=128)
|
||||
rdp_domain: str | None = Field(default=None, max_length=128)
|
||||
# ---- Altlast (vor Migration 0010) -------------------------------------
|
||||
# Benutzername und Domaene gehoeren zu den ZUGANGSDATEN (ssh_keys.username
|
||||
# bzw. rdp_credentials.username/.domain) und nicht zum Host. Die Felder
|
||||
# bleiben nur entgegennahmefaehig, damit bestehende Skripte/Tokens nicht
|
||||
# brechen; die Admin-Oberflaeche sendet sie nicht mehr, und der
|
||||
# Verbindungsaufbau liest sie nur noch als Fallback.
|
||||
ssh_username: str | None = Field(default=None, max_length=128, deprecated=True)
|
||||
rdp_username: str | None = Field(default=None, max_length=128, deprecated=True)
|
||||
rdp_domain: str | None = Field(default=None, max_length=128, deprecated=True)
|
||||
rdp_require_nla: bool = True
|
||||
clipboard_enabled: bool = True
|
||||
file_transfer_enabled: bool = True
|
||||
@ -139,9 +145,10 @@ class HostUpdateRequest(BaseModel):
|
||||
hostname: str | None = Field(default=None, min_length=1, max_length=128)
|
||||
address: str | None = Field(default=None, min_length=1, max_length=255)
|
||||
port: int | None = Field(default=None, gt=0, le=65535)
|
||||
ssh_username: str | None = None
|
||||
rdp_username: str | None = None
|
||||
rdp_domain: str | None = None
|
||||
# Siehe HostCreateRequest: Altlast, gehoert zu den Zugangsdaten.
|
||||
ssh_username: str | None = Field(default=None, deprecated=True)
|
||||
rdp_username: str | None = Field(default=None, deprecated=True)
|
||||
rdp_domain: str | None = Field(default=None, deprecated=True)
|
||||
rdp_require_nla: bool | None = None
|
||||
clipboard_enabled: bool | None = None
|
||||
file_transfer_enabled: bool | None = None
|
||||
@ -214,6 +221,10 @@ class SshKeyCreateRequest(BaseModel):
|
||||
public_key: str = Field(min_length=1, max_length=8192)
|
||||
key_type: Literal["ed25519", "rsa-3072", "rsa-4096", "ca-cert"]
|
||||
tenant_id: int | None = None
|
||||
# Anmeldename am Zielsystem. Gehoert zum Schluessel, nicht zum Host
|
||||
# (Migration 0010): ein Schluessel ist immer "der Zugang von Benutzer X".
|
||||
# Fuer mehrere Konten wird je ein Schluesseleintrag angelegt.
|
||||
username: str | None = Field(default=None, max_length=128)
|
||||
# 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
|
||||
@ -230,6 +241,10 @@ class SshKeyUpdateRequest(BaseModel):
|
||||
private_key_pem: str | None = Field(default=None, min_length=1, max_length=32_768)
|
||||
public_key: str | None = Field(default=None, min_length=1, max_length=8192)
|
||||
key_type: Literal["ed25519", "rsa-3072", "rsa-4096", "ca-cert"] | None = None
|
||||
# Wird -- wie passphrase -- nur beachtet, wenn das Feld im Request
|
||||
# vorkommt: Weglassen laesst den Benutzernamen unveraendert, explizites
|
||||
# null/"" entfernt ihn.
|
||||
username: str | None = Field(default=None, max_length=128)
|
||||
# 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
|
||||
@ -244,3 +259,7 @@ class ConnectRequest(BaseModel):
|
||||
|
||||
class RdpCredentialsRequest(BaseModel):
|
||||
password: str = Field(min_length=1, max_length=512)
|
||||
# Benutzername und (optional) Domaene der Windows-Anmeldung. Seit
|
||||
# Migration 0010 Teil der Zugangsdaten statt des Hosts.
|
||||
username: str | None = Field(default=None, max_length=128)
|
||||
domain: str | None = Field(default=None, max_length=128)
|
||||
|
||||
@ -162,7 +162,13 @@ async def open_tunnel(
|
||||
return GuacdTunnel(reader, writer, connection_id)
|
||||
|
||||
|
||||
def build_rdp_params(host: dict, password: str) -> dict[str, str]:
|
||||
def build_rdp_params(
|
||||
host: dict,
|
||||
password: str,
|
||||
*,
|
||||
username: str | None = None,
|
||||
domain: str | None = None,
|
||||
) -> dict[str, str]:
|
||||
"""Baut die Parameter-Map fuer die connect-Instruktion aus dem Host-Datensatz.
|
||||
|
||||
Sicherheitsdefaults (siehe Konzept 6.3/6.7): NLA wird erzwungen sofern
|
||||
@ -171,17 +177,19 @@ def build_rdp_params(host: dict, password: str) -> dict[str, str]:
|
||||
ohne interne PKI praesentiert jedes Windows-Ziel ein selbstsigniertes
|
||||
Zertifikat, guacd/FreeRDP bricht dann bereits vor dem ersten Bild ab.
|
||||
|
||||
`username`/`domain` kommen aus rdp_credentials (Migration 0010) -- der
|
||||
Benutzername gehoert zur Anmeldung, nicht zum Zielsystem. Fehlen sie, wird
|
||||
auf hosts.rdp_username/rdp_domain zurueckgegriffen (Altbestand).
|
||||
|
||||
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.
|
||||
Funktion liest rdp_require_nla/clipboard_enabled/rdp_ignore_cert -- genau
|
||||
diese Spalten hat load_host() frueher NICHT selektiert, wodurch hier still
|
||||
eine unbrauchbare connect-Instruktion entstand. 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")
|
||||
key for key in ("rdp_require_nla", "clipboard_enabled", "rdp_ignore_cert")
|
||||
if key not in host
|
||||
]
|
||||
if missing:
|
||||
@ -189,20 +197,25 @@ def build_rdp_params(host: dict, password: str) -> dict[str, str]:
|
||||
f"Hostdatensatz unvollstaendig, fehlende Felder: {', '.join(missing)}"
|
||||
)
|
||||
|
||||
username = (host.get("rdp_username") or "").strip()
|
||||
if not username:
|
||||
# Benutzername/Domaene kommen seit Migration 0010 aus den Zugangsdaten
|
||||
# (rdp_credentials). Der Wert am Host wird nur noch als Fallback fuer
|
||||
# Datensaetze gelesen, die vor der Migration angelegt wurden.
|
||||
effective_username = (username or "").strip() or (host.get("rdp_username") or "").strip()
|
||||
if not effective_username:
|
||||
raise GuacamoleProtocolError(
|
||||
"Fuer diesen Host ist kein RDP-Benutzername hinterlegt "
|
||||
"(Adminbereich -> Server -> RDP-Benutzer)."
|
||||
"Fuer diesen Host ist kein RDP-Benutzername hinterlegt. Der Benutzername "
|
||||
"gehoert zu den Zugangsdaten (Adminbereich -> Zugangsdaten -> "
|
||||
"RDP/Windows-Passwoerter)."
|
||||
)
|
||||
effective_domain = (domain or "").strip() or (host.get("rdp_domain") or "").strip()
|
||||
|
||||
clipboard_enabled = bool(host.get("clipboard_enabled"))
|
||||
return {
|
||||
"hostname": host["address"],
|
||||
"port": str(host["port"]),
|
||||
"username": username,
|
||||
"username": effective_username,
|
||||
"password": password,
|
||||
"domain": host.get("rdp_domain") or "",
|
||||
"domain": effective_domain,
|
||||
"security": "nla" if host.get("rdp_require_nla", True) else "any",
|
||||
"ignore-cert": "true" if host.get("rdp_ignore_cert", True) else "false",
|
||||
"disable-audio": "true",
|
||||
|
||||
@ -174,8 +174,12 @@ async def rdp_tunnel(
|
||||
)
|
||||
return
|
||||
|
||||
# username/domain stehen seit Migration 0010 bei den Zugangsdaten und nicht
|
||||
# mehr am Host -- der Benutzername gehoert zur Anmeldung, nicht zur
|
||||
# Beschreibung des Zielsystems.
|
||||
cred_cursor = await conn.execute(
|
||||
"SELECT password_enc FROM rdp_credentials WHERE host_id = ?", (host_id,)
|
||||
"SELECT password_enc, username, domain FROM rdp_credentials WHERE host_id = ?",
|
||||
(host_id,),
|
||||
)
|
||||
cred_row = await cred_cursor.fetchone()
|
||||
if cred_row is None:
|
||||
@ -221,7 +225,9 @@ async def rdp_tunnel(
|
||||
# riss ein Fehler hier VOR jeglicher Protokollierung durch und die
|
||||
# Sitzung verschwand spurlos (schwarzer Bildschirm, kein Log-Eintrag).
|
||||
password = decrypt_secret(cred_row[0], associated_data=b"rdp_password")
|
||||
params = build_rdp_params(host, password.decode())
|
||||
params = build_rdp_params(
|
||||
host, password.decode(), username=cred_row[1], domain=cred_row[2]
|
||||
)
|
||||
tunnel = await open_tunnel(
|
||||
guacd_host=settings.guacd_host, guacd_port=settings.guacd_port,
|
||||
protocol="rdp", params=params, screen_width=width, screen_height=height, dpi=dpi,
|
||||
|
||||
@ -44,6 +44,23 @@ class HostKeyDiscoveryError(Exception):
|
||||
super().__init__(f"Host-Key-Ermittlung fuer Host {host_id} fehlgeschlagen: {reason}")
|
||||
|
||||
|
||||
class HostKeyNotPinnedError(Exception):
|
||||
"""Fuer den Host ist kein Host-Key hinterlegt.
|
||||
|
||||
Bewusst ein harter Abbruch statt Trust-on-First-Use: ohne gepinnten
|
||||
Schluessel laesst sich nicht feststellen, ob am anderen Ende das
|
||||
erwartete System antwortet (Konzept 4.2/6.4).
|
||||
"""
|
||||
|
||||
def __init__(self, host_id: int) -> None:
|
||||
self.host_id = host_id
|
||||
super().__init__(
|
||||
"Fuer diesen Host ist kein SSH-Host-Key hinterlegt. Im Adminbereich unter "
|
||||
"'Server' -> Host-Key ermitteln einmalig uebernehmen (bewusste "
|
||||
"Vertrauensentscheidung), danach ist die Verbindung moeglich."
|
||||
)
|
||||
|
||||
|
||||
class PrivateKeyUnusableError(Exception):
|
||||
"""Der hinterlegte private Schluessel laesst sich nicht laden.
|
||||
|
||||
@ -62,6 +79,35 @@ class PrivateKeyUnusableError(Exception):
|
||||
super().__init__(message)
|
||||
|
||||
|
||||
BCRYPT_MISSING_MESSAGE = (
|
||||
"Der private Schluessel ist passphrasegeschuetzt im OpenSSH-Format, auf dem "
|
||||
"Server fehlt aber das Python-Modul 'bcrypt' -- asyncssh kann die "
|
||||
"Schluesseldatei deshalb gar nicht erst entschluesseln. Weder der Schluessel "
|
||||
"noch die hinterlegte Passphrase sind schuld. Abhilfe: 'bcrypt' im "
|
||||
"Virtualenv der Anwendung nachinstallieren (pip install -r requirements.txt "
|
||||
"bzw. Ansible-Rolle python_runtime erneut ausrollen) und den Dienst "
|
||||
"jumphost-app neu starten."
|
||||
)
|
||||
|
||||
|
||||
def bcrypt_kdf_available() -> bool:
|
||||
"""Ist das (formal optionale) bcrypt-Modul mit KDF-Unterstuetzung da?
|
||||
|
||||
asyncssh und cryptography benoetigen es fuer die bcrypt_pbkdf-Ableitung,
|
||||
mit der OpenSSH verschluesselte Schluesseldateien schuetzt -- also fuer
|
||||
genau das Format, das 'ssh-keygen' standardmaessig erzeugt. Fehlt das
|
||||
Modul, scheitert JEDER passphrasegeschuetzte Schluessel mit
|
||||
"OpenSSH private key encryption requires bcrypt with KDF support", obwohl
|
||||
Schluessel und Passphrase korrekt sind. Es stand bis Phase 11 nicht in
|
||||
requirements.txt und fehlte deshalb im ausgerollten Virtualenv.
|
||||
"""
|
||||
try:
|
||||
import bcrypt # noqa: PLC0415 - bewusst lazy, das Modul ist optional
|
||||
except ImportError:
|
||||
return False
|
||||
return hasattr(bcrypt, "kdf")
|
||||
|
||||
|
||||
def import_private_key_material(
|
||||
pem: bytes | str, passphrase: bytes | str | None = None
|
||||
) -> asyncssh.SSHKey:
|
||||
@ -96,6 +142,18 @@ def import_private_key_material(
|
||||
# wird hier korrekterweise nicht mitgefangen.
|
||||
text = str(exc)
|
||||
|
||||
if "bcrypt" in text.lower():
|
||||
# Umgebungsfehler, kein Benutzerfehler: ohne bcrypt kann asyncssh
|
||||
# OpenSSH-verschluesselte Schluessel grundsaetzlich nicht lesen.
|
||||
# Ohne diese Uebersetzung landete die englische asyncssh-Meldung
|
||||
# ungefiltert im Terminalfenster und sah nach einem kaputten
|
||||
# Schluessel aus.
|
||||
logger.error(
|
||||
"Import eines privaten Schluessels fehlgeschlagen: bcrypt fehlt im "
|
||||
"Virtualenv (asyncssh-Meldung: %s)", text,
|
||||
)
|
||||
raise PrivateKeyUnusableError(BCRYPT_MISSING_MESSAGE) from exc
|
||||
|
||||
if "Passphrase must be specified" in text:
|
||||
raise PrivateKeyUnusableError(
|
||||
"Der private Schluessel ist passphrasegeschuetzt, es ist aber keine "
|
||||
@ -123,9 +181,41 @@ def import_private_key_material(
|
||||
) from exc
|
||||
|
||||
|
||||
#: Fehler, die den Aufbau einer SSH-Sitzung verhindern und dem angemeldeten
|
||||
#: Benutzer im Klartext gezeigt werden duerfen (keine Geheimnisse, nur
|
||||
#: Konfigurations-/Erreichbarkeitsaussagen). Wird von terminal_ws.py und
|
||||
#: sftp.py gemeinsam benutzt, damit beide Wege dieselbe Meldung zeigen.
|
||||
SSH_SETUP_ERRORS: tuple[type[Exception], ...] = (
|
||||
HostNotConfiguredError,
|
||||
HostKeyNotPinnedError,
|
||||
HostKeyMismatchError,
|
||||
HostKeyDiscoveryError,
|
||||
PrivateKeyUnusableError,
|
||||
)
|
||||
|
||||
|
||||
def describe_connection_error(exc: Exception) -> str:
|
||||
"""Deutsche Klartextmeldung zu einem Verbindungsfehler."""
|
||||
if isinstance(exc, HostKeyMismatchError):
|
||||
return (
|
||||
"Der SSH-Host-Key des Ziels weicht vom hinterlegten ab -- die Verbindung wurde "
|
||||
"abgebrochen, bevor Zugangsdaten gesendet wurden. "
|
||||
f"Erwartet: {exc.expected}; angeboten: {exc.observed}. Wurde das Zielsystem neu "
|
||||
"aufgesetzt, den Host-Key im Adminbereich erneut ermitteln."
|
||||
)
|
||||
if isinstance(exc, HostKeyDiscoveryError):
|
||||
return f"Das Ziel ist nicht erreichbar: {exc.reason}"
|
||||
return str(exc)
|
||||
|
||||
|
||||
class _PinnedHostKeyClient(asyncssh.SSHClient):
|
||||
"""Erzwingt Strict Host Key Checking gegen einen fest hinterlegten
|
||||
SHA-256-Fingerprint. Kein automatisches Trust-on-First-Use (TOFU)."""
|
||||
"""Zweite Verteidigungslinie fuer Strict Host Key Checking.
|
||||
|
||||
ACHTUNG: asyncssh ruft validate_host_public_key() nur auf, wenn
|
||||
known_hosts NICHT None ist -- diese Klasse allein hat das Pinning also
|
||||
nicht durchgesetzt (siehe _verified_host_key(), das die eigentliche
|
||||
Pruefung vor der Anmeldung uebernimmt). Sie bleibt erhalten, weil sie in
|
||||
Konfigurationen mit gesetztem known_hosts greift und nie schadet."""
|
||||
|
||||
def __init__(self, expected_fingerprint: str | None, *, discovery_mode: bool = False) -> None:
|
||||
self.expected_fingerprint = expected_fingerprint
|
||||
@ -147,7 +237,8 @@ async def load_host(conn: aiosqlite.Connection, host_id: int) -> dict:
|
||||
cursor = await conn.execute(
|
||||
"SELECT id, hostname, address, port, os_type, protocol, ssh_host_key_fingerprint, "
|
||||
"ssh_username, file_transfer_enabled, host_group_id, "
|
||||
"rdp_username, rdp_domain, rdp_require_nla, clipboard_enabled, rdp_ignore_cert "
|
||||
"rdp_username, rdp_domain, rdp_require_nla, clipboard_enabled, rdp_ignore_cert, "
|
||||
"ssh_host_key "
|
||||
"FROM hosts WHERE id = ? AND is_active = 1",
|
||||
(host_id,),
|
||||
)
|
||||
@ -166,6 +257,11 @@ async def load_host(conn: aiosqlite.Connection, host_id: int) -> dict:
|
||||
# dauerhaft gesperrt, unabhaengig von der Hostkonfiguration).
|
||||
"rdp_username", "rdp_domain", "rdp_require_nla", "clipboard_enabled",
|
||||
"rdp_ignore_cert",
|
||||
# Vollstaendiger Host-Key (Migration 0010): erlaubt es, den vom Ziel
|
||||
# angebotenen Schluessel VOR der Anmeldung byteweise zu vergleichen --
|
||||
# mit einem Fingerprint allein geht das nicht, den sieht man erst,
|
||||
# wenn der Server seinen Schluessel bereits praesentiert hat.
|
||||
"ssh_host_key",
|
||||
)
|
||||
host = dict(zip(keys, row))
|
||||
for flag in ("file_transfer_enabled", "rdp_require_nla", "clipboard_enabled", "rdp_ignore_cert"):
|
||||
@ -173,17 +269,20 @@ async def load_host(conn: aiosqlite.Connection, host_id: int) -> dict:
|
||||
return host
|
||||
|
||||
|
||||
async def load_private_key_for_host(conn: aiosqlite.Connection, host_id: int) -> asyncssh.SSHKey:
|
||||
"""Laedt den dem Host zugeordneten Schluessel und entschluesselt ihn.
|
||||
async def load_ssh_credential_for_host(
|
||||
conn: aiosqlite.Connection, host_id: int
|
||||
) -> tuple[asyncssh.SSHKey, str | None]:
|
||||
"""Laedt den dem Host zugeordneten Schluessel, entschluesselt ihn und gibt
|
||||
ihn zusammen mit dem am Schluessel hinterlegten Benutzernamen zurueck.
|
||||
|
||||
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.
|
||||
Der Benutzername gehoert seit Migration 0010 zu den Zugangsdaten
|
||||
(ssh_keys.username) und nicht mehr zum Host: er ist Teil der Anmeldung,
|
||||
nicht der Beschreibung des Zielsystems. Ist zu dem Schluessel eine
|
||||
Passphrase hinterlegt (ssh_keys.passphrase_enc, Migration 0009), wird sie
|
||||
ebenfalls entschluesselt und an asyncssh uebergeben.
|
||||
"""
|
||||
cursor = await conn.execute(
|
||||
"SELECT sk.private_key_enc, sk.passphrase_enc FROM ssh_keys sk "
|
||||
"SELECT sk.private_key_enc, sk.passphrase_enc, sk.username FROM ssh_keys sk "
|
||||
"JOIN host_ssh_key_map m ON m.ssh_key_id = sk.id "
|
||||
"WHERE m.host_id = ? LIMIT 1",
|
||||
(host_id,),
|
||||
@ -195,8 +294,9 @@ async def load_private_key_for_host(conn: aiosqlite.Connection, host_id: int) ->
|
||||
passphrase = (
|
||||
decrypt_secret(row[1], associated_data=b"ssh_key_passphrase") if row[1] else None
|
||||
)
|
||||
username = (row[2] or "").strip() or None
|
||||
try:
|
||||
return import_private_key_material(pem, passphrase)
|
||||
return import_private_key_material(pem, passphrase), username
|
||||
finally:
|
||||
# Bestpraxis: Referenzen auf das Klartextmaterial so schnell wie
|
||||
# moeglich loslassen (Konzept 6.4).
|
||||
@ -204,78 +304,173 @@ async def load_private_key_for_host(conn: aiosqlite.Connection, host_id: int) ->
|
||||
del passphrase
|
||||
|
||||
|
||||
async def load_private_key_for_host(conn: aiosqlite.Connection, host_id: int) -> asyncssh.SSHKey:
|
||||
"""Rueckwaertskompatible Fassung ohne Benutzername (siehe
|
||||
load_ssh_credential_for_host)."""
|
||||
key, _username = await load_ssh_credential_for_host(conn, host_id)
|
||||
return key
|
||||
|
||||
|
||||
def resolve_ssh_username(host: dict, credential_username: str | None) -> str:
|
||||
"""Ermittelt den Anmeldenamen fuer eine SSH-Sitzung.
|
||||
|
||||
Vorrang hat der Benutzername der Zugangsdaten (ssh_keys.username). Der
|
||||
alte Wert am Host (hosts.ssh_username) wird nur noch als Fallback fuer
|
||||
Datensaetze gelesen, die vor Migration 0010 angelegt wurden -- neue
|
||||
Schreibpfade und die Oberflaeche pflegen ihn nicht mehr.
|
||||
"""
|
||||
username = (credential_username or "").strip() or (host.get("ssh_username") or "").strip()
|
||||
if not username:
|
||||
raise HostNotConfiguredError(
|
||||
"Fuer diesen Host ist kein SSH-Benutzername hinterlegt. Der Benutzername "
|
||||
"gehoert zum SSH-Key (Adminbereich -> Zugangsdaten -> SSH-Key bearbeiten)."
|
||||
)
|
||||
return username
|
||||
|
||||
|
||||
async def _verified_host_key(
|
||||
conn: aiosqlite.Connection, host: dict
|
||||
) -> asyncssh.SSHKey:
|
||||
"""Holt den Host-Key des Ziels OHNE Anmeldung und prueft ihn gegen den
|
||||
hinterlegten Schluessel bzw. Fingerprint.
|
||||
|
||||
Warum ueberhaupt ein eigener Schritt: asyncssh ruft
|
||||
SSHClient.validate_host_public_key() NUR auf, wenn known_hosts nicht None
|
||||
ist -- mit known_hosts=None ist die Host-Key-Pruefung komplett abgeschaltet
|
||||
und der Callback wird nie erreicht. Genau diese Kombination war hier im
|
||||
Einsatz, das Pinning also faktisch wirkungslos (aufgefallen, weil aus dem
|
||||
gleichen Grund auch die Host-Key-Ermittlung keinen Fingerprint mehr sah).
|
||||
asyncssh.get_server_host_key() fuehrt nur den Key-Exchange durch und
|
||||
liefert den Schluessel zurueck -- damit laesst sich VOR jeder Anmeldung
|
||||
entscheiden, ob das Gegenueber das erwartete System ist.
|
||||
"""
|
||||
expected_fingerprint = (host.get("ssh_host_key_fingerprint") or "").strip()
|
||||
if not expected_fingerprint:
|
||||
raise HostKeyNotPinnedError(host["id"])
|
||||
|
||||
try:
|
||||
observed_key = await asyncssh.get_server_host_key(
|
||||
host["address"], port=host["port"], connect_timeout=10
|
||||
)
|
||||
except (asyncssh.Error, OSError) as exc:
|
||||
raise HostKeyDiscoveryError(host["id"], str(exc)) from exc
|
||||
if observed_key is None:
|
||||
raise HostKeyDiscoveryError(host["id"], "Das Ziel hat keinen Host-Key gesendet")
|
||||
|
||||
observed_fingerprint = observed_key.get_fingerprint("sha256")
|
||||
if observed_fingerprint != expected_fingerprint:
|
||||
raise HostKeyMismatchError(expected_fingerprint, observed_fingerprint)
|
||||
|
||||
stored = (host.get("ssh_host_key") or "").strip()
|
||||
observed_pub = observed_key.export_public_key("openssh").decode().strip()
|
||||
if stored:
|
||||
# Schaerfer als der Fingerprintvergleich: byteweise Gleichheit des
|
||||
# gesamten Schluessels.
|
||||
if _public_key_body(stored) != _public_key_body(observed_pub):
|
||||
raise HostKeyMismatchError(expected_fingerprint, observed_fingerprint)
|
||||
else:
|
||||
# Altbestand (vor Migration 0010 ermittelt): Schluessel nachtragen,
|
||||
# nachdem er gegen den gepinnten Fingerprint geprueft wurde.
|
||||
await conn.execute(
|
||||
"UPDATE hosts SET ssh_host_key = ? WHERE id = ?", (observed_pub, host["id"])
|
||||
)
|
||||
await conn.commit()
|
||||
return observed_key
|
||||
|
||||
|
||||
def _public_key_body(openssh_line: str) -> tuple[str, str]:
|
||||
"""(Algorithmus, Base64-Material) einer OpenSSH-Zeile -- ohne Kommentar,
|
||||
der sich zwischen Exporten unterscheiden darf."""
|
||||
parts = openssh_line.split()
|
||||
if len(parts) < 2:
|
||||
return ("", openssh_line.strip())
|
||||
return (parts[0], parts[1])
|
||||
|
||||
|
||||
async def connect_to_host(conn: aiosqlite.Connection, host_id: int) -> asyncssh.SSHClientConnection:
|
||||
host = await load_host(conn, host_id)
|
||||
if host["protocol"] != "ssh":
|
||||
raise HostNotConfiguredError("Host ist kein SSH-Ziel")
|
||||
|
||||
private_key = await load_private_key_for_host(conn, host_id)
|
||||
# Reihenfolge mit Absicht: erst das Ziel identifizieren, dann erst
|
||||
# Schluesselmaterial entschluesseln und eine Anmeldung versuchen.
|
||||
trusted_key = await _verified_host_key(conn, host)
|
||||
private_key, credential_username = await load_ssh_credential_for_host(conn, host_id)
|
||||
username = resolve_ssh_username(host, credential_username)
|
||||
client_factory = lambda: _PinnedHostKeyClient(host["ssh_host_key_fingerprint"])
|
||||
|
||||
try:
|
||||
connection = await asyncssh.connect(
|
||||
host["address"],
|
||||
port=host["port"],
|
||||
username=host["ssh_username"],
|
||||
username=username,
|
||||
client_keys=[private_key],
|
||||
known_hosts=None, # Validierung erfolgt ausschliesslich ueber validate_host_public_key
|
||||
known_hosts=None,
|
||||
client_factory=client_factory,
|
||||
connect_timeout=10,
|
||||
)
|
||||
except asyncssh.Error as exc:
|
||||
logger.warning("SSH-Verbindungsfehler zu Host %s: %s", host_id, exc)
|
||||
raise
|
||||
|
||||
# Zweite Haelfte des Pinnings: der Schluessel dieser Sitzung muss derselbe
|
||||
# sein wie der eben gepruefte. Faengt den (sehr schmalen) Fall ab, dass
|
||||
# zwischen Pruefung und Sitzungsaufbau ein anderes System antwortet.
|
||||
session_key = connection.get_server_host_key()
|
||||
session_fingerprint = session_key.get_fingerprint("sha256") if session_key else None
|
||||
if session_fingerprint != trusted_key.get_fingerprint("sha256"):
|
||||
connection.abort()
|
||||
logger.error(
|
||||
"Host-Key-Wechsel zwischen Pruefung und Sitzungsaufbau bei Host %s", host_id
|
||||
)
|
||||
raise HostKeyMismatchError(host["ssh_host_key_fingerprint"], session_fingerprint)
|
||||
return connection
|
||||
|
||||
|
||||
async def discover_and_store_host_key(
|
||||
conn: aiosqlite.Connection, host_id: int, *, admin_user_id: int
|
||||
) -> str:
|
||||
"""Verbindet EINMALIG ohne Pinning, um den Host-Key-Fingerprint zu erfassen
|
||||
und in der DB zu hinterlegen. Nur ueber einen dedizierten, admin-only
|
||||
Endpunkt aufrufbar -- jeder Aufruf ist eine bewusste Vertrauensentscheidung
|
||||
und wird im Audit-Log als solche vermerkt (siehe admin/routes.py).
|
||||
"""Holt den Host-Key des Ziels EINMALIG und hinterlegt ihn (Fingerprint und
|
||||
vollstaendigen Schluessel) in der DB. Nur ueber einen dedizierten,
|
||||
admin-only Endpunkt aufrufbar -- jeder Aufruf ist eine bewusste
|
||||
Vertrauensentscheidung und wird im Audit-Log als solche vermerkt (siehe
|
||||
admin/routes.py).
|
||||
|
||||
Bugfix (Fehler 500 bei 'Host-Key ermitteln'): asyncssh.connect() fuehrt
|
||||
nach dem Key-Exchange (bei dem validate_host_public_key() den Fingerprint
|
||||
bereits erfasst) automatisch die Authentifizierung durch -- ohne
|
||||
hinterlegten Client-Key/Passwort schlaegt die IMMER fehl (asyncssh.Error,
|
||||
z.B. PermissionDenied), obwohl der Fingerprint laengst bekannt ist. Diese
|
||||
fuer die reine Discovery irrelevante Auth-Fehlermeldung wurde bisher NICHT
|
||||
abgefangen und riss als unbehandelte Exception bis zu FastAPI durch ->
|
||||
500 Internal Server Error bei praktisch jedem Aufruf. Fix: Verbindungs-
|
||||
fehler abfangen; wenn dabei bereits ein Fingerprint beobachtet wurde, gilt
|
||||
die Discovery als erfolgreich. Nur wenn wirklich KEIN Fingerprint erfasst
|
||||
wurde (Host nicht erreichbar, DNS-Fehler, Timeout -- also ein Fehler VOR
|
||||
dem Key-Exchange), ist es ein echter Fehler (HostKeyDiscoveryError)."""
|
||||
Bugfix (Meldung "Ziel nicht erreichbar: Permission denied for user ..."):
|
||||
Die Ermittlung lief bisher ueber einen vollstaendigen
|
||||
asyncssh.connect()-Versuch und las den Fingerprint aus dem Callback
|
||||
SSHClient.validate_host_public_key(). Diesen Callback ruft asyncssh aber
|
||||
NUR auf, wenn known_hosts nicht None ist -- mit known_hosts=None ist die
|
||||
Host-Key-Pruefung abgeschaltet und der Callback wird nie erreicht. Der
|
||||
Fingerprint blieb deshalb leer, und der voellig erwartbare Auth-Fehler der
|
||||
anschliessenden Anmeldung ("Permission denied") wurde als "Ziel nicht
|
||||
erreichbar" gemeldet, obwohl der Key-Exchange laengst erfolgreich war.
|
||||
Jetzt wird asyncssh.get_server_host_key() verwendet: das fuehrt nur den
|
||||
Key-Exchange durch, versucht gar keine Anmeldung und liefert den
|
||||
Schluessel direkt zurueck. Ein Auth-Fehler kann die Ermittlung damit
|
||||
nicht mehr verhindern."""
|
||||
host = await load_host(conn, host_id)
|
||||
if host["protocol"] != "ssh":
|
||||
raise HostNotConfiguredError("Host-Key-Ermittlung ist nur fuer SSH-Ziele moeglich")
|
||||
|
||||
client = _PinnedHostKeyClient(None, discovery_mode=True)
|
||||
connection = None
|
||||
try:
|
||||
connection = await asyncssh.connect(
|
||||
host["address"], port=host["port"], username=host["ssh_username"],
|
||||
known_hosts=None, client_factory=lambda: client, connect_timeout=10,
|
||||
key = await asyncssh.get_server_host_key(
|
||||
host["address"], port=host["port"], connect_timeout=10
|
||||
)
|
||||
except (asyncssh.Error, OSError) as exc:
|
||||
if client.observed_fingerprint is None:
|
||||
logger.warning("Host-Key-Ermittlung fuer Host %s fehlgeschlagen: %s", host_id, exc)
|
||||
raise HostKeyDiscoveryError(host_id, str(exc)) from exc
|
||||
# Fingerprint wurde bereits waehrend des Key-Exchange erfasst -- der
|
||||
# anschliessende Auth-Fehler ist fuer die Discovery unschaedlich.
|
||||
logger.info(
|
||||
"Host-Key fuer Host %s erfasst (Auth-Phase erwartungsgemaess fehlgeschlagen: %s)",
|
||||
host_id, exc,
|
||||
)
|
||||
finally:
|
||||
if connection is not None:
|
||||
connection.close()
|
||||
logger.warning("Host-Key-Ermittlung fuer Host %s fehlgeschlagen: %s", host_id, exc)
|
||||
raise HostKeyDiscoveryError(host_id, str(exc)) from exc
|
||||
if key is None:
|
||||
raise HostKeyDiscoveryError(host_id, "Das Ziel hat keinen Host-Key gesendet")
|
||||
|
||||
fingerprint = client.observed_fingerprint
|
||||
fingerprint = key.get_fingerprint("sha256")
|
||||
public_key = key.export_public_key("openssh").decode().strip()
|
||||
await conn.execute(
|
||||
"UPDATE hosts SET ssh_host_key_fingerprint = ? WHERE id = ?", (fingerprint, host_id)
|
||||
"UPDATE hosts SET ssh_host_key_fingerprint = ?, ssh_host_key = ? WHERE id = ?",
|
||||
(fingerprint, public_key, host_id),
|
||||
)
|
||||
await conn.commit()
|
||||
logger.info(
|
||||
"Host-Key fuer Host %s durch Admin %s uebernommen: %s", host_id, admin_user_id, fingerprint
|
||||
)
|
||||
return fingerprint
|
||||
|
||||
@ -17,9 +17,11 @@ from app.rbac import user_has_role_for_host
|
||||
from app.security.audit import write_audit_event
|
||||
from app.security.av_scan import scan_bytes
|
||||
from app.ssh_proxy.proxy import (
|
||||
SSH_SETUP_ERRORS,
|
||||
HostNotConfiguredError,
|
||||
PrivateKeyUnusableError,
|
||||
connect_to_host,
|
||||
describe_connection_error,
|
||||
load_host,
|
||||
)
|
||||
|
||||
@ -102,15 +104,13 @@ async def upload_file(
|
||||
await remote_file.write(data)
|
||||
finally:
|
||||
ssh_conn.close()
|
||||
except HostNotConfiguredError as exc:
|
||||
raise HTTPException(status.HTTP_400_BAD_REQUEST, str(exc))
|
||||
except PrivateKeyUnusableError as exc:
|
||||
# Gleiche Ursache wie bei einer scheiternden Terminalsitzung (siehe
|
||||
# app/ssh_proxy/terminal_ws.py): passphrasegeschuetzter Schluessel
|
||||
# ohne hinterlegte Passphrase. Hier als 400 mit Klartext statt als
|
||||
# unbehandelter 500.
|
||||
except SSH_SETUP_ERRORS as exc:
|
||||
# Alles, was den Verbindungsaufbau verhindert und in der Konfiguration
|
||||
# begruendet ist (fehlender Benutzername/Schluessel, unbrauchbarer
|
||||
# Schluessel, nicht gepinnter oder abweichender Host-Key, Ziel nicht
|
||||
# erreichbar): 400 mit Klartext statt eines unbehandelten 500.
|
||||
logger.warning("Dateitransfer fuer Host %s nicht moeglich: %s", host_id, exc)
|
||||
raise HTTPException(status.HTTP_400_BAD_REQUEST, str(exc))
|
||||
raise HTTPException(status.HTTP_400_BAD_REQUEST, describe_connection_error(exc))
|
||||
|
||||
await _log_transfer(
|
||||
conn, user=user, host_id=host_id, client_ip=_client_ip(request), direction="upload",
|
||||
@ -139,15 +139,13 @@ async def download_file(
|
||||
data = await remote_file.read()
|
||||
finally:
|
||||
ssh_conn.close()
|
||||
except HostNotConfiguredError as exc:
|
||||
raise HTTPException(status.HTTP_400_BAD_REQUEST, str(exc))
|
||||
except PrivateKeyUnusableError as exc:
|
||||
# Gleiche Ursache wie bei einer scheiternden Terminalsitzung (siehe
|
||||
# app/ssh_proxy/terminal_ws.py): passphrasegeschuetzter Schluessel
|
||||
# ohne hinterlegte Passphrase. Hier als 400 mit Klartext statt als
|
||||
# unbehandelter 500.
|
||||
except SSH_SETUP_ERRORS as exc:
|
||||
# Alles, was den Verbindungsaufbau verhindert und in der Konfiguration
|
||||
# begruendet ist (fehlender Benutzername/Schluessel, unbrauchbarer
|
||||
# Schluessel, nicht gepinnter oder abweichender Host-Key, Ziel nicht
|
||||
# erreichbar): 400 mit Klartext statt eines unbehandelten 500.
|
||||
logger.warning("Dateitransfer fuer Host %s nicht moeglich: %s", host_id, exc)
|
||||
raise HTTPException(status.HTTP_400_BAD_REQUEST, str(exc))
|
||||
raise HTTPException(status.HTTP_400_BAD_REQUEST, describe_connection_error(exc))
|
||||
|
||||
sha256 = hashlib.sha256(data).hexdigest()
|
||||
filename = remote_path.rsplit("/", 1)[-1]
|
||||
|
||||
@ -24,9 +24,11 @@ from app.recordings.recorder import SessionRecorder
|
||||
from app.security import active_sessions
|
||||
from app.security.audit import write_audit_event
|
||||
from app.ssh_proxy.proxy import (
|
||||
SSH_SETUP_ERRORS,
|
||||
HostNotConfiguredError,
|
||||
PrivateKeyUnusableError,
|
||||
connect_to_host,
|
||||
describe_connection_error,
|
||||
load_host,
|
||||
)
|
||||
|
||||
@ -153,11 +155,18 @@ async def ssh_terminal(websocket: WebSocket, host_id: int):
|
||||
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:
|
||||
except SSH_SETUP_ERRORS as exc:
|
||||
# Alles, was den Sitzungsaufbau verhindert und der Benutzer selbst
|
||||
# einordnen kann: fehlender Benutzername/Schluessel, nicht gepinnter
|
||||
# oder abweichender Host-Key, unerreichbares Ziel. Bewusst mit
|
||||
# Klartextmeldung statt "interner Fehler" -- der Grund steht in der
|
||||
# Konfiguration, nicht im Code.
|
||||
logger.warning("SSH-Sitzung %s abgebrochen: %s", session_id, exc)
|
||||
end_reason = "error"
|
||||
try:
|
||||
await websocket.send_json({"type": "error", "message": str(exc)})
|
||||
await websocket.send_json(
|
||||
{"type": "error", "message": describe_connection_error(exc)}
|
||||
)
|
||||
except Exception:
|
||||
logger.debug("Fehlermeldung konnte nicht mehr an Client gesendet werden", exc_info=True)
|
||||
except asyncio.CancelledError:
|
||||
|
||||
Reference in New Issue
Block a user