From 4de5828573546c600f2cab825dba8f21c1da1245 Mon Sep 17 00:00:00 2001 From: alientim Date: Sat, 22 Aug 2026 12:15:32 +0200 Subject: [PATCH] NGINX-Seite ausgebaut: Domain/Ports, RAW-Config, Let's Encrypt mit Auto-Verlaengerung; Flask nur noch lokal erreichbar Umbenennung: Einstellungen -> SSL/TLS heisst jetzt Einstellungen -> NGINX (Route /settings/ssl -> /settings/nginx, Recht settings_ssl.* -> settings_nginx.*, Icon "server" statt "shield") -- die Seite verwaltet den kompletten Reverse-Proxy, nicht mehr nur SSL. Neu: - Domain (server_name) und HTTP-/HTTPS-Port sind jetzt einstellbar, nicht mehr hart "_" bzw. 80/443. Aendert man den HTTP-Port weg von 80, wird automatisch zusaetzlich ein minimaler Nur-ACME-Block auf Port 80 mitgeschrieben, da Let's Encrypt (HTTP-01) protokollbedingt immer Port 80 braucht, unabhaengig vom konfigurierten HTTP-Port. - "RAW-Konfiguration"-Button zeigt die komplette aktuelle nginx-Config im selben Popup-Stil wie das Live-Log (Wiederverwendung von window.openRawLogModal, kein neuer Code dafuer noetig). - Let's Encrypt: Zertifikat per certbot anfordern (certonly --webroot, bewusst NICHT das certbot-nginx-Plugin -- das wuerde die App-eigene, komplett selbstgeschriebene Config direkt editieren und bei der naechsten Aenderung ueber diese Seite wieder verlieren). Automatische Verlaengerung laeuft ueber certbots eigenen systemd-Timer/Cron (Standard-Paketverhalten), ein Deploy-Hook (/etc/letsencrypt/renewal-hooks/deploy/tesm-reload-nginx.sh, wird wie die logrotate-Config bei jedem App-Start neu geschrieben) laedt nginx nach jeder Verlaengerung neu. Fehlgeschlagene Anforderung (kein oeffentlicher DNS-Eintrag o.ae.) wird sauber abgefangen und als Fehlermeldung angezeigt, ohne etwas zu veraendern -- live mit einer ungueltigen Test-Domain verifiziert. - Zertifikatsquelle (Upload vs. Let's Encrypt) wird als eigenes Setting (ssl_source) verfolgt, das Anwenden/Anzeigen/Entfernen beruecksichtigt beide Quellen jeweils passend (Entfernen einer LE-verwalteten Installation laeuft ueber "certbot delete", nicht ueber rohes Datei-Loeschen, damit certbots eigene Renewal-Konfiguration nicht verwaist). - certbot zu install.sh's Paketliste hinzugefuegt. Flask/gunicorn bindet jetzt an 127.0.0.1 statt 0.0.0.0 (tesm.service) -- direkter Zugriff auf Port 5000 aus dem Netz ist damit nicht mehr moeglich, nur noch ueber den nginx-Reverse-Proxy (der ohnehin nur ueber 127.0.0.1 zugreift). Beantwortet die Nachfrage, ob Flask unerreichbar gemacht werden kann. Live auf Testsystem-Update-Restart verifiziert: Domain/Port-Aenderung (HTTP-Port 8080 inkl. automatischem Zusatz-Block auf Port 80), RAW-Config-Anzeige, Zertifikat-Upload, Let's-Encrypt-Fehlschlag sauber abgefangen, Rueckbau auf Standardwerte (_, 80, 443, kein SSL) ueber denselben Anwenden/Bestaetigen-Sicherheitsnetz-Pfad wie zuvor. Co-Authored-By: Claude Sonnet 5 --- etc/systemd/system/tesm.service | 6 +- install.sh | 2 +- srv/tesm/app.py | 334 ++++++++++++++---- srv/tesm/templates/base.html | 1 + ...{settings_ssl.html => settings_nginx.html} | 162 ++++++--- 5 files changed, 375 insertions(+), 130 deletions(-) rename srv/tesm/templates/{settings_ssl.html => settings_nginx.html} (54%) diff --git a/etc/systemd/system/tesm.service b/etc/systemd/system/tesm.service index caa078c..64f55ef 100644 --- a/etc/systemd/system/tesm.service +++ b/etc/systemd/system/tesm.service @@ -14,8 +14,12 @@ WorkingDirectory=/srv/tesm # nach 30s auch bei einer offenen, aber gerade inaktiven SSH-Terminal- # WebSocket-Verbindung und würde den Worker (und damit JEDE Verbindung, # da nur ein Prozess läuft) killen. +# "--bind 127.0.0.1:5000" statt 0.0.0.0: Flask/Gunicorn soll ausschließlich +# über den nginx-Reverse-Proxy (siehe Einstellungen -> NGINX) erreichbar +# sein, nicht zusätzlich direkt und unverschlüsselt über Port 5000 aus dem +# Netz -- nginx selbst greift ohnehin nur über 127.0.0.1 zu. ExecStart=/srv/tesm/venv/bin/gunicorn --workers 1 --worker-class gthread --threads 16 \ - --timeout 0 --bind 0.0.0.0:5000 app:app + --timeout 0 --bind 127.0.0.1:5000 app:app Restart=always RestartSec=5 diff --git a/install.sh b/install.sh index 6d86d70..907f152 100644 --- a/install.sh +++ b/install.sh @@ -114,7 +114,7 @@ fi # ---- Pakete ---- step "Installing system packages" sudo apt-get update >>/var/log/tesm-install.log 2>&1 && print_status "apt update" -sudo apt-get install -y python3 python3-venv python3-pip nginx sqlite3 expect openssh-client git rsync iputils-ping logrotate >>/var/log/tesm-install.log 2>&1 && print_status "Packages installed" +sudo apt-get install -y python3 python3-venv python3-pip nginx sqlite3 expect openssh-client git rsync iputils-ping logrotate certbot >>/var/log/tesm-install.log 2>&1 && print_status "Packages installed" # ---- Log-Verzeichnis ---- # NICHT weltweit beschreibbar (0755 reicht) -- sowohl tesm.service als diff --git a/srv/tesm/app.py b/srv/tesm/app.py index 43cfefb..5de3eb4 100644 --- a/srv/tesm/app.py +++ b/srv/tesm/app.py @@ -112,6 +112,8 @@ NGINX_SITE_CONFIG_PATH = os.environ.get("TESM_NGINX_CONFIG", "/etc/nginx/sites-a TESM_SSL_DIR = os.environ.get("TESM_SSL_DIR", "/etc/tesm/ssl") TESM_SSL_CERT_PATH = os.path.join(TESM_SSL_DIR, "cert.pem") TESM_SSL_KEY_PATH = os.path.join(TESM_SSL_DIR, "key.pem") +TESM_ACME_WEBROOT = os.environ.get("TESM_ACME_WEBROOT", "/var/www/tesm-acme") +CERTBOT_DEPLOY_HOOK_PATH = "/etc/letsencrypt/renewal-hooks/deploy/tesm-reload-nginx.sh" try: # Bewusst AUSSERHALB von /srv/tesm -- das rsync --delete beim Deployen # (siehe install.sh) würde ein dort abgelegtes Zertifikat sonst bei @@ -119,9 +121,38 @@ try: # außerhalb des App-Verzeichnisses, übersteht also jedes Update. os.makedirs(TESM_SSL_DIR, exist_ok=True) os.chmod(TESM_SSL_DIR, 0o700) + os.makedirs(TESM_ACME_WEBROOT, exist_ok=True) except OSError: pass + +def _le_cert_path(domain): + return f"/etc/letsencrypt/live/{domain}/fullchain.pem" + + +def _le_key_path(domain): + return f"/etc/letsencrypt/live/{domain}/privkey.pem" + + +def _write_certbot_deploy_hook(): + """certbot führt nach JEDER erfolgreichen Erneuerung (egal für welche + Domain) automatisch alle ausführbaren Skripte in diesem Verzeichnis aus + -- ohne diesen Hook würde nginx nach einer automatischen Verlängerung + weiter das alte (bald ablaufende) Zertifikat verwenden, da es die + Datei nicht von selbst neu einliest. Läuft wie _write_logrotate_config + bei jedem App-Start neu, damit ein frisch installiertes certbot den + Hook auch ohne manuellen Aufruf der NGINX-Seite bekommt.""" + try: + os.makedirs(os.path.dirname(CERTBOT_DEPLOY_HOOK_PATH), exist_ok=True) + with open(CERTBOT_DEPLOY_HOOK_PATH, "w", encoding="utf-8") as f: + f.write("#!/bin/bash\nsystemctl reload nginx\n") + os.chmod(CERTBOT_DEPLOY_HOOK_PATH, 0o755) + except OSError: + pass + + +_write_certbot_deploy_hook() + CREDENTIAL_CATEGORIES = [ ("switch", "Switch"), ("linux", "Linux-Client"), @@ -275,9 +306,9 @@ PERMISSIONS = { "label": "LDAP", "rows": {"view": "settings_ldap.view", "edit": "settings_ldap.edit"}, }, - "settings_ssl": { - "label": "SSL/TLS", - "rows": {"view": "settings_ssl.view", "edit": "settings_ssl.edit"}, + "settings_nginx": { + "label": "NGINX", + "rows": {"view": "settings_nginx.view", "edit": "settings_nginx.edit"}, }, "papierkorb": { "label": "Papierkorb", @@ -317,8 +348,8 @@ PERMISSION_LABELS = { "settings_dhcp.edit": "DHCP-Konfiguration speichern und Reservierungsdatei schreiben", "settings_ldap.view": "LDAP/AD-Konfiguration lesen", "settings_ldap.edit": "LDAP/AD-Konfiguration speichern (Bind-Konto, Gruppenzuordnungen)", - "settings_ssl.view": "SSL/TLS-Konfiguration und Zertifikatsstatus lesen", - "settings_ssl.edit": "SSL/TLS-Konfiguration ändern (Zertifikat hochladen, HTTPS/HSTS aktivieren)", + "settings_nginx.view": "NGINX-Konfiguration und Zertifikatsstatus lesen", + "settings_nginx.edit": "NGINX-Konfiguration ändern (Domain/Ports, Zertifikat hochladen oder per Let's Encrypt anfordern, HTTPS/HSTS aktivieren)", } ALL_PERMISSION_KEYS = [] @@ -358,7 +389,7 @@ NAV_ITEMS = [ {"key": "settings_ldap", "label": "LDAP", "icon": "users", "endpoint": "settings_ldap"}, {"key": "settings_dhcp", "label": "DHCP", "icon": "network", "endpoint": "settings_dhcp"}, {"key": "settings_importexport", "label": "Im-/Export", "icon": "transfer", "endpoint": "settings_import_export"}, - {"key": "settings_ssl", "label": "SSL/TLS", "icon": "shield", "endpoint": "settings_ssl"}, + {"key": "settings_nginx", "label": "NGINX", "icon": "server", "endpoint": "settings_nginx"}, {"key": "papierkorb", "label": "Papierkorb", "icon": "trash", "endpoint": "papierkorb"}, ]}, {"key": "logs_group", "label": "Logs", "icon": "terminal", "children": [ @@ -397,8 +428,8 @@ def _nav_key_visible(key, user): return user.can_view_settings_dhcp if key == "settings_ldap": return user.can_view_settings_ldap - if key == "settings_ssl": - return user.has_permission("settings_ssl.view") + if key == "settings_nginx": + return user.has_permission("settings_nginx.view") if key == "logs_live": return user.can_view_live_log if key == "logs_history": @@ -2519,19 +2550,41 @@ def _revert_network_config(token): # ============================================================================ -# SSL/TLS (Systemeinstellungen -> SSL/TLS) -# Let's-Encrypt-Ausstellung ist bewusst (noch) nicht Teil dieser Funktion -- -# alle bekannten TESM-Instanzen laufen aktuell rein im internen Netz ohne -# öffentliche Domain (server_name "_"), HTTP-01 würde dort nicht -# funktionieren und DNS-01 bräuchte Provider-spezifische API-Zugänge. Für -# den Anfang daher nur manueller Zertifikat-Upload; Let's Encrypt kann bei -# Bedarf als spätere Erweiterung ergänzt werden, ohne dieses Fundament -# (Speicherort, Validierung, sicheres Anwenden) ändern zu müssen. +# NGINX (Systemeinstellungen -> NGINX) +# Verwaltet den kompletten Reverse-Proxy: Domain/Ports, SSL/HSTS, +# Zertifikat (Upload ODER Let's Encrypt). +# +# Let's Encrypt läuft bewusst über "certbot certonly --webroot", NICHT über +# das certbot-nginx-Plugin -- das Plugin würde NGINX_SITE_CONFIG_PATH +# direkt editieren (eigene server-Blöcke/Zeilen einfügen) und diese +# Änderungen bei der nächsten Änderung über diese Seite (die die Datei +# komplett neu schreibt, siehe _render_nginx_config) wieder verlieren. +# Mit der webroot-Methode bleibt _render_nginx_config die EINZIGE Quelle +# für den Datei-Inhalt; certbot legt nur Dateien unter TESM_ACME_WEBROOT +# ab und liest/schreibt sein Zertifikat unter /etc/letsencrypt/live/. # ============================================================================ NGINX_REVERT_SECONDS = 45 _pending_nginx_revert = {} +_DOMAIN_RE = re.compile(r"^(?=.{1,253}$)([a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\.)+[a-zA-Z]{2,63}$") + + +def _valid_server_name(name): + """"_" (nginx-Catch-all, kein bestimmter Hostname -- Standard für rein + interne Instanzen ohne eigene Domain) oder eine plausibel geformte + FQDN.""" + return name == "_" or bool(_DOMAIN_RE.match(name)) + + +def _valid_port(value): + try: + n = int(value) + except (TypeError, ValueError): + return False + return 1 <= n <= 65535 and n != 5000 + + _NGINX_PROXY_LOCATIONS = """ location /ws/ { proxy_pass http://127.0.0.1:5000; proxy_http_version 1.1; @@ -2560,33 +2613,80 @@ _NGINX_PROXY_LOCATIONS = """ location /ws/ { """ -def _render_nginx_config(ssl_enabled, hsts_enabled): +def _render_nginx_config(server_name, http_port, https_port, ssl_enabled, hsts_enabled, cert_path, key_path): """Erzeugt den kompletten Inhalt von NGINX_SITE_CONFIG_PATH. Die Reverse-Proxy-Regeln (WebSocket-Terminal, Haupt-App, statische Dateien) - sind bei aktivem SSL identisch, nur hinter einem 443-Block mit - Zertifikat/HSTS statt direkt hinter Port 80 -- Port 80 leitet dann nur - noch auf https weiter, damit ein alter Bookmark/Link nicht plötzlich - ins Leere läuft.""" + sind bei aktivem SSL identisch, nur hinter einem HTTPS-Block mit + Zertifikat/HSTS statt direkt hinter dem HTTP-Port -- der HTTP-Port + leitet dann nur noch auf https weiter, damit ein alter Bookmark/Link + nicht plötzlich ins Leere läuft. Die ACME-Challenge-Location wird + IMMER mitgeschrieben (auch ohne aktives SSL) -- eine Let's-Encrypt- + (Erst-)Anforderung braucht sie schon VOR dem ersten SSL-Aktivieren, + eine Verlängerung später ebenso. ACME HTTP-01 verbindet sich immer auf + Port 80 (protokollbedingt, nicht konfigurierbar) -- weicht der + eingestellte HTTP-Port davon ab, wird zusätzlich ein minimaler, nur + dafür zuständiger Port-80-Block ergänzt, damit Let's Encrypt trotzdem + funktioniert.""" header = 'map $http_upgrade $connection_upgrade {\n default upgrade;\n "" close;\n}\n\n' + acme_location = ( + " location /.well-known/acme-challenge/ {\n" + f" root {TESM_ACME_WEBROOT};\n" + " try_files $uri =404;\n" + " }\n\n" + ) + + extra_acme_block = "" + if str(http_port) != "80": + extra_acme_block = ( + "server {\n listen 80;\n server_name _;\n\n" + + acme_location + + " location / { return 404; }\n}\n\n" + ) + if not ssl_enabled: - return header + "server {\n listen 80;\n server_name _;\n\n" + _NGINX_PROXY_LOCATIONS + "}\n" + return ( + header + extra_acme_block + + f"server {{\n listen {http_port};\n server_name {server_name};\n\n" + + acme_location + + _NGINX_PROXY_LOCATIONS + + "}\n" + ) hsts_line = "" if hsts_enabled: hsts_line = ' add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;\n\n' + redirect_target = "https://$host$request_uri" if str(https_port) == "443" else f"https://$host:{https_port}$request_uri" return ( - header - + "server {\n listen 80;\n server_name _;\n return 301 https://$host$request_uri;\n}\n\n" - + "server {\n listen 443 ssl;\n server_name _;\n\n" - + f" ssl_certificate {TESM_SSL_CERT_PATH};\n" - + f" ssl_certificate_key {TESM_SSL_KEY_PATH};\n\n" + header + extra_acme_block + + f"server {{\n listen {http_port};\n server_name {server_name};\n\n" + + acme_location + + f" location / {{\n return 301 {redirect_target};\n }}\n" + + "}\n\n" + + f"server {{\n listen {https_port} ssl;\n server_name {server_name};\n\n" + + f" ssl_certificate {cert_path};\n" + + f" ssl_certificate_key {key_path};\n\n" + hsts_line + _NGINX_PROXY_LOCATIONS + "}\n" ) -def _apply_nginx_config(ssl_enabled, hsts_enabled): +def _current_cert_paths(): + """(cert_path, key_path) je nach aktiver Zertifikatsquelle (Setting + ssl_source: "upload" oder "letsencrypt"), oder (None, None) wenn + keins vorhanden ist.""" + if get_setting("ssl_source", "upload") == "letsencrypt": + domain = get_setting("nginx_server_name", "_") + cert, key = _le_cert_path(domain), _le_key_path(domain) + if os.path.isfile(cert) and os.path.isfile(key): + return cert, key + return None, None + if os.path.isfile(TESM_SSL_CERT_PATH) and os.path.isfile(TESM_SSL_KEY_PATH): + return TESM_SSL_CERT_PATH, TESM_SSL_KEY_PATH + return None, None + + +def _apply_nginx_config(server_name, http_port, https_port, ssl_enabled, hsts_enabled): """Schreibt die neue nginx-Konfiguration, validiert sie (nginx -t) und lädt nginx erst danach neu -- schlägt Validierung ODER Reload fehl, wird der vorherige Inhalt sofort wiederhergestellt (Selbstschutz gegen @@ -2600,7 +2700,13 @@ def _apply_nginx_config(ssl_enabled, hsts_enabled): except OSError: previous = None - new_content = _render_nginx_config(ssl_enabled, hsts_enabled) + cert_path = key_path = None + if ssl_enabled: + cert_path, key_path = _current_cert_paths() + if not cert_path: + return False, "Kein Zertifikat verfügbar.", previous + + new_content = _render_nginx_config(server_name, http_port, https_port, ssl_enabled, hsts_enabled, cert_path, key_path) try: with open(NGINX_SITE_CONFIG_PATH, "w", encoding="utf-8") as f: f.write(new_content) @@ -2626,8 +2732,8 @@ def _apply_nginx_config(ssl_enabled, hsts_enabled): def _revert_nginx_config(token): """Timer-Callback analog zu _revert_network_config: keine Bestätigung - binnen NGINX_REVERT_SECONDS -> vorherige nginx-Konfiguration UND die - zugehörigen ssl_enabled/hsts_enabled-Einstellungen wiederherstellen.""" + binnen NGINX_REVERT_SECONDS -> vorherige nginx-Konfiguration UND alle + zugehörigen Einstellungen (Domain, Ports, SSL, HSTS) wiederherstellen.""" entry = _pending_nginx_revert.get(token) if not entry: return @@ -2637,9 +2743,9 @@ def _revert_nginx_config(token): f.write(entry["previous"]) subprocess.run(["nginx", "-t"], capture_output=True, timeout=10) subprocess.run(["systemctl", "reload", "nginx"], capture_output=True, timeout=10) - set_setting("ssl_enabled", entry["prev_ssl_enabled"]) - set_setting("hsts_enabled", entry["prev_hsts_enabled"]) - log_action_system("settings.ssl_revert", "nginx", "automatisch nach Timeout zurückgerollt") + for key, value in entry["prev_settings"].items(): + set_setting(key, value) + log_action_system("settings.nginx_revert", "nginx", "automatisch nach Timeout zurückgerollt") except Exception as e: app.logger.error("nginx auto-revert failed: %s", e) finally: @@ -2693,30 +2799,67 @@ def _validate_cert_key_pair(cert_bytes, key_bytes): def _current_cert_info(): - """Für die SSL-Seite: Status des aktuell gespeicherten Zertifikats - (falls vorhanden) inkl. verbleibender Gültigkeitsdauer.""" - if not (os.path.isfile(TESM_SSL_CERT_PATH) and os.path.isfile(TESM_SSL_KEY_PATH)): + """Für die NGINX-Seite: Status des aktuell aktiven Zertifikats (Upload + oder Let's Encrypt, je nach ssl_source) inkl. verbleibender + Gültigkeitsdauer.""" + cert_path, _key_path = _current_cert_paths() + if not cert_path: return None try: - with open(TESM_SSL_CERT_PATH, "rb") as f: + with open(cert_path, "rb") as f: _, info = _parse_cert(f.read()) info["days_left"] = (info["not_after"] - datetime.utcnow()).days + info["source"] = get_setting("ssl_source", "upload") return info except (OSError, ValueError): return None -@app.route("/settings/ssl", methods=["GET", "POST"]) +def _request_letsencrypt_cert(domain, email=None): + """Fordert per certbot (webroot-Methode, siehe TESM_ACME_WEBROOT) ein + Zertifikat für die angegebene Domain an. Setzt voraus, dass die Domain + öffentlich auf diesen Host auflöst UND Port 80 aus dem Internet + erreichbar ist (ACME HTTP-01) -- beides kann diese App nicht prüfen, + ein Fehlschlag hier liegt i.d.R. an der Netzwerk-/DNS-Situation, nicht + an TESM selbst. Automatische Verlängerung übernimmt certbots eigener + systemd-Timer/Cron (Standard-Paketverhalten unter Debian/Ubuntu), der + Deploy-Hook (_write_certbot_deploy_hook) lädt nginx danach neu.""" + args = [ + "certbot", "certonly", "--webroot", "-w", TESM_ACME_WEBROOT, + "-d", domain, "--non-interactive", "--agree-tos", "--cert-name", domain, + ] + args += ["--email", email] if email else ["--register-unsafely-without-email"] + return _dhcp_run_privileged(args, timeout=120) + + +@app.route("/settings/nginx/raw") @login_required -def settings_ssl(): - if request.method == "GET" and not current_user.has_permission("settings_ssl.view"): - flash("Keine Berechtigung, die SSL/TLS-Einstellungen anzusehen.", "danger") +def settings_nginx_raw(): + """Komplette, unbearbeitete nginx-Konfiguration für das RAW-Popup -- + gleiches Muster wie beim Live-Log (window.openRawLogModal).""" + if not current_user.has_permission("settings_nginx.view"): + return "Keine Berechtigung.", 403 + try: + with open(NGINX_SITE_CONFIG_PATH, "r", encoding="utf-8", errors="replace") as f: + content = f.read() + except OSError as e: + content = f"Fehler beim Lesen der Konfiguration: {e}" + response = app.response_class(content, mimetype="text/plain") + response.headers["X-Log-Name"] = os.path.basename(NGINX_SITE_CONFIG_PATH) + return response + + +@app.route("/settings/nginx", methods=["GET", "POST"]) +@login_required +def settings_nginx(): + if request.method == "GET" and not current_user.has_permission("settings_nginx.view"): + flash("Keine Berechtigung, die NGINX-Einstellungen anzusehen.", "danger") return redirect(url_for("index")) if request.method == "POST": - if not current_user.has_permission("settings_ssl.edit"): - flash("Keine Berechtigung, die SSL/TLS-Einstellungen zu ändern.", "danger") - return redirect(url_for("settings_ssl")) + if not current_user.has_permission("settings_nginx.edit"): + flash("Keine Berechtigung, die NGINX-Einstellungen zu ändern.", "danger") + return redirect(url_for("settings_nginx")) if "upload_cert" in request.form: cert_file = request.files.get("cert_file") @@ -2738,7 +2881,8 @@ def settings_ssl(): f.write(key_bytes) os.chmod(TESM_SSL_KEY_PATH, 0o600) os.chmod(TESM_SSL_CERT_PATH, 0o644) - log_action("settings.ssl_cert_upload", info.get("subject", ""), f"gültig bis {info['not_after']:%d.%m.%Y}") + set_setting("ssl_source", "upload") + log_action("settings.nginx_cert_upload", info.get("subject", ""), f"gültig bis {info['not_after']:%d.%m.%Y}") flash( f"Zertifikat gespeichert (Subject: {info.get('subject')}, gültig bis " f"{info['not_after']:%d.%m.%Y}). SSL ist damit noch nicht aktiviert -- unten aktivieren.", @@ -2747,72 +2891,122 @@ def settings_ssl(): except OSError as e: flash(f"Zertifikat konnte nicht gespeichert werden: {e}", "danger") - elif "apply_ssl" in request.form: + elif "request_letsencrypt" in request.form: + domain = request.form.get("le_domain", "").strip() + email = request.form.get("le_email", "").strip() or None + if not domain or not _DOMAIN_RE.match(domain): + flash("Bitte eine gültige, öffentlich auflösbare Domain angeben.", "danger") + else: + ok, out = _request_letsencrypt_cert(domain, email) + if not ok: + flash(f"Let's-Encrypt-Anforderung fehlgeschlagen: {out.strip()[-800:]}", "danger") + else: + set_setting("ssl_source", "letsencrypt") + set_setting("nginx_server_name", domain) + log_action("settings.nginx_letsencrypt_request", domain, "Zertifikat erfolgreich ausgestellt") + flash( + f"Let's-Encrypt-Zertifikat für {domain} ausgestellt (automatische Verlängerung läuft über " + f"certbots eigenen Timer). SSL ist damit noch nicht aktiviert -- unten aktivieren.", + "success", + ) + + elif "apply_nginx" in request.form: + new_server_name = request.form.get("server_name", "_").strip() or "_" + new_http_port = request.form.get("http_port", "80").strip() + new_https_port = request.form.get("https_port", "443").strip() new_ssl = "ssl_enabled" in request.form new_hsts = "hsts_enabled" in request.form - if new_ssl and not (os.path.isfile(TESM_SSL_CERT_PATH) and os.path.isfile(TESM_SSL_KEY_PATH)): - flash("Kein Zertifikat hinterlegt -- bitte zuerst Zertifikat + Schlüssel hochladen.", "danger") + + if not _valid_server_name(new_server_name): + flash("Ungültiger Domain-/Servername.", "danger") + elif not _valid_port(new_http_port) or not _valid_port(new_https_port): + flash("Port muss eine Zahl zwischen 1 und 65535 sein (5000 ist für die App selbst reserviert).", "danger") + elif new_http_port == new_https_port: + flash("HTTP- und HTTPS-Port müssen unterschiedlich sein.", "danger") + elif new_ssl and not _current_cert_paths()[0]: + flash("Kein Zertifikat verfügbar -- bitte zuerst hochladen oder per Let's Encrypt anfordern.", "danger") elif new_hsts and not new_ssl: flash("HSTS setzt aktives SSL voraus.", "danger") else: - prev_ssl = get_setting("ssl_enabled", "0") - prev_hsts = get_setting("hsts_enabled", "0") - ok, err, previous = _apply_nginx_config(new_ssl, new_hsts) + prev_settings = { + "nginx_server_name": get_setting("nginx_server_name", "_"), + "nginx_http_port": get_setting("nginx_http_port", "80"), + "nginx_https_port": get_setting("nginx_https_port", "443"), + "ssl_enabled": get_setting("ssl_enabled", "0"), + "hsts_enabled": get_setting("hsts_enabled", "0"), + } + ok, err, previous = _apply_nginx_config(new_server_name, new_http_port, new_https_port, new_ssl, new_hsts) if not ok: flash(f"nginx-Konfiguration konnte nicht angewendet werden, unverändert gelassen: {err}", "danger") else: + set_setting("nginx_server_name", new_server_name) + set_setting("nginx_http_port", new_http_port) + set_setting("nginx_https_port", new_https_port) set_setting("ssl_enabled", "1" if new_ssl else "0") set_setting("hsts_enabled", "1" if new_hsts else "0") - log_action("settings.ssl_apply", "nginx", f"ssl={new_ssl} hsts={new_hsts}") + log_action( + "settings.nginx_apply", new_server_name, + f"http={new_http_port} https={new_https_port} ssl={new_ssl} hsts={new_hsts}", + ) token = secrets.token_hex(8) timer = threading.Timer(NGINX_REVERT_SECONDS, _revert_nginx_config, args=(token,)) timer.daemon = True _pending_nginx_revert.clear() - _pending_nginx_revert[token] = { - "timer": timer, "previous": previous, - "prev_ssl_enabled": prev_ssl, "prev_hsts_enabled": prev_hsts, - } + _pending_nginx_revert[token] = {"timer": timer, "previous": previous, "prev_settings": prev_settings} timer.start() scheme = "https" if new_ssl else "http" + port = new_https_port if new_ssl else new_http_port + port_suffix = "" if port in ("80", "443") else f":{port}" flash( - f"nginx-Konfiguration angewendet. Falls diese Seite jetzt noch über {scheme} erreichbar " - f"ist, bitte unten bestätigen — sonst wird nach {NGINX_REVERT_SECONDS}s automatisch " - f"zurückgerollt.", + f"nginx-Konfiguration angewendet. Falls diese Seite jetzt noch über {scheme}{port_suffix} " + f"erreichbar ist, bitte unten bestätigen — sonst wird nach {NGINX_REVERT_SECONDS}s " + f"automatisch zurückgerollt.", "success", ) - elif "confirm_ssl" in request.form: - token = request.form.get("confirm_ssl") + elif "confirm_nginx" in request.form: + token = request.form.get("confirm_nginx") entry = _pending_nginx_revert.pop(token, None) if entry: entry["timer"].cancel() - log_action("settings.ssl_confirm", "nginx") - flash("SSL-Konfiguration bestätigt — kein automatisches Rollback mehr.", "success") + log_action("settings.nginx_confirm", "nginx") + flash("nginx-Konfiguration bestätigt — kein automatisches Rollback mehr.", "success") else: flash("Keine ausstehende Bestätigung gefunden (evtl. bereits abgelaufen).", "danger") elif "remove_cert" in request.form: if get_setting("ssl_enabled", "0") == "1": flash("Zertifikat kann nicht entfernt werden, solange SSL aktiv ist -- bitte zuerst deaktivieren.", "danger") + elif get_setting("ssl_source", "upload") == "letsencrypt": + domain = get_setting("nginx_server_name", "_") + ok, out = _dhcp_run_privileged(["certbot", "delete", "--cert-name", domain, "--non-interactive"], timeout=30) + if not ok: + flash(f"Let's-Encrypt-Zertifikat konnte nicht entfernt werden: {out}", "danger") + else: + log_action("settings.nginx_cert_remove", domain, "Let's Encrypt") + flash("Let's-Encrypt-Zertifikat entfernt.", "success") else: for path in (TESM_SSL_CERT_PATH, TESM_SSL_KEY_PATH): try: os.remove(path) except OSError: pass - log_action("settings.ssl_cert_remove", "nginx") - flash("Zertifikat entfernt.", "success") + log_action("settings.nginx_cert_remove", "upload") + flash("Hochgeladenes Zertifikat entfernt.", "success") - return redirect(url_for("settings_ssl")) + return redirect(url_for("settings_nginx")) pending_token = next(iter(_pending_nginx_revert), None) return render_template( - "settings_ssl.html", + "settings_nginx.html", cert_info=_current_cert_info(), ssl_enabled=get_setting("ssl_enabled", "0") == "1", hsts_enabled=get_setting("hsts_enabled", "0") == "1", - pending_ssl_token=pending_token, - ssl_revert_seconds=NGINX_REVERT_SECONDS, + server_name=get_setting("nginx_server_name", "_"), + http_port=get_setting("nginx_http_port", "80"), + https_port=get_setting("nginx_https_port", "443"), + pending_nginx_token=pending_token, + nginx_revert_seconds=NGINX_REVERT_SECONDS, ) diff --git a/srv/tesm/templates/base.html b/srv/tesm/templates/base.html index 4052437..01f74c1 100644 --- a/srv/tesm/templates/base.html +++ b/srv/tesm/templates/base.html @@ -21,6 +21,7 @@ "history": '', "clock": '', "shield": '', + "server": '', "sliders": '', "logout": '', "gear": '', diff --git a/srv/tesm/templates/settings_ssl.html b/srv/tesm/templates/settings_nginx.html similarity index 54% rename from srv/tesm/templates/settings_ssl.html rename to srv/tesm/templates/settings_nginx.html index e6a9ab6..d9a1d74 100644 --- a/srv/tesm/templates/settings_ssl.html +++ b/srv/tesm/templates/settings_nginx.html @@ -1,22 +1,98 @@ {% extends "base.html" %} -{% set active_page = "settings_ssl" %} -{% set can_edit = current_user.has_permission('settings_ssl.edit') %} -{% block page_title %}SSL/TLS{% endblock %} -{% block page_sub %}
HTTPS-Verschlüsselung und HSTS für den nginx-Reverse-Proxy
{% endblock %} +{% set active_page = "settings_nginx" %} +{% set can_edit = current_user.has_permission('settings_nginx.edit') %} +{% block page_title %}NGINX{% endblock %} +{% block page_sub %}
Reverse-Proxy: Domain, Ports, SSL/HSTS und Zertifikat
{% endblock %} {% block content %}
+
+
+
+

Reverse-Proxy

+
Domain (server_name), Ports und HTTPS/HSTS für den nginx-Reverse-Proxy vor der App.
+
+ +
+ + {% if pending_nginx_token %} +
+

+ Neue nginx-Konfiguration wurde angewendet. Wenn diese Seite gerade noch lädt, funktioniert die Verbindung — + bitte bestätigen, bevor automatisch zurückgerollt wird (nach {{ nginx_revert_seconds }}s ohne Bestätigung). +

+
+ + +
+
+ {% elif can_edit %} +
+
+ + +
"_" ist nginx' Catch-all (Standard für interne Instanzen ohne eigene Domain) — für Let's Encrypt muss hier die tatsächliche, öffentlich auflösbare Domain stehen.
+
+
+
+ + +
+
+ + +
+
+
+ Let's Encrypt validiert IMMER über Port 80 (protokollbedingt, unabhängig vom hier eingestellten HTTP-Port) — + weicht der HTTP-Port von 80 ab, wird dafür automatisch zusätzlich ein minimaler Port-80-Block mitgeschrieben. +
+
+ + {% if not cert_info %}
Erst nach Hochladen oder Anfordern eines Zertifikats verfügbar.
{% endif %} +
+
+ +
Weist Browser an, diese Instanz künftig NUR noch über HTTPS aufzurufen — bleibt auch bei einem späteren Zurückschalten auf HTTP im Browser für 1 Jahr bestehen. Nur aktivieren, wenn das Zertifikat dauerhaft gepflegt wird.
+
+ +
+ {% else %} +

Für Änderungen fehlt das Recht „NGINX ändern“.

+ {% endif %} +
+

Zertifikat

-
Zertifikat + privater Schlüssel im PEM-Format. Automatische Ausstellung über Let's Encrypt ist für rein interne Instanzen (noch) nicht verfügbar — Upload eines vorhandenen (z.B. selbst ausgestellten oder von einer internen CA signierten) Zertifikats.
+
Hochladen (PEM) oder automatisch per Let's Encrypt anfordern — mit automatischer Verlängerung über certbots eigenen Timer.
{% if cert_info %}
+
Quelle{{ "Let's Encrypt" if cert_info.source == "letsencrypt" else "Hochgeladen" }}
Subject{{ cert_info.subject }}
Aussteller{{ cert_info.issuer }}
@@ -38,6 +114,26 @@ {% endif %} {% if can_edit %} +
+ +
Setzt voraus, dass die Domain öffentlich auf diesen Host auflöst und Port 80 aus dem Internet erreichbar ist (HTTP-01-Validierung) — für rein interne Instanzen ohne öffentliche Domain nicht nutzbar, dann stattdessen hochladen.
+
+
+
+ + +
+
+ + +
+ +
+ +
@@ -46,13 +142,14 @@
-
Ein passwortgeschützter Schlüssel wird abgelehnt — nginx könnte ihn beim Start ohnehin nicht ohne manuelle Passworteingabe laden.
+
Ein passwortgeschütztes Schlüssel wird abgelehnt — nginx könnte ihn beim Start ohnehin nicht ohne manuelle Passworteingabe laden.
+ {% if cert_info and not ssl_enabled %}
@@ -61,58 +158,7 @@

SSL muss zuerst deaktiviert werden, bevor das Zertifikat entfernt werden kann.

{% endif %} {% else %} -

Für Upload/Entfernen fehlt das Recht „SSL/TLS ändern“.

- {% endif %} -
- -
-
-
-

HTTPS aktivieren

-
Schaltet nginx auf Port 443 mit dem oben hinterlegten Zertifikat um. Port 80 leitet dann automatisch weiter, damit bestehende Lesezeichen/Links nicht ins Leere laufen.
-
-
- - {% if pending_ssl_token %} -
-

- Neue nginx-Konfiguration wurde angewendet. Wenn diese Seite gerade noch lädt, funktioniert die Verbindung — - bitte bestätigen, bevor automatisch zurückgerollt wird (nach {{ ssl_revert_seconds }}s ohne Bestätigung). -

- - - - -
- {% elif can_edit %} -
-
- - {% if not cert_info %}
Erst nach Hochladen eines Zertifikats verfügbar.
{% endif %} -
-
- -
Weist Browser an, diese Instanz künftig NUR noch über HTTPS aufzurufen — auch bei einem späteren Zurückschalten auf HTTP bleibt das im Browser für die eingestellte Dauer (1 Jahr) bestehen. Nur aktivieren, wenn das Zertifikat dauerhaft gepflegt wird.
-
- -
- {% else %} -

Für das Aktivieren fehlt das Recht „SSL/TLS ändern“.

+

Für Anfordern/Upload/Entfernen fehlt das Recht „NGINX ändern“.

{% endif %}