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:
2026-08-22 11:56:55 +02:00
co-authored by Claude Sonnet 5
parent 8253ab3f0e
commit f27dde466b
5 changed files with 503 additions and 40 deletions
+33 -6
View File
@@ -44,6 +44,21 @@ NC='\033[0m'
print_status() { echo -e "${GREEN}${NC} ${1} completed."; }
step() { echo -e "${RED}${NC} ${1}..." | tee -a /var/log/tesm-install.log; }
# Kopiert nur, wenn der Inhalt sich tatsächlich unterscheidet (oder das
# Ziel noch fehlt) -- bei einem Update sollen ausschließlich Dateien
# angefasst werden, die sich zwischen der laufenden und der neuen Version
# wirklich geändert haben (unverändert lässt "cp" Zeitstempel/Inode aber
# trotzdem hochzählen, was z.B. Monitoring/Backups unnötig triggert).
# Gibt 0 zurück, wenn tatsächlich kopiert wurde, 1 wenn übersprungen.
install_if_changed() {
local src="$1" dst="$2" mode="${3:-644}"
if [ -f "$dst" ] && cmp -s "$src" "$dst"; then
return 1
fi
sudo install -m "$mode" "$src" "$dst"
return 0
}
# "clear" schlägt ohne TERM/Pseudo-Terminal fehl (z.B. bei Automatisierung
# per SSH exec_command ohne pty, genau wie update.sh dieses Skript aufruft)
# -- mit "set -e" bricht das sonst die GESAMTE Installation sofort und ohne
@@ -150,10 +165,13 @@ fi
# ---- Custom-Skripte + systemd ----
step "Copying custom scripts and systemd units"
sudo mkdir -p /usr/local/bin/custom
sudo cp "$REPO_DIR/usr/local/bin/custom/poe.sh" /usr/local/bin/custom/poe.sh
sudo chmod +x /usr/local/bin/custom/poe.sh
sudo cp "$REPO_DIR"/etc/systemd/system/*.service /etc/systemd/system/
sudo cp "$REPO_DIR"/etc/systemd/system/*.timer /etc/systemd/system/
UNITS_CHANGED=0
install_if_changed "$REPO_DIR/usr/local/bin/custom/poe.sh" /usr/local/bin/custom/poe.sh 755 || true
for f in "$REPO_DIR"/etc/systemd/system/*.service "$REPO_DIR"/etc/systemd/system/*.timer; do
if install_if_changed "$f" "/etc/systemd/system/$(basename "$f")" 644; then
UNITS_CHANGED=1
fi
done
print_status "Scripts and units copied"
# ---- SSH-Login-Banner (MOTD) ----
@@ -162,8 +180,7 @@ print_status "Scripts and units copied"
# Die Standard-Skripte bleiben erhalten, werden aber deaktiviert (chmod -x,
# nicht gelöscht), damit ein Rückbau jederzeit per chmod +x möglich ist.
step "Setting up TESM SSH login banner"
sudo cp "$REPO_DIR/etc/update-motd.d/50-tesm" /etc/update-motd.d/50-tesm
sudo chmod 755 /etc/update-motd.d/50-tesm
install_if_changed "$REPO_DIR/etc/update-motd.d/50-tesm" /etc/update-motd.d/50-tesm 755 || true
sudo chmod -x /etc/update-motd.d/00-header /etc/update-motd.d/10-help-text \
/etc/update-motd.d/50-motd-news /etc/update-motd.d/60-unminimize \
/etc/update-motd.d/91-release-upgrade /etc/update-motd.d/92-unattended-upgrades \
@@ -171,8 +188,16 @@ sudo chmod -x /etc/update-motd.d/00-header /etc/update-motd.d/10-help-text \
print_status "SSH login banner configured"
# ---- nginx ----
# Nur bei einer Frischinstallation aus dem Repo-Template geschrieben --
# eine bereits vorhandene Konfiguration (egal ob unverändert oder von der
# App selbst z.B. um SSL/HSTS erweitert, siehe Systemeinstellungen -> SSL)
# wird NIE wieder überschrieben. Symlink/Default-Site-Entfernung und der
# Validierungs-Reload laufen dagegen immer -- rein idempotent, ändern an
# einer bestehenden Konfiguration selbst nichts.
step "Configuring nginx reverse proxy"
if [ ! -f /etc/nginx/sites-available/tesm ]; then
sudo cp "$REPO_DIR/etc/nginx/sites-available/tesm" /etc/nginx/sites-available/tesm
fi
sudo ln -sf /etc/nginx/sites-available/tesm /etc/nginx/sites-enabled/tesm
sudo rm -f /etc/nginx/sites-enabled/default
sudo nginx -t >>/var/log/tesm-install.log 2>&1 && sudo systemctl reload nginx
@@ -180,7 +205,9 @@ print_status "nginx configured"
# ---- Dienste aktivieren ----
step "Enabling services"
if [ "$UNITS_CHANGED" -eq 1 ]; then
sudo systemctl daemon-reload
fi
sudo systemctl enable --now tesm.service
sudo systemctl enable --now tesm-check.service tesm-check-restart.timer
print_status "Services enabled and started"
+323 -1
View File
@@ -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():
+1
View File
@@ -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"/>',
+135
View File
@@ -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 %}
+9 -31
View File
@@ -9,16 +9,15 @@
# automatischem Backup + Migrations-Sicherheitsnetz samt Rückroll-
# Möglichkeit -- siehe install.sh für Details).
#
# Dieses Skript selbst ist nur noch für das Ablösen ÄLTERER, TESM
# vorausgehender Installationsstufen auf diesem Host zuständig:
# 1. Das ursprüngliche, reine Bash-Setup aus dem separaten Repo
# https://gitea.int.eertmoed.net/WiS/Aruba-PoE (rpi-check.service +
# statische ips.list, keine Datenbank/Web-UI).
# 2. Die spätere Flask-App "PoE Manager" unter /srv/poe_manager
# (poe_web.service, Vorgänger von TESM in diesem Repo vor der
# Umbenennung).
# Beide werden NICHT gelöscht, sondern nur gestoppt/deaktiviert und
# (falls vorhanden) beiseite verschoben.
# Dieses Skript ist reiner Bootstrapper: Download + Entpacken + Aufruf von
# install.sh. Die frühere Sonderbehandlung zweier TESM vorausgehender
# Installationsstufen (rpi-check.service aus dem ursprünglichen reinen
# Bash-Setup, poe_web.service/poe_manager als Flask-Vorgänger von TESM)
# wurde entfernt -- diese Stufen sind auf allen aktuellen Installationen
# längst migriert, die dazugehörigen Unit-Dateien wurden dort manuell
# entfernt. Ein heute noch unmigriertes System wäre ohnehin so alt, dass
# ein manueller Blick vor dem Update angebracht ist statt automatischer
# stillschweigender Bereinigung.
#
# Auszuführen als root auf dem Zielsystem:
# sudo ./update.sh
@@ -38,7 +37,6 @@ GITEA_TOKEN="${TESM_GITEA_TOKEN:-}"
PACKAGE_NAME="tesm-${RELEASE_TAG}.tar.gz"
DOWNLOAD_URL="${GITEA_BASE}/${REPO_OWNER}/${REPO_NAME}/releases/download/${RELEASE_TAG}/${PACKAGE_NAME}"
TIMESTAMP="$(date +%Y%m%d-%H%M%S)"
WORK_DIR="/tmp/tesm-update-$(date +%s)"
PACKAGE_FILE="$WORK_DIR/${PACKAGE_NAME}"
@@ -61,10 +59,6 @@ echo "install.sh daraus -- das erkennt selbst, ob es sich um eine frische"
echo "Installation oder ein Update einer bestehenden handelt, und sichert"
echo "die Datenbank bei einer Schema-Änderung automatisch vorsorglich ab."
echo
echo "Ältere, TESM vorausgehende Installationsstufen (PoE Manager unter"
echo "/srv/poe_manager, das ursprüngliche reine Bash-Setup) werden dabei"
echo "erkannt, gestoppt/deaktiviert und beiseite verschoben (nicht gelöscht)."
echo
read -r -p "Fortfahren? [y/N] " confirm
if [[ "$confirm" != "y" && "$confirm" != "Y" ]]; then
@@ -86,22 +80,6 @@ mkdir -p "$WORK_DIR/pkg"
tar xzf "$PACKAGE_FILE" -C "$WORK_DIR/pkg" --strip-components=1
echo -e "${GREEN}${NC} Paket entpackt."
echo -e "${RED}${NC} Stoppe/deaktiviere ältere Installationsstufen (falls vorhanden)..."
# "|| true" macht nichts kaputt, falls einzelne Units gar nicht vorhanden
# sind. tesm.service selbst wird NICHT hier, sondern von install.sh
# gestoppt -- das entscheidet dort auch gleich, ob ein Backup nötig ist.
systemctl stop poe_web.service 2>/dev/null || true
systemctl disable poe_web.service 2>/dev/null || true
systemctl stop rpi-check.service rpi-check-restart.service rpi-check-restart.timer 2>/dev/null || true
systemctl disable rpi-check.service rpi-check-restart.service rpi-check-restart.timer 2>/dev/null || true
if [ -d /srv/poe_manager ]; then
dest="/srv/poe_manager-backup-${TIMESTAMP}"
echo -e "${RED}${NC} Sichere /srv/poe_manager nach ${dest}..."
mv /srv/poe_manager "$dest"
echo -e "${GREEN}${NC} Gesichert (nicht gelöscht)."
fi
echo -e "${GREEN}${NC} Ältere Installationsstufen bereinigt."
echo -e "${RED}${NC} Starte install.sh aus dem Paket..."
bash "$WORK_DIR/pkg/install.sh"