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:
@@ -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
|
||||
|
||||
|
||||
+1
-1
@@ -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
|
||||
|
||||
+264
-70
@@ -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,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user