Files
ssh-jumphost/app/config.py
2026-09-02 20:30:44 +02:00

158 lines
8.6 KiB
Python

"""
Zentrale Konfiguration der Jumphost-Anwendung.
Secrets (KEK, Session-Signaturschluessel) werden bevorzugt ueber systemd
Credentials geladen (siehe systemd LoadCredentialEncrypted= im Unit-File,
$CREDENTIALS_DIRECTORY zur Laufzeit). Fuer lokale Entwicklung/Tests wird auf
Umgebungsvariablen bzw. eine lokale .env-Datei zurueckgefallen -- das ist
NICHT fuer den Produktivbetrieb gedacht.
"""
from __future__ import annotations
import os
import secrets
from dataclasses import dataclass, field
from pathlib import Path
def _read_credential(name: str, env_fallback: str | None = None, *, required: bool = True) -> bytes | None:
"""Liest ein Secret aus $CREDENTIALS_DIRECTORY (systemd-creds) oder Fallback-Env.
Der Wert wird als Hex-String erwartet (z.B. per `openssl rand -hex 32`
erzeugt) und hier zu den zugrundeliegenden Rohbytes dekodiert -- passend
zum selben Format, das auch fuer JUMPHOST_DEV_KEK/JUMPHOST_DEV_SESSION_SECRET
im development-Zweig unten verwendet wird (s. Konzept 6.4). Vorher wurde
dieser Wert hier NICHT dekodiert, sondern roh als Bytes interpretiert --
das fuehrte bei einem korrekt per `openssl rand -hex 32` erzeugten
64-Zeichen-Hex-String (= 32 Byte Entropie) zu einer 64-Byte-Rohlaenge und
damit zum Fehler "KEK muss genau 32 Bytes lang sein".
"""
cred_dir = os.environ.get("CREDENTIALS_DIRECTORY")
raw: bytes | None = None
if cred_dir:
cred_path = Path(cred_dir) / name
if cred_path.exists():
raw = cred_path.read_bytes().strip()
if raw is None and env_fallback and env_fallback in os.environ:
raw = os.environ[env_fallback].strip().encode()
if raw is None:
if required:
raise RuntimeError(
f"Secret '{name}' weder ueber systemd-creds noch ueber Env-Variable "
f"'{env_fallback}' verfuegbar. In Produktion MUSS dies ueber "
f"systemd LoadCredentialEncrypted= bereitgestellt werden."
)
return None
try:
return bytes.fromhex(raw.decode("ascii"))
except (ValueError, UnicodeDecodeError) as exc:
raise RuntimeError(
f"Secret '{name}' ist kein gueltiger Hex-String (erwartet z.B. das "
f"Ergebnis von `openssl rand -hex 32`)."
) from exc
@dataclass
class Settings:
app_env: str = os.environ.get("JUMPHOST_ENV", "development")
data_dir: Path = Path(os.environ.get("JUMPHOST_DATA_DIR", "/var/lib/jumphost"))
db_path: Path = field(init=False)
recordings_dir: Path = field(init=False)
# Key-Encryption-Key fuer AES-256-GCM (verschluesselt SSH-Keys/TOTP-Secrets in der DB)
kek: bytes = field(init=False)
# separater Schluessel fuer Session-Cookie-Signatur (Schluesseltrennung, siehe Konzept 6.2)
session_secret: bytes = field(init=False)
listen_uds: str = os.environ.get("JUMPHOST_LISTEN_UDS", "/run/jumphost/app.sock")
guacd_host: str = os.environ.get("JUMPHOST_GUACD_HOST", "127.0.0.1")
guacd_port: int = int(os.environ.get("JUMPHOST_GUACD_PORT", "4822"))
# Befund A3 (Umsetzungsauftrag_Sonnet5.md Teil A.3): weder der
# TCP-Verbindungsaufbau zu guacd noch die beiden read_instruction()-
# Aufrufe im guac-Handshake (args/ready) hatten ein Timeout -- antwortet
# guacd nach dem TCP-Accept nicht mehr, haengt die Coroutine unbegrenzt
# (offene sessions-Zeile ohne ended_at, offene Aufzeichnungsdatei,
# belegtes WebSocket). Je Phase konfigurierbar statt eines Werts fuer
# beides, da ein TCP-Connect-Timeout und ein Protokoll-Handshake-Timeout
# unterschiedliche Fehlerbilder abdecken (guacd-Prozess tot vs. guacd
# haengt mitten im Protokoll).
guacd_connect_timeout_s: float = float(os.environ.get("JUMPHOST_GUACD_CONNECT_TIMEOUT", "10"))
guacd_handshake_timeout_s: float = float(os.environ.get("JUMPHOST_GUACD_HANDSHAKE_TIMEOUT", "10"))
session_idle_timeout_s: int = int(os.environ.get("JUMPHOST_SESSION_IDLE_TIMEOUT", "900"))
session_absolute_timeout_s: int = int(os.environ.get("JUMPHOST_SESSION_ABS_TIMEOUT", "28800"))
max_failed_logins: int = int(os.environ.get("JUMPHOST_MAX_FAILED_LOGINS", "5"))
lockout_base_seconds: int = int(os.environ.get("JUMPHOST_LOCKOUT_BASE_SECONDS", "30"))
# Sitzungsaufzeichnung (Umsetzungsauftrag Teil A/D2, Teil E/E2): Puffer-
# Zeitfenster, Groesse je Teildatei (Rotation) und Gesamtgroesse je
# Sitzung (harte Obergrenze -- Aufzeichnung wird danach mit einem
# Abschluss-Eintrag abgeschnitten, die Sitzung selbst laeuft normal
# weiter). Siehe app/recordings/recorder.py.
recording_flush_interval_s: float = float(os.environ.get("JUMPHOST_RECORDING_FLUSH_INTERVAL_S", "1.0"))
recording_max_part_bytes: int = int(os.environ.get("JUMPHOST_RECORDING_MAX_PART_BYTES", str(50 * 1024 * 1024)))
recording_max_total_bytes: int = int(os.environ.get("JUMPHOST_RECORDING_MAX_TOTAL_BYTES", str(500 * 1024 * 1024)))
# Dateitransfer (Umsetzungsauftrag Teil E, E4): Obergrenze gleichzeitig
# laufender Uploads+Downloads ueber alle Benutzer hinweg. Ohne Grenze
# koennten mehrere parallele 200-MiB-Transfers den Prozess in den
# OOM-Killer treiben (siehe app/ssh_proxy/sftp.py).
max_concurrent_transfers: int = int(os.environ.get("JUMPHOST_MAX_CONCURRENT_TRANSFERS", "8"))
# SSH-Terminal-Sitzungen (Umsetzungsauftrag Teil E):
# E10 -- Inaktivitaet wird in BEIDEN Richtungen gemessen (Eingaben UND
# Ausgaben vom Ziel, z.B. ein laufendes `tail -f`), nicht mehr nur an
# websocket.receive_json() (Benutzereingaben). Vorher wurde eine
# inhaltlich aktive, aber eingabelose Sitzung nach 15 Minuten getrennt.
ssh_idle_timeout_s: int = int(os.environ.get("JUMPHOST_SSH_IDLE_TIMEOUT", "900"))
# E11 -- vorher als MAX_SESSION_SECONDS-Konstante in
# app/ssh_proxy/terminal_ws.py definiert, aber nirgends verwendet: es gab
# de facto GAR KEINE absolute Obergrenze fuer eine SSH-Sitzung, anders
# als beim Anmelde-Cookie (session_absolute_timeout_s oben). Jetzt aktiv
# durchgesetzt, mit Vorwarnung (siehe ssh_max_session_warning_s).
ssh_max_session_seconds: int = int(os.environ.get("JUMPHOST_SSH_MAX_SESSION_SECONDS", str(8 * 3600)))
ssh_max_session_warning_s: int = int(os.environ.get("JUMPHOST_SSH_MAX_SESSION_WARNING_S", "60"))
# E8 (Umsetzungsauftrag Teil E): IP-basiertes Login-Rate-Limit. Bewusst
# NICHT automatisch an eine erkannte Benutzerzahl angepasst -- ob mehrere
# interne Benutzer hinter derselben NAT-Adresse sitzen (typischer
# Buero-Fall) ist eine Betriebsentscheidung, die zusammen mit dem
# nginx-limit_req (ansible/roles/nginx_proxy) und der fail2ban-Schwelle
# (ansible/roles/fail2ban) getroffen werden muss -- siehe deren
# jumphost_login_rate_limit_* group_vars. Der kontenbezogene Lockout
# (max_failed_logins oben) bleibt in jedem Fall die scharfe, IP-
# unabhaengige Bremse.
login_rate_limit_max_events: int = int(os.environ.get("JUMPHOST_LOGIN_RATE_LIMIT_MAX_EVENTS", "10"))
login_rate_limit_window_s: int = int(os.environ.get("JUMPHOST_LOGIN_RATE_LIMIT_WINDOW_S", "60"))
# E.4 (Umsetzungsauftrag Teil E): Obergrenzen fuer gleichzeitige SSH/RDP-
# Sitzungen. Vorher gab es WEDER einen Zaehler je Benutzer NOCH einen
# globalen Zaehler -- mit der Mehrsitzungs-Seitenleiste (Teil F) wird es
# zur Normalitaet, dass ein Benutzer mehrere Sitzungen gleichzeitig haelt,
# jede kostet mindestens einen zusaetzlichen Socket zum Ziel (RDP
# zusaetzlich die guacd-Verbindung) plus eine Aufzeichnungsdatei.
max_sessions_per_user: int = int(os.environ.get("JUMPHOST_MAX_SESSIONS_PER_USER", "6"))
max_sessions_global: int = int(os.environ.get("JUMPHOST_MAX_SESSIONS_GLOBAL", "100"))
def __post_init__(self) -> None:
self.db_path = self.data_dir / "jumphost.db"
self.recordings_dir = self.data_dir / "recordings"
if self.app_env == "development":
# Nur fuer lokale Entwicklung: deterministisch aus Env oder Zufallswert je Prozessstart.
kek_hex = os.environ.get("JUMPHOST_DEV_KEK")
self.kek = bytes.fromhex(kek_hex) if kek_hex else secrets.token_bytes(32)
sess_hex = os.environ.get("JUMPHOST_DEV_SESSION_SECRET")
self.session_secret = bytes.fromhex(sess_hex) if sess_hex else secrets.token_bytes(32)
else:
self.kek = _read_credential("jumphost_kek", "JUMPHOST_KEK")
self.session_secret = _read_credential("jumphost_session_secret", "JUMPHOST_SESSION_SECRET")
if len(self.kek) != 32:
raise RuntimeError("KEK muss genau 32 Bytes (256 Bit) lang sein.")
if len(self.session_secret) != 32:
raise RuntimeError("Session-Secret muss genau 32 Bytes (256 Bit) lang sein.")
settings = Settings()