From 877903bd77ef79291b771bcc14457525ed1b00d8 Mon Sep 17 00:00:00 2001 From: alientim Date: Sat, 22 Aug 2026 22:33:31 +0200 Subject: [PATCH] NGINX-Export inkl. Zertifikat/Schluessel, Kategorie-Hinweise als Icon (weiterhin v1.1.11) - NGINX-Export nimmt jetzt zusaetzlich das aktuell hinterlegte TLS- Zertifikat und den privaten Schluessel selbst mit (Base64, falls vorhanden) -- der gesamte Export ist ohnehin passphrasenverschluesselt, genau wie andere Geheimnisse (Zugangsdaten-Passwoerter, LDAP-Bind- Passwort) bereits im Klartext darin landen. Bewusst weiterhin AUSGESCHLOSSEN bleibt jeglicher Let's-Encrypt-/certbot-Zustand (ssl_source, ACME-Konto, Renewal-Timer) -- an genau diesen Host/diese Domain-Registrierung gebunden, kann nicht mitziehen. Ein Import setzt ssl_source deshalb immer auf "upload", auch wenn urspruenglich per Let's Encrypt ausgestellt. - Die erklaerenden Klammerzusaetze bei "Zugangsdaten" (know_hosts-Hinweis) und "NGINX-Einstellungen" (Zertifikat/Schluessel-Hinweis) sind jetzt ein "i"-Hinweis-Icon statt Fliesstext im Label, passend zum bestehenden Hover-Tooltip-/Klick-Modal-Muster (siehe v1.1.10). Verifiziert per isoliertem Test mit einem echten (selbstsignierten) Test-Zertifikat: Export->Import-Roundtrip liefert bitidentische Cert-/ Key-Dateien mit korrekten Berechtigungen (0600 Schluessel, 0644 Zertifikat) auf einem eigenen Zielpfad, sowie live per Browser gegen POETEST (Hover zeigt korrekten Tooltip-Text, 2 Icons im Export-Formular). Co-Authored-By: Claude Sonnet 5 --- srv/tesm/app.py | 71 ++++++++++++++++--- .../templates/settings_import_export.html | 1 + 2 files changed, 61 insertions(+), 11 deletions(-) diff --git a/srv/tesm/app.py b/srv/tesm/app.py index 27818f7..b39db19 100644 --- a/srv/tesm/app.py +++ b/srv/tesm/app.py @@ -4224,6 +4224,7 @@ def settings_import_export(): "settings_import_export.html", export_sections=EXPORT_SECTIONS, admin_only_sections=IMPORT_EXPORT_ADMIN_ONLY_SECTIONS, + export_section_hints=EXPORT_SECTION_HINTS, ) @@ -5435,16 +5436,24 @@ def _merge_known_hosts_content(new_content): EXPORT_SECTIONS = [ ("devices", "Clients"), ("switches", "Switche"), - ("credentials", "Zugangsdaten (inkl. bekannter SSH-Host-Keys aus known_hosts)"), + ("credentials", "Zugangsdaten"), ("users", "Benutzer (lokal)"), ("groups", "Custom-Gruppen"), ("ldap", "LDAP/AD-Einstellungen"), ("dhcp", "DHCP-Einstellungen"), - ("nginx", "NGINX-Einstellungen (Domain, Ports, SSL/HSTS — ohne Zertifikat/Schlüssel)"), + ("nginx", "NGINX-Einstellungen"), ("logs", "Auditlog (alle aktuell in der Datenbank vorhandenen Einträge)"), ] EXPORT_SECTION_LABELS = dict(EXPORT_SECTIONS) +# Zusätzlicher Hinweistext je Kategorie fürs "i"-Icon neben dem Label +# (siehe _hint_icon.html) -- nur für Kategorien mit erklärungsbedürftigem +# Zusatzverhalten, das über den reinen Namen hinausgeht. +EXPORT_SECTION_HINTS = { + "credentials": "Enthält zusätzlich den Inhalt von known_hosts (bereits bestätigte SSH-Host-Keys) — wird beim Import ergänzend (nicht überschreibend) eingespielt.", + "nginx": "Enthält Domain, Ports und SSL/HSTS-Schalter sowie — falls vorhanden — das aktuell hinterlegte TLS-Zertifikat und den privaten Schlüssel selbst.", +} + IMPORT_EXPORT_ADMIN_ONLY_SECTIONS = {"users", "groups", "ldap"} LDAP_SETTING_KEYS = [ @@ -5580,17 +5589,31 @@ NGINX_SETTING_KEYS = ["nginx_server_name", "nginx_http_port", "nginx_https_port" def _export_nginx(conn): - """Exportiert nur portable Konfiguration (Domain, Ports, SSL/HSTS- - Schalter) -- bewusst OHNE ssl_source, TLS-Zertifikat/Schlüssel oder - Let's-Encrypt-/certbot-Zustand: das sind entweder host-gebundene reale - Dateien (TESM_SSL_CERT_PATH/TESM_SSL_KEY_PATH) oder eine ACME- - Registrierung für genau diesen Host/diese Domain, kein portabler - Einstellungswert (gleiche Begründung wie bei avatar_filename in - _export_users()).""" + """Exportiert Domain/Ports/SSL-HSTS-Schalter sowie, falls vorhanden, + das aktuell hinterlegte TLS-Zertifikat+Schlüssel selbst (Base64, da + JSON keine Binärdaten kann) -- anders als bei avatar_filename ist das + hier sinnvoll: Zertifikat/Schlüssel liegen bereits vollständig auf + diesem Host vor und lassen sich 1:1 auf ein Zielsystem übertragen, + genau wie andere Geheimnisse (Zugangsdaten-Passwörter, LDAP-Bind- + Passwort), die ebenfalls im Klartext in den -- als Ganzes + passphrasenverschlüsselten -- Export wandern. Bewusst AUSGESCHLOSSEN + bleibt trotzdem jeglicher Let's-Encrypt-/certbot-Zustand (ssl_source, + ACME-Konto, Renewal-Timer): das ist an genau diesen Host und dessen + Domain-Registrierung gebunden und kann nicht mitziehen -- ein Import + setzt ssl_source deshalb immer auf "upload", siehe _import_nginx().""" settings_data = {k: get_setting(k) for k in NGINX_SETTING_KEYS if get_setting(k) not in (None, "")} - if not settings_data: + data = {"settings": settings_data} + try: + if os.path.isfile(TESM_SSL_CERT_PATH) and os.path.isfile(TESM_SSL_KEY_PATH): + with open(TESM_SSL_CERT_PATH, "rb") as f: + data["cert_pem"] = base64.b64encode(f.read()).decode("ascii") + with open(TESM_SSL_KEY_PATH, "rb") as f: + data["key_pem"] = base64.b64encode(f.read()).decode("ascii") + except OSError: + pass + if not (settings_data or "cert_pem" in data): return None - return {"settings": settings_data} + return data def _export_logs(conn): @@ -5835,6 +5858,31 @@ def _import_nginx(conn, data): (key, value), ) n += 1 + if data.get("cert_pem") and data.get("key_pem"): + try: + cert_bytes = base64.b64decode(data["cert_pem"]) + key_bytes = base64.b64decode(data["key_pem"]) + ok, _msg, _info = _validate_cert_key_pair(cert_bytes, key_bytes) + if ok: + os.makedirs(TESM_SSL_DIR, exist_ok=True) + with open(TESM_SSL_CERT_PATH, "wb") as f: + f.write(cert_bytes) + with open(TESM_SSL_KEY_PATH, "wb") as f: + f.write(key_bytes) + os.chmod(TESM_SSL_KEY_PATH, 0o600) + os.chmod(TESM_SSL_CERT_PATH, 0o644) + # ACME-/certbot-Zustand kommt bewusst nie mit (siehe + # _export_nginx) -- nach dem Import ist es technisch nur + # noch ein hochgeladenes Zertifikat, keine automatische + # Verlängerung mehr, auch wenn es ursprünglich per Let's + # Encrypt ausgestellt wurde. + conn.execute( + "INSERT INTO settings (key, value) VALUES ('ssl_source', 'upload') " + "ON CONFLICT(key) DO UPDATE SET value=excluded.value" + ) + n += 1 + except (OSError, ValueError, TypeError): + pass return n @@ -5983,6 +6031,7 @@ def settings_import_preview(token): import_preview={"token": token, "sections": preview_sections}, export_sections=EXPORT_SECTIONS, admin_only_sections=IMPORT_EXPORT_ADMIN_ONLY_SECTIONS, + export_section_hints=EXPORT_SECTION_HINTS, ) diff --git a/srv/tesm/templates/settings_import_export.html b/srv/tesm/templates/settings_import_export.html index 2c9f506..47ae753 100644 --- a/srv/tesm/templates/settings_import_export.html +++ b/srv/tesm/templates/settings_import_export.html @@ -82,6 +82,7 @@ {% if key not in admin_only_sections or current_user.is_admin %}checked{% endif %} {% if key in admin_only_sections and not current_user.is_admin %}disabled{% endif %}> {{ label }} + {% if export_section_hints and key in export_section_hints %}{{ hi.hint_icon(export_section_hints[key], label) }}{% endif %} {% if key in admin_only_sections %}Nur Admin{% endif %} {% endfor %}