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 <noreply@anthropic.com>
This commit is contained in:
2026-08-22 12:15:32 +02:00
co-authored by Claude Sonnet 5
parent f27dde466b
commit 4de5828573
5 changed files with 375 additions and 130 deletions
+264 -70
View File
@@ -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,
)
+1
View File
@@ -21,6 +21,7 @@
"history": '<path d="M3 12a9 9 0 109-9 9.75 9.75 0 00-6.74 2.74L3 8"/><path d="M3 3v5h5"/><path d="M12 7v5l4 2"/>',
"clock": '<circle cx="12" cy="12" r="9"/><path d="M12 7v5l3 3"/>',
"shield": '<path d="M12 2l8 4v6c0 5-3.5 8.5-8 10-4.5-1.5-8-5-8-10V6z"/><path d="M9 12l2 2 4-4"/>',
"server": '<rect x="2" y="3" width="20" height="7" rx="1.5"/><rect x="2" y="14" width="20" height="7" rx="1.5"/><path d="M6 6.5h.01M6 17.5h.01"/>',
"sliders": '<path d="M4 6h9M17 6h3M4 12h3M11 12h9M4 18h13M20 18h0"/><circle cx="15" cy="6" r="2"/><circle cx="9" cy="12" r="2"/><circle cx="17" cy="18" r="2"/>',
"logout": '<path d="M9 21H5a2 2 0 01-2-2V5a2 2 0 012-2h4"/><path d="M16 17l5-5-5-5"/><path d="M21 12H9"/>',
"gear": '<circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 00.33 1.82l.06.06a2 2 0 11-2.83 2.83l-.06-.06a1.65 1.65 0 00-1.82-.33 1.65 1.65 0 00-1 1.51V21a2 2 0 01-4 0v-.09A1.65 1.65 0 009 19.4a1.65 1.65 0 00-1.82.33l-.06.06a2 2 0 11-2.83-2.83l.06-.06A1.65 1.65 0 004.6 15a1.65 1.65 0 00-1.51-1H3a2 2 0 010-4h.09A1.65 1.65 0 004.6 9a1.65 1.65 0 00-.33-1.82l-.06-.06a2 2 0 112.83-2.83l.06.06A1.65 1.65 0 009 4.6a1.65 1.65 0 001-1.51V3a2 2 0 014 0v.09a1.65 1.65 0 001 1.51 1.65 1.65 0 001.82-.33l.06-.06a2 2 0 112.83 2.83l-.06.06A1.65 1.65 0 0019.4 9a1.65 1.65 0 001.51 1H21a2 2 0 010 4h-.09a1.65 1.65 0 00-1.51 1z"/>',
@@ -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 %}<div class="topbar-sub">HTTPS-Verschlüsselung und HSTS für den nginx-Reverse-Proxy</div>{% endblock %}
{% set active_page = "settings_nginx" %}
{% set can_edit = current_user.has_permission('settings_nginx.edit') %}
{% block page_title %}NGINX{% endblock %}
{% block page_sub %}<div class="topbar-sub">Reverse-Proxy: Domain, Ports, SSL/HSTS und Zertifikat</div>{% endblock %}
{% block content %}
<div class="settings-grid" style="grid-template-columns: repeat(auto-fit, minmax(420px, 1fr));">
<div class="card card-pad">
<div class="section-head" style="margin-bottom:16px;">
<div>
<h2 style="font-size:16px;">Reverse-Proxy</h2>
<div class="hint">Domain (server_name), Ports und HTTPS/HSTS für den nginx-Reverse-Proxy vor der App.</div>
</div>
<button type="button" class="btn btn-secondary btn-sm"
onclick="openRawLogModal('{{ url_for('settings_nginx_raw') }}', 'Aktuelle nginx-Konfiguration (RAW)')">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14 2H6a2 2 0 00-2 2v16a2 2 0 002 2h12a2 2 0 002-2V8z"/><path d="M14 2v6h6"/></svg>
RAW-Konfiguration
</button>
</div>
{% if pending_nginx_token %}
<div class="card-pad" style="background:var(--warning-dim); border-radius:var(--radius-sm); margin-bottom:16px;">
<p style="margin:0 0 12px; font-size:13px;">
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).
</p>
<form method="post">
<input type="hidden" name="confirm_nginx" value="{{ pending_nginx_token }}">
<button type="submit" class="btn btn-primary">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20 6L9 17l-5-5"/></svg>
Verbindung funktioniert — bestätigen
</button>
</form>
</div>
{% elif can_edit %}
<form method="post"
data-confirm="nginx-Konfiguration wirklich ändern? Falls die Verbindung danach abbricht, wird die vorherige Konfiguration automatisch nach {{ nginx_revert_seconds }} Sekunden wiederhergestellt.">
<div class="field">
<label for="server_name">Domain (server_name)</label>
<input type="text" name="server_name" id="server_name" value="{{ server_name }}" placeholder="_ (kein bestimmter Hostname) oder z.B. tesm.example.com">
<div class="field-hint">"_" 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.</div>
</div>
<div class="flex gap-2">
<div class="field" style="flex:1;">
<label for="http_port">HTTP-Port</label>
<input type="number" name="http_port" id="http_port" min="1" max="65535" value="{{ http_port }}" required>
</div>
<div class="field" style="flex:1;">
<label for="https_port">HTTPS-Port</label>
<input type="number" name="https_port" id="https_port" min="1" max="65535" value="{{ https_port }}" required>
</div>
</div>
<div class="field-hint" style="margin-top:-8px; margin-bottom:14px;">
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.
</div>
<div class="field">
<label class="switch-check" style="display:inline-flex; align-items:center; gap:8px;">
<input type="checkbox" name="ssl_enabled" id="ssl_enabled_check" {% if ssl_enabled %}checked{% endif %} {% if not cert_info %}disabled{% endif %}>
<span class="track"></span>
<span>SSL/HTTPS aktivieren</span>
</label>
{% if not cert_info %}<div class="field-hint">Erst nach Hochladen oder Anfordern eines Zertifikats verfügbar.</div>{% endif %}
</div>
<div class="field">
<label class="switch-check" style="display:inline-flex; align-items:center; gap:8px;">
<input type="checkbox" name="hsts_enabled" id="hsts_enabled_check" {% if hsts_enabled %}checked{% endif %} {% if not ssl_enabled %}disabled{% endif %}>
<span class="track"></span>
<span>HSTS (Strict-Transport-Security)</span>
</label>
<div class="field-hint">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.</div>
</div>
<button type="submit" name="apply_nginx" value="1" class="btn btn-primary btn-block">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9 12l2 2 4-4"/><path d="M12 2l8 4v6c0 5-3.5 8.5-8 10-4.5-1.5-8-5-8-10V6z"/></svg>
Anwenden
</button>
</form>
{% else %}
<p class="text-faint" style="font-size:12.5px;">Für Änderungen fehlt das Recht „NGINX ändern“.</p>
{% endif %}
</div>
<div class="card card-pad">
<div class="section-head" style="margin-bottom:16px;">
<div>
<h2 style="font-size:16px;">Zertifikat</h2>
<div class="hint">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.</div>
<div class="hint">Hochladen (PEM) oder automatisch per Let's Encrypt anfordern — mit automatischer Verlängerung über certbots eigenen Timer.</div>
</div>
</div>
{% if cert_info %}
<div class="detail-list" style="margin-bottom:18px;">
<div class="detail-row"><span class="k">Quelle</span><span class="v">{{ "Let's Encrypt" if cert_info.source == "letsencrypt" else "Hochgeladen" }}</span></div>
<div class="detail-row"><span class="k">Subject</span><span class="v" style="text-align:right; word-break:break-all;">{{ cert_info.subject }}</span></div>
<div class="detail-row"><span class="k">Aussteller</span><span class="v" style="text-align:right; word-break:break-all;">{{ cert_info.issuer }}</span></div>
<div class="detail-row">
@@ -38,6 +114,26 @@
{% endif %}
{% if can_edit %}
<div class="field">
<label style="font-weight:600; font-size:13px;">Let's Encrypt anfordern</label>
<div class="field-hint" style="margin-bottom:8px;">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.</div>
</div>
<form method="post" style="margin-bottom:18px;">
<div class="field">
<label for="le_domain">Domain</label>
<input type="text" name="le_domain" id="le_domain" placeholder="z.B. tesm.example.com" value="{{ server_name if server_name != '_' else '' }}">
</div>
<div class="field">
<label for="le_email">E-Mail (optional, für Ablauf-Erinnerungen)</label>
<input type="email" name="le_email" id="le_email" placeholder="admin@example.com">
</div>
<button type="submit" name="request_letsencrypt" value="1" class="btn btn-secondary btn-block">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 2l8 4v6c0 5-3.5 8.5-8 10-4.5-1.5-8-5-8-10V6z"/><path d="M9 12l2 2 4-4"/></svg>
Zertifikat anfordern
</button>
</form>
<div class="field"><label style="font-weight:600; font-size:13px;">Manuell hochladen</label></div>
<form method="post" enctype="multipart/form-data" style="margin-bottom:10px;">
<div class="field">
<label for="cert_file">Zertifikat (PEM, .crt/.pem)</label>
@@ -46,13 +142,14 @@
<div class="field">
<label for="key_file">Privater Schlüssel (PEM, unverschlüsselt)</label>
<input type="file" name="key_file" id="key_file" accept=".pem,.key" required>
<div class="field-hint">Ein passwortgeschützter Schlüssel wird abgelehnt — nginx könnte ihn beim Start ohnehin nicht ohne manuelle Passworteingabe laden.</div>
<div class="field-hint">Ein passwortgeschütztes Schlüssel wird abgelehnt — nginx könnte ihn beim Start ohnehin nicht ohne manuelle Passworteingabe laden.</div>
</div>
<button type="submit" name="upload_cert" value="1" class="btn btn-secondary btn-block">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4"/><path d="M17 8l-5-5-5 5"/><path d="M12 3v12"/></svg>
Zertifikat + Schlüssel hochladen
</button>
</form>
{% if cert_info and not ssl_enabled %}
<form method="post" data-confirm="Hinterlegtes Zertifikat wirklich entfernen?" data-no-unsaved-guard>
<button type="submit" name="remove_cert" value="1" class="btn btn-danger btn-block">Zertifikat entfernen</button>
@@ -61,58 +158,7 @@
<p class="text-faint" style="font-size:11.5px;">SSL muss zuerst deaktiviert werden, bevor das Zertifikat entfernt werden kann.</p>
{% endif %}
{% else %}
<p class="text-faint" style="font-size:12.5px;">Für Upload/Entfernen fehlt das Recht „SSL/TLS ändern“.</p>
{% endif %}
</div>
<div class="card card-pad">
<div class="section-head" style="margin-bottom:16px;">
<div>
<h2 style="font-size:16px;">HTTPS aktivieren</h2>
<div class="hint">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.</div>
</div>
</div>
{% if pending_ssl_token %}
<div class="card-pad" style="background:var(--warning-dim); border-radius:var(--radius-sm); margin-bottom:16px;">
<p style="margin:0 0 12px; font-size:13px;">
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).
</p>
<form method="post">
<input type="hidden" name="confirm_ssl" value="{{ pending_ssl_token }}">
<button type="submit" class="btn btn-primary">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20 6L9 17l-5-5"/></svg>
Verbindung funktioniert — bestätigen
</button>
</form>
</div>
{% elif can_edit %}
<form method="post"
data-confirm="nginx-Konfiguration wirklich ändern? Falls die Verbindung danach abbricht, wird die vorherige Konfiguration automatisch nach {{ ssl_revert_seconds }} Sekunden wiederhergestellt.">
<div class="field">
<label class="switch-check" style="display:inline-flex; align-items:center; gap:8px;">
<input type="checkbox" name="ssl_enabled" id="ssl_enabled_check" {% if ssl_enabled %}checked{% endif %} {% if not cert_info %}disabled{% endif %}>
<span class="track"></span>
<span>SSL/HTTPS aktivieren</span>
</label>
{% if not cert_info %}<div class="field-hint">Erst nach Hochladen eines Zertifikats verfügbar.</div>{% endif %}
</div>
<div class="field">
<label class="switch-check" style="display:inline-flex; align-items:center; gap:8px;">
<input type="checkbox" name="hsts_enabled" id="hsts_enabled_check" {% if hsts_enabled %}checked{% endif %} {% if not ssl_enabled %}disabled{% endif %}>
<span class="track"></span>
<span>HSTS (Strict-Transport-Security)</span>
</label>
<div class="field-hint">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.</div>
</div>
<button type="submit" name="apply_ssl" value="1" class="btn btn-primary btn-block">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9 12l2 2 4-4"/><path d="M12 2l8 4v6c0 5-3.5 8.5-8 10-4.5-1.5-8-5-8-10V6z"/></svg>
Anwenden
</button>
</form>
{% else %}
<p class="text-faint" style="font-size:12.5px;">Für das Aktivieren fehlt das Recht „SSL/TLS ändern“.</p>
<p class="text-faint" style="font-size:12.5px;">Für Anfordern/Upload/Entfernen fehlt das Recht „NGINX ändern“.</p>
{% endif %}
</div>