SSL/TLS-Verwaltung (Upload+Aktivieren), install.sh-Idempotenz, poe_web/rpi-check-Cleanup entfernt
SSL/TLS (neue Seite Einstellungen -> SSL/TLS, eigenes Recht settings_ssl.view/.edit):
- Zertifikat+privater Schluessel per Upload hinterlegbar (PEM), Validierung
VOR dem Speichern: gueltiges Zertifikat, noch nicht abgelaufen, Schluessel
passt zum Zertifikat (Abgleich ueber die oeffentlichen Schluessel als
DER-Bytes -- funktioniert algorithmus-unabhaengig fuer RSA/EC/...).
Speicherort /etc/tesm/ssl (bewusst ausserhalb von /srv/tesm, damit
rsync --delete beim Update das Zertifikat nicht mitloescht), Key mit
chmod 600.
- HTTPS aktivieren schreibt/validiert (nginx -t) eine neue nginx-Konfiguration
und laedt erst danach neu; Port 80 leitet dann automatisch auf https weiter.
HSTS optional zuschaltbar, setzt aktives SSL voraus.
- Gleiches Sicherheitsnetz wie bei den Netzwerkeinstellungen: nach dem
Anwenden 45s Zeitfenster zum Bestaetigen, sonst automatischer Rueckroll
der nginx-Konfiguration UND der ssl_enabled/hsts_enabled-Einstellungen.
Beide Pfade (Timeout-Rollback und Bestaetigen-verhindert-Rollback) live
auf Testsystem-Update-Restart verifiziert, inklusive eines ECHTEN,
ungeplant ausgeloesten Rollbacks waehrend des Testens.
- Noch KEINE Let's-Encrypt-Automatisierung (siehe Kommentar im Code) --
alle bekannten Instanzen laufen intern ohne oeffentliche Domain, HTTP-01
wuerde dort nicht funktionieren; kann bei Bedarf separat ergaenzt werden.
install.sh: Update-Idempotenz ("nur anfassen, was sich geaendert hat"):
- install_if_changed()-Helfer: Custom-Skript/systemd-Units/MOTD-Banner
werden nur noch bei tatsaechlich geaendertem Inhalt neu geschrieben,
daemon-reload nur wenn sich mindestens eine Unit geaendert hat.
- nginx-Konfiguration wird nur noch bei einer Frischinstallation aus dem
Repo-Template geschrieben -- eine bereits vorhandene (egal ob unveraendert
oder von der App selbst um SSL erweitert) wird nie wieder ueberschrieben.
Voraussetzung dafuer, dass die neue SSL-Funktion ein Update ueberlebt.
update.sh: komplette Sonderbehandlung fuer die zwei TESM vorausgehenden
Generationen (rpi-check.service, poe_web.service/poe_manager) entfernt --
auf allen aktuellen Installationen laengst migriert. Deaktivierte
Unit-Dateien dieser Generationen wurden auf Testsystem-Update-Restart
zusaetzlich manuell endgueltig geloescht (waren dort seit einer Migration
im September 2025 nur deaktiviert, nicht entfernt).
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
+323
-1
@@ -16,7 +16,8 @@ from simple_websocket.errors import ConnectionClosed
|
||||
from werkzeug.utils import secure_filename
|
||||
from cryptography.fernet import Fernet
|
||||
from cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2HMAC
|
||||
from cryptography.hazmat.primitives import hashes
|
||||
from cryptography.hazmat.primitives import hashes, serialization
|
||||
from cryptography import x509
|
||||
from datetime import datetime, timedelta
|
||||
import base64, csv, ipaddress, logging, shlex, shutil, socket, sqlite3, glob, json, os, re, secrets, stat, subprocess, threading, time, traceback
|
||||
import paramiko
|
||||
@@ -107,6 +108,20 @@ except OSError:
|
||||
SWITCH_DEFAULT_SSH_PORT = 22
|
||||
ALLOWED_AVATAR_EXT = {"png", "jpg", "jpeg", "gif", "webp"}
|
||||
|
||||
NGINX_SITE_CONFIG_PATH = os.environ.get("TESM_NGINX_CONFIG", "/etc/nginx/sites-available/tesm")
|
||||
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")
|
||||
try:
|
||||
# Bewusst AUSSERHALB von /srv/tesm -- das rsync --delete beim Deployen
|
||||
# (siehe install.sh) würde ein dort abgelegtes Zertifikat sonst bei
|
||||
# jedem Update wieder löschen. Analog zu /etc/nginx/ selbst: liegt
|
||||
# außerhalb des App-Verzeichnisses, übersteht also jedes Update.
|
||||
os.makedirs(TESM_SSL_DIR, exist_ok=True)
|
||||
os.chmod(TESM_SSL_DIR, 0o700)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
CREDENTIAL_CATEGORIES = [
|
||||
("switch", "Switch"),
|
||||
("linux", "Linux-Client"),
|
||||
@@ -260,6 +275,10 @@ 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"},
|
||||
},
|
||||
"papierkorb": {
|
||||
"label": "Papierkorb",
|
||||
"rows": {"view": "papierkorb.view", "edit": "papierkorb.edit"},
|
||||
@@ -298,6 +317,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)",
|
||||
}
|
||||
|
||||
ALL_PERMISSION_KEYS = []
|
||||
@@ -337,6 +358,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": "papierkorb", "label": "Papierkorb", "icon": "trash", "endpoint": "papierkorb"},
|
||||
]},
|
||||
{"key": "logs_group", "label": "Logs", "icon": "terminal", "children": [
|
||||
@@ -375,6 +397,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 == "logs_live":
|
||||
return user.can_view_live_log
|
||||
if key == "logs_history":
|
||||
@@ -2494,6 +2518,304 @@ def _revert_network_config(token):
|
||||
_pending_network_revert.pop(token, None)
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# 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_REVERT_SECONDS = 45
|
||||
_pending_nginx_revert = {}
|
||||
|
||||
_NGINX_PROXY_LOCATIONS = """ location /ws/ {
|
||||
proxy_pass http://127.0.0.1:5000;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection $connection_upgrade;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_read_timeout 3600s;
|
||||
proxy_send_timeout 3600s;
|
||||
}
|
||||
|
||||
location / {
|
||||
proxy_pass http://127.0.0.1:5000;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
location /static/ {
|
||||
alias /srv/tesm/static/;
|
||||
expires 7d;
|
||||
}
|
||||
"""
|
||||
|
||||
|
||||
def _render_nginx_config(ssl_enabled, hsts_enabled):
|
||||
"""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."""
|
||||
header = 'map $http_upgrade $connection_upgrade {\n default upgrade;\n "" close;\n}\n\n'
|
||||
if not ssl_enabled:
|
||||
return header + "server {\n listen 80;\n server_name _;\n\n" + _NGINX_PROXY_LOCATIONS + "}\n"
|
||||
|
||||
hsts_line = ""
|
||||
if hsts_enabled:
|
||||
hsts_line = ' add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;\n\n'
|
||||
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"
|
||||
+ hsts_line
|
||||
+ _NGINX_PROXY_LOCATIONS
|
||||
+ "}\n"
|
||||
)
|
||||
|
||||
|
||||
def _apply_nginx_config(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
|
||||
einen Tippfehler/ein defektes Zertifikat, das sonst den kompletten
|
||||
Reverse-Proxy lahmlegen würde). Gibt (ok, fehlermeldung, alter_inhalt)
|
||||
zurück -- den alten Inhalt braucht der Aufrufer zusätzlich für das
|
||||
zeitgesteuerte Rückroll-Sicherheitsnetz, siehe NGINX_REVERT_SECONDS."""
|
||||
try:
|
||||
with open(NGINX_SITE_CONFIG_PATH, encoding="utf-8") as f:
|
||||
previous = f.read()
|
||||
except OSError:
|
||||
previous = None
|
||||
|
||||
new_content = _render_nginx_config(ssl_enabled, hsts_enabled)
|
||||
try:
|
||||
with open(NGINX_SITE_CONFIG_PATH, "w", encoding="utf-8") as f:
|
||||
f.write(new_content)
|
||||
except OSError as e:
|
||||
return False, f"Konfiguration konnte nicht geschrieben werden: {e}", previous
|
||||
|
||||
ok, out = _dhcp_run_privileged(["nginx", "-t"], timeout=10)
|
||||
if ok:
|
||||
ok, out = _dhcp_run_privileged(["systemctl", "reload", "nginx"], timeout=10)
|
||||
|
||||
if not ok:
|
||||
if previous is not None:
|
||||
try:
|
||||
with open(NGINX_SITE_CONFIG_PATH, "w", encoding="utf-8") as f:
|
||||
f.write(previous)
|
||||
subprocess.run(["systemctl", "reload", "nginx"], capture_output=True, timeout=10)
|
||||
except OSError:
|
||||
pass
|
||||
return False, out, previous
|
||||
|
||||
return True, "", previous
|
||||
|
||||
|
||||
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."""
|
||||
entry = _pending_nginx_revert.get(token)
|
||||
if not entry:
|
||||
return
|
||||
try:
|
||||
if entry["previous"] is not None:
|
||||
with open(NGINX_SITE_CONFIG_PATH, "w", encoding="utf-8") as f:
|
||||
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")
|
||||
except Exception as e:
|
||||
app.logger.error("nginx auto-revert failed: %s", e)
|
||||
finally:
|
||||
_pending_nginx_revert.pop(token, None)
|
||||
|
||||
|
||||
def _parse_cert(cert_bytes):
|
||||
"""Lädt ein PEM-Zertifikat und liefert sowohl das cryptography-Objekt
|
||||
(für den Schlüssel-Abgleich) als auch die für die UI relevanten Felder
|
||||
(Subject, Aussteller, Ablaufdatum)."""
|
||||
cert = x509.load_pem_x509_certificate(cert_bytes)
|
||||
try:
|
||||
not_after = cert.not_valid_after_utc.replace(tzinfo=None)
|
||||
except AttributeError:
|
||||
not_after = cert.not_valid_after
|
||||
return cert, {
|
||||
"subject": cert.subject.rfc4514_string(),
|
||||
"issuer": cert.issuer.rfc4514_string(),
|
||||
"not_after": not_after,
|
||||
}
|
||||
|
||||
|
||||
def _validate_cert_key_pair(cert_bytes, key_bytes):
|
||||
"""Prüft ein hochgeladenes Zertifikat+Key-Paar VOR dem Speichern --
|
||||
verhindert, dass ein kaputtes oder nicht zusammenpassendes Paar erst
|
||||
beim tatsächlichen SSL-Aktivieren auffällt (mit dem entsprechenden
|
||||
Ausfallrisiko für den Reverse-Proxy). Der Schlüssel-Abgleich
|
||||
funktioniert algorithmus-unabhängig (RSA/EC/...), da nur die
|
||||
öffentlichen Schlüssel als DER-Bytes verglichen werden, nicht
|
||||
algorithmus-spezifische Felder. Gibt (ok, fehlermeldung, info-dict)
|
||||
zurück."""
|
||||
try:
|
||||
cert, info = _parse_cert(cert_bytes)
|
||||
except ValueError as e:
|
||||
return False, f"Datei ist kein gültiges PEM-Zertifikat ({e}).", {}
|
||||
|
||||
if info["not_after"] < datetime.utcnow():
|
||||
return False, f"Zertifikat ist bereits abgelaufen (gültig bis {info['not_after']:%d.%m.%Y}).", info
|
||||
|
||||
try:
|
||||
private_key = serialization.load_pem_private_key(key_bytes, password=None)
|
||||
except (ValueError, TypeError) as e:
|
||||
return False, f"Datei ist kein gültiger, unverschlüsselter privater Schlüssel ({e}).", info
|
||||
|
||||
cert_pub = cert.public_key().public_bytes(serialization.Encoding.DER, serialization.PublicFormat.SubjectPublicKeyInfo)
|
||||
key_pub = private_key.public_key().public_bytes(serialization.Encoding.DER, serialization.PublicFormat.SubjectPublicKeyInfo)
|
||||
if cert_pub != key_pub:
|
||||
return False, "Zertifikat und privater Schlüssel passen nicht zusammen.", info
|
||||
|
||||
return True, "", info
|
||||
|
||||
|
||||
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)):
|
||||
return None
|
||||
try:
|
||||
with open(TESM_SSL_CERT_PATH, "rb") as f:
|
||||
_, info = _parse_cert(f.read())
|
||||
info["days_left"] = (info["not_after"] - datetime.utcnow()).days
|
||||
return info
|
||||
except (OSError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
@app.route("/settings/ssl", methods=["GET", "POST"])
|
||||
@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")
|
||||
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 "upload_cert" in request.form:
|
||||
cert_file = request.files.get("cert_file")
|
||||
key_file = request.files.get("key_file")
|
||||
if not cert_file or not cert_file.filename or not key_file or not key_file.filename:
|
||||
flash("Bitte sowohl Zertifikat (PEM) als auch privaten Schlüssel (PEM) auswählen.", "danger")
|
||||
else:
|
||||
cert_bytes = cert_file.read()
|
||||
key_bytes = key_file.read()
|
||||
ok, msg, info = _validate_cert_key_pair(cert_bytes, key_bytes)
|
||||
if not ok:
|
||||
flash(f"Zertifikat/Schlüssel nicht gespeichert: {msg}", "danger")
|
||||
else:
|
||||
try:
|
||||
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)
|
||||
log_action("settings.ssl_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.",
|
||||
"success",
|
||||
)
|
||||
except OSError as e:
|
||||
flash(f"Zertifikat konnte nicht gespeichert werden: {e}", "danger")
|
||||
|
||||
elif "apply_ssl" in request.form:
|
||||
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")
|
||||
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)
|
||||
if not ok:
|
||||
flash(f"nginx-Konfiguration konnte nicht angewendet werden, unverändert gelassen: {err}", "danger")
|
||||
else:
|
||||
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}")
|
||||
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,
|
||||
}
|
||||
timer.start()
|
||||
scheme = "https" if new_ssl else "http"
|
||||
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.",
|
||||
"success",
|
||||
)
|
||||
|
||||
elif "confirm_ssl" in request.form:
|
||||
token = request.form.get("confirm_ssl")
|
||||
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")
|
||||
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")
|
||||
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")
|
||||
|
||||
return redirect(url_for("settings_ssl"))
|
||||
|
||||
pending_token = next(iter(_pending_nginx_revert), None)
|
||||
return render_template(
|
||||
"settings_ssl.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,
|
||||
)
|
||||
|
||||
|
||||
@app.route("/settings", methods=["GET", "POST"])
|
||||
@login_required
|
||||
def settings():
|
||||
|
||||
@@ -20,6 +20,7 @@
|
||||
"terminal": '<rect x="2" y="4" width="20" height="16" rx="2"/><path d="M6 9l4 3-4 3M13 15h5"/>',
|
||||
"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"/>',
|
||||
"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"/>',
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
{% 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 %}
|
||||
|
||||
{% 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;">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>
|
||||
</div>
|
||||
|
||||
{% if cert_info %}
|
||||
<div class="detail-list" style="margin-bottom:18px;">
|
||||
<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">
|
||||
<span class="k">Gültig bis</span>
|
||||
<span class="v">
|
||||
{{ cert_info.not_after.strftime('%d.%m.%Y') }}
|
||||
{% if cert_info.days_left < 0 %}
|
||||
<span class="pill offline" style="margin-left:6px;">abgelaufen</span>
|
||||
{% elif cert_info.days_left <= 30 %}
|
||||
<span class="pill unknown" style="margin-left:6px;">noch {{ cert_info.days_left }} Tage</span>
|
||||
{% else %}
|
||||
<span class="pill online" style="margin-left:6px;">noch {{ cert_info.days_left }} Tage</span>
|
||||
{% endif %}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
{% else %}
|
||||
<p class="text-faint" style="font-size:12.5px; margin-bottom:18px;">Noch kein Zertifikat hinterlegt.</p>
|
||||
{% endif %}
|
||||
|
||||
{% if can_edit %}
|
||||
<form method="post" enctype="multipart/form-data" style="margin-bottom:10px;">
|
||||
<div class="field">
|
||||
<label for="cert_file">Zertifikat (PEM, .crt/.pem)</label>
|
||||
<input type="file" name="cert_file" id="cert_file" accept=".pem,.crt,.cer" required>
|
||||
</div>
|
||||
<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>
|
||||
<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>
|
||||
</form>
|
||||
{% elif cert_info %}
|
||||
<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>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
<script>
|
||||
document.addEventListener("DOMContentLoaded", () => {
|
||||
const sslCheck = document.getElementById("ssl_enabled_check");
|
||||
const hstsCheck = document.getElementById("hsts_enabled_check");
|
||||
if (sslCheck && hstsCheck) {
|
||||
sslCheck.addEventListener("change", () => {
|
||||
hstsCheck.disabled = !sslCheck.checked;
|
||||
if (!sslCheck.checked) hstsCheck.checked = false;
|
||||
});
|
||||
}
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
Reference in New Issue
Block a user