Compare commits

..
6 Commits
Author SHA1 Message Date
alientimandClaude Sonnet 5 4de5828573 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>
2026-08-22 12:15:32 +02:00
alientimandClaude Sonnet 5 f27dde466b 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>
2026-08-22 11:56:55 +02:00
alientimandClaude Sonnet 5 8253ab3f0e Verlauf: aktuelle live.log als Eintrag, Auswahl per Zeitraum statt Dateiname; RAW-Darstellung
- _live_log_history_files() liefert jetzt zusätzlich generation 0 = die
  aktuelle, noch laufende live.log selbst (nicht nur die rotierten
  Kopien .1/.2/...) -- _resolve_log_history_file akzeptiert 'live.log'
  als gültige Auswahl.
- Jeder Eintrag bekommt ein range_label ('von' - 'bis'): 'bis' ist das
  eigene mtime (Zeitpunkt der Rotation) bzw. 'laufend' bei der aktuellen
  Datei, 'von' ist das mtime der naechstaelteren Generation -- rename()
  bei einer Rotation aendert mtime nicht, jede Generation behaelt also
  dauerhaft ihren eigenen Entstehungszeitpunkt. Live verifiziert: die
  Zeitraeume reihen sich lueckenlos aneinander (Ende von Generation N ==
  Anfang von Generation N-1).
- Dropdown und Toolbar zeigen jetzt den Zeitraum statt des rohen
  Dateinamens.
- logs_history.html: Farbcodierung (colorizeLine) entfernt, log-box durch
  ein <pre class="raw-log-content"> ersetzt -- reine RAW-Darstellung
  ohne Client-seitiges Span-pro-Zeile-Aufbauen, auf Wunsch zur weiteren
  Reduktion der Ladezeit im Verlauf (Live-Ansicht selbst bleibt farbig).
- VERSION 1.0.6 -> 1.0.7.

Live getestet auf Testsystem-Update-Restart: 4 Eintraege (aktuell + 3
rotierte Generationen), Zeitraeume lueckenlos verkettet, RAW-Endpoint für
'file=live.log' funktioniert, Path-Traversal-Versuch weiterhin mit 404
abgewiesen.

Zusaetzlich untersucht (kein Code-Fix, siehe Konversation): der Verdacht
eines install.sh-Bugs, der tesm-check.service nach einem Update gestoppt
liesse. journalctl zeigt auf beiden Boxen durchgehend 'active (running)'
ueber den fraglichen Zeitraum -- keine Bestaetigung eines Defekts in der
Dienstverwaltung gefunden. Wahrscheinlichste Erklaerung: ein manuell zu
Testzwecken erzwungener logrotate-Lauf (logrotate -f) direkt nach dem
Update hat live.log geleert, wodurch die Live-Log-Seite und die
Naechste-Pruefung-Anzeige fuer die Dauer eines Pruefintervalls leer
wirkten, bis der naechste Zyklus neuen Inhalt schrieb.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-22 11:28:15 +02:00
alientimandClaude Sonnet 5 befc76819e Logrotate: TESM_LOG_DIR world-writable verhinderte jede Rotation
Root Cause (live auf POETEST reproduziert): /var/log/tesm war 0o777
(noch aus einer Zeit vor dem Umstieg auf root-laufende systemd-Units).
logrotate verweigert seit den aktuellen Haertungen jede Rotation in
einem group-/world-writable Verzeichnis ("insecure permissions") komplett
-- live.log wuchs auf POETEST seit der letzten Neuinstallation (9 Tage)
unbegrenzt, /var/lib/logrotate/status hatte fuer tesm ueberhaupt keinen
Eintrag. Direkte Folge: die neue Verlauf-Seite blieb leer, weil nie
live.log.1/.2/... entstanden.

Fix:
- os.chmod(TESM_LOG_DIR) und os.chmod(TESM_CLIENT_UPDATE_LOG_DIR) in
  app.py: 0o777 -> 0o755 (tesm.service/tesm-check.service laufen beide
  als root, kein anderer Dienstbenutzer schreibt hierher -- Keas eigenes
  Log liegt separat unter /var/log/kea/). Laeuft bei jedem App-Start,
  heilt bestehende Installationen also automatisch beim naechsten
  Neustart/Update.
- install.sh: chmod 777 -> chmod 755 fuer /var/log/tesm bei einer
  Frischinstallation, veraltete/falsche Kommentar-Begruendung (angeblich
  auch fuer Kea noetig) korrigiert.
- _write_logrotate_config(): zusaetzlich 'su root root' in der
  generierten Konfiguration als Absicherung, falls das Verzeichnis aus
  irgendeinem Grund wieder zu weit geoeffnet wird.
- VERSION 1.0.5 -> 1.0.6.

Live verifiziert auf Testsystem-Update-Restart: logrotate -d zeigt nach
dem Fix keine 'insecure permissions'-Fehler mehr, ein erzwungener Lauf
(logrotate -f) erzeugt sauber live.log.1/live.log.2.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-22 11:11:41 +02:00
alientimandClaude Sonnet 5 99d4d3cd01 Live-Log: 300-Zeilen-Tail, RAW-Popup und Log-Verlauf (History) mit eigenem Recht
- /logs und /get_log liefern nur noch die letzten 300 Zeilen (_tail_lines,
  effizientes Rueckwaerts-Lesen ohne komplette Datei einzulesen) statt der
  kompletten live.log -- behebt Ladezeit-Probleme auf groesseren Umgebungen.
- Neuer Endpoint /logs/live/raw liefert das komplette Live-Log fuer ein
  Popup im Stil des bestehenden 'Ungespeicherte Aenderungen'-Modals
  (window.openRawLogModal in app.js, lazy nachgeladen erst bei Klick).
- Neue Unterseite 'Verlauf' (/logs/history, /logs/history/raw) fuer aeltere,
  von logrotate rotierte Kopien des Live-Logs (live.log.1, .2, ...),
  auswaehlbar per Dropdown, ebenfalls mit 300-Zeilen-Tail + RAW-Popup.
- Eigenes Recht logs_history.view (Kind von logs_group, direkt unter Live
  einsortiert) statt an logs_live.view gekoppelt -- Live-Status sehen und
  durch alte Logs blaettern sind bewusst getrennte Rechte.
- Dateiname-Validierung gegen Path-Traversal in _resolve_log_history_file
  (striktes Regex + Verzeichnis-Check + Existenzpruefung).
- VERSION 1.0.4 -> 1.0.5.

Live getestet auf Testsystem-Update-Restart (192.168.82.51): Tail-Anzeige,
RAW-Popup, Verlauf-Auswahl inkl. echter live.log.1, Permission-Matrix
(Gruppen-Seite) sowie Rechtetrennung (Nutzer mit nur logs_live.view sieht
/logs, aber weder Verlauf-Link noch Zugriff auf /logs/history bzw.
/logs/history/raw) und Path-Traversal-Abwehr allesamt verifiziert.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-22 10:53:56 +02:00
alientim c30ad41101 install.sh: Sicherheitsnetz gilt jetzt fuer JEDES Update, nicht nur Schema-Aenderungen
Live gefunden: der 'Schema unveraendert -> kein Backup'-Kurzschluss aus
v1.0.3 liess einen rein CODE-seitigen Fehler im neuen Paket (z.B. ein
kaputtes app.py -- mit dem DB-Schema hat das nichts zu tun) komplett
ungeschuetzt durch. Backup + Health-Check + ggf. Rollback laufen jetzt
bei JEDEM Update einer bestehenden Installation, unabhaengig von
SCHEMA_VERSION -- die bleibt nur noch fuer den Wortlaut der Log-Meldung
relevant (In-Place-Update vs. Migration).

Zusaetzlich beim Rollback selbst: 'mv' durch 'cp -a' beim Zurueckspielen
ersetzt, plus die fehlgeschlagene Installation vorher separat beiseite
gelegt (/srv/tesm-failed-update-<timestamp>) statt geloescht -- vorher
wurde das Backup beim Zurueckspielen selbst zum neuen /srv/tesm und
verschwand dadurch als eigenstaendige Sicherung (widersprach der
eigenen Zusage 'Backup bleibt erhalten').

Live auf der Testbox in allen drei Pfaden verifiziert:
- Erfolgreiches Update: Backup erstellt, Health-Check bestanden,
  Backup geloescht, Daten erhalten.
- Migration (kuenstlich abweichende SCHEMA_VERSION, echte Struktur
  unveraendert): Backup erstellt, Health-Check bestanden, Backup
  geloescht.
- Fehlgeschlagenes Update (absichtlich kaputte app.py): Backup
  erstellt, Health-Check schlaegt korrekt fehl, automatischer Rollback
  stellt den letzten funktionierenden Zustand wieder her (Dienst aktiv,
  Health-Check 200, Datenbank-Canary erhalten), Backup UND
  fehlgeschlagene Installation bleiben als zwei getrennte Verzeichnisse
  zur Fehlersuche erhalten.

VERSION: 1.0.4 (v1.0.3 hatte die oben beschriebene Luecke und wurde
bereits ausgerollt, daher hier ein neues Release statt eines erneuten
Tags auf v1.0.3).
2026-08-22 10:30:33 +02:00
11 changed files with 1231 additions and 100 deletions
+5 -1
View File
@@ -14,8 +14,12 @@ WorkingDirectory=/srv/tesm
# nach 30s auch bei einer offenen, aber gerade inaktiven SSH-Terminal- # nach 30s auch bei einer offenen, aber gerade inaktiven SSH-Terminal-
# WebSocket-Verbindung und würde den Worker (und damit JEDE Verbindung, # WebSocket-Verbindung und würde den Worker (und damit JEDE Verbindung,
# da nur ein Prozess läuft) killen. # 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 \ 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 Restart=always
RestartSec=5 RestartSec=5
+102 -47
View File
@@ -8,23 +8,30 @@
# Erkennt selbstständig, ob unter /srv/tesm bereits eine Installation # Erkennt selbstständig, ob unter /srv/tesm bereits eine Installation
# existiert (an sqlite.db), und verhält sich entsprechend: # existiert (an sqlite.db), und verhält sich entsprechend:
# #
# - Keine vorhandene Installation -> normale Frischinstallation. # - Keine vorhandene Installation -> normale Frischinstallation, kein
# - Vorhanden, SCHEMA_VERSION unverändert -> einfaches In-Place-Update # Backup nötig (nichts, worauf zurückgerollt werden könnte).
# (Code/Templates/Abhängigkeiten aktualisieren; sqlite.db bleibt # - Vorhanden (egal ob SCHEMA_VERSION gleich geblieben oder sich
# unangetastet -- rsync schließt sie ohnehin aus). Kein Backup nötig, # geändert hat) -> Update MIT Sicherheitsnetz: die komplette bisherige
# da sich am Datenbank-Schema nichts ändert. # Installation (Code, venv, Datenbank, Schlüssel) wird vorher 1:1 nach
# - Vorhanden, SCHEMA_VERSION unbekannt oder abweichend -> Update MIT # /srv/tesm-backup-pre-update-<timestamp> kopiert. Eine eigentliche
# Sicherheitsnetz: die komplette bisherige Installation (Code, venv, # Schema-Migration übernimmt danach die App selbst beim Start (siehe
# Datenbank, Schlüssel) wird vorher 1:1 nach /srv/tesm-backup-pre- # _ensure_schema() in app.py). Kommt der Dienst anschließend
# migration-<timestamp> kopiert. Die eigentliche Schema-Migration # nachweislich NICHT gesund hoch (HTTP-Check auf /login), wird
# übernimmt danach die App selbst beim Start (siehe _ensure_schema() # automatisch die komplette Sicherung zurückgespielt (alter Code +
# in app.py). Kommt der Dienst anschließend nachweislich NICHT gesund # alte Datenbank zusammen -- ein reines DB-Rollback allein würde
# hoch (HTTP-Check auf /login), wird automatisch die komplette # denselben Fehler beim nächsten Start mit dem neuen Code sofort
# Sicherung zurückgespielt (alter Code + alte Datenbank zusammen -- # wiederholen) und das Backup bleibt zur manuellen Prüfung erhalten.
# ein reines DB-Rollback allein würde denselben Migrationsfehler beim # Kommt er gesund hoch, gilt das Update als erfolgreich und das
# nächsten Start mit dem neuen Code sofort wiederholen) und das # Backup wird gelöscht.
# Backup bleibt zur manuellen Prüfung erhalten. Kommt er gesund hoch, #
# gilt die Migration als erfolgreich und das Backup wird gelöscht. # Das Sicherheitsnetz greift bewusst bei JEDEM Update einer bestehenden
# Installation, nicht nur bei einer erkannten Schema-Änderung -- ein
# rein code-seitiger Fehler im neuen Paket hat mit dem Datenbank-Schema
# nichts zu tun, hätte den Dienst bei einem "Schema unverändert -> kein
# Backup"-Kurzschluss aber trotzdem ungeschützt lahmgelegt (live
# reproduziert). SCHEMA_VERSION bestimmt nur noch den Wortlaut der
# Log-Meldung (In-Place-Update vs. Migration), nicht mehr, OB
# abgesichert wird.
# ============================================================================ # ============================================================================
set -e set -e
@@ -37,6 +44,21 @@ NC='\033[0m'
print_status() { echo -e "${GREEN}${NC} ${1} completed."; } print_status() { echo -e "${GREEN}${NC} ${1} completed."; }
step() { echo -e "${RED}${NC} ${1}..." | tee -a /var/log/tesm-install.log; } 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 # "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) # 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 # -- mit "set -e" bricht das sonst die GESAMTE Installation sofort und ohne
@@ -46,6 +68,15 @@ step() { echo -e "${RED}→${NC} ${1}..." | tee -a /var/log/tesm-install
clear 2>/dev/null || true clear 2>/dev/null || true
# ---- Bestehende Installation erkennen ---- # ---- Bestehende Installation erkennen ----
# WICHTIG: das Sicherheitsnetz (Backup + Health-Check + ggf. Rückroll)
# greift bei JEDEM Update einer bestehenden Installation, nicht nur bei
# einer erkannten Schema-Änderung -- live so gefunden: ein rein
# code-seitiger Fehler im neuen Paket (z.B. ein kaputtes app.py, mit dem
# Schema selbst hat das nichts zu tun) hätte bei einem reinen "Schema
# unverändert -> kein Backup"-Kurzschluss ungeschützt den Dienst
# lahmgelegt, ganz ohne automatische Wiederherstellung. Die
# SCHEMA_VERSION-Auswertung dient jetzt nur noch der Log-Meldung (WARUM
# aktualisiert wird), nicht mehr der Entscheidung OB abgesichert wird.
EXISTING_INSTALL=0 EXISTING_INSTALL=0
[ -f /srv/tesm/sqlite.db ] && EXISTING_INSTALL=1 [ -f /srv/tesm/sqlite.db ] && EXISTING_INSTALL=1
@@ -63,7 +94,7 @@ if [ "$EXISTING_INSTALL" -eq 1 ]; then
print_status "Service stopped" print_status "Service stopped"
if [ -n "$OLD_SCHEMA_VERSION" ] && [ -n "$NEW_SCHEMA_VERSION" ] && [ "$OLD_SCHEMA_VERSION" == "$NEW_SCHEMA_VERSION" ]; then if [ -n "$OLD_SCHEMA_VERSION" ] && [ -n "$NEW_SCHEMA_VERSION" ] && [ "$OLD_SCHEMA_VERSION" == "$NEW_SCHEMA_VERSION" ]; then
echo -e "${GREEN}Bestehende Installation erkannt, Datenbank-Schema unverändert (Version ${OLD_SCHEMA_VERSION}):${NC} einfaches In-Place-Update, kein Backup nötig." echo -e "${GREEN}Bestehende Installation erkannt, Datenbank-Schema unverändert (Version ${OLD_SCHEMA_VERSION}):${NC} In-Place-Update."
else else
if [ -n "$OLD_SCHEMA_VERSION" ] && [ -n "$NEW_SCHEMA_VERSION" ]; then if [ -n "$OLD_SCHEMA_VERSION" ] && [ -n "$NEW_SCHEMA_VERSION" ]; then
reason="Datenbank-Schema hat sich geändert (${OLD_SCHEMA_VERSION} -> ${NEW_SCHEMA_VERSION})" reason="Datenbank-Schema hat sich geändert (${OLD_SCHEMA_VERSION} -> ${NEW_SCHEMA_VERSION})"
@@ -71,28 +102,33 @@ if [ "$EXISTING_INSTALL" -eq 1 ]; then
reason="Schema-Version der bestehenden Installation oder dieses Pakets unbekannt" reason="Schema-Version der bestehenden Installation oder dieses Pakets unbekannt"
fi fi
echo -e "${YELLOW}Bestehende Installation erkannt, ${reason}:${NC}" echo -e "${YELLOW}Bestehende Installation erkannt, ${reason}:${NC}"
echo "Update mit automatischer Migration und Sicherheitsnetz (Rückroll-Backup)." echo "Update mit automatischer Migration."
fi
NEEDS_MIGRATION_GUARD=1 NEEDS_MIGRATION_GUARD=1
BACKUP_DIR="/srv/tesm-backup-pre-migration-$(date +%Y%m%d-%H%M%S)" BACKUP_DIR="/srv/tesm-backup-pre-update-$(date +%Y%m%d-%H%M%S)"
step "Backing up current installation to $BACKUP_DIR before migration" step "Backing up current installation to $BACKUP_DIR before update"
cp -a /srv/tesm "$BACKUP_DIR" cp -a /srv/tesm "$BACKUP_DIR"
print_status "Backup created" print_status "Backup created"
fi
fi fi
# ---- Pakete ---- # ---- Pakete ----
step "Installing system packages" step "Installing system packages"
sudo apt-get update >>/var/log/tesm-install.log 2>&1 && print_status "apt update" 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 ---- # ---- Log-Verzeichnis ----
# Weltweit beschreibbar, da sowohl root (tesm.service/poe.sh) als auch # NICHT weltweit beschreibbar (0755 reicht) -- sowohl tesm.service als
# der i.d.R. unprivilegierte kea-dhcp4-server-Dienstbenutzer (_kea) hier # auch tesm-check.service (poe.sh) laufen als root, kein anderer
# ihre jeweils eigene Log-Datei anlegen/schreiben müssen. Die eigentliche # Dienstbenutzer schreibt hierher (Keas eigenes Log liegt separat unter
# logrotate-Konfiguration (/etc/logrotate.d/tesm) schreibt die App # TESM_KEA_LOG_PATH, i.d.R. /var/log/kea/). Ein group-/world-writable
# selbst beim Start bzw. beim Speichern unter Systemeinstellungen → Logs. # Verzeichnis lässt logrotate die Rotation aus Sicherheitsgründen
# komplett verweigern ("insecure permissions") -- live reproduziert: bei
# 0777 rotierte live.log über Wochen hinweg gar nicht mehr. Die
# eigentliche logrotate-Konfiguration (/etc/logrotate.d/tesm) schreibt
# die App selbst beim Start bzw. beim Speichern unter
# Systemeinstellungen → Logs.
sudo mkdir -p /var/log/tesm sudo mkdir -p /var/log/tesm
sudo chmod 777 /var/log/tesm sudo chmod 755 /var/log/tesm
# ---- App-Verzeichnis ---- # ---- App-Verzeichnis ----
step "Deploying application to /srv/tesm" step "Deploying application to /srv/tesm"
@@ -129,10 +165,13 @@ fi
# ---- Custom-Skripte + systemd ---- # ---- Custom-Skripte + systemd ----
step "Copying custom scripts and systemd units" step "Copying custom scripts and systemd units"
sudo mkdir -p /usr/local/bin/custom sudo mkdir -p /usr/local/bin/custom
sudo cp "$REPO_DIR/usr/local/bin/custom/poe.sh" /usr/local/bin/custom/poe.sh UNITS_CHANGED=0
sudo chmod +x /usr/local/bin/custom/poe.sh install_if_changed "$REPO_DIR/usr/local/bin/custom/poe.sh" /usr/local/bin/custom/poe.sh 755 || true
sudo cp "$REPO_DIR"/etc/systemd/system/*.service /etc/systemd/system/ for f in "$REPO_DIR"/etc/systemd/system/*.service "$REPO_DIR"/etc/systemd/system/*.timer; do
sudo cp "$REPO_DIR"/etc/systemd/system/*.timer /etc/systemd/system/ if install_if_changed "$f" "/etc/systemd/system/$(basename "$f")" 644; then
UNITS_CHANGED=1
fi
done
print_status "Scripts and units copied" print_status "Scripts and units copied"
# ---- SSH-Login-Banner (MOTD) ---- # ---- SSH-Login-Banner (MOTD) ----
@@ -141,8 +180,7 @@ print_status "Scripts and units copied"
# Die Standard-Skripte bleiben erhalten, werden aber deaktiviert (chmod -x, # Die Standard-Skripte bleiben erhalten, werden aber deaktiviert (chmod -x,
# nicht gelöscht), damit ein Rückbau jederzeit per chmod +x möglich ist. # nicht gelöscht), damit ein Rückbau jederzeit per chmod +x möglich ist.
step "Setting up TESM SSH login banner" step "Setting up TESM SSH login banner"
sudo cp "$REPO_DIR/etc/update-motd.d/50-tesm" /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 755 /etc/update-motd.d/50-tesm
sudo chmod -x /etc/update-motd.d/00-header /etc/update-motd.d/10-help-text \ 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/50-motd-news /etc/update-motd.d/60-unminimize \
/etc/update-motd.d/91-release-upgrade /etc/update-motd.d/92-unattended-upgrades \ /etc/update-motd.d/91-release-upgrade /etc/update-motd.d/92-unattended-upgrades \
@@ -150,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" print_status "SSH login banner configured"
# ---- nginx ---- # ---- 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" step "Configuring nginx reverse proxy"
sudo cp "$REPO_DIR/etc/nginx/sites-available/tesm" /etc/nginx/sites-available/tesm 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 ln -sf /etc/nginx/sites-available/tesm /etc/nginx/sites-enabled/tesm
sudo rm -f /etc/nginx/sites-enabled/default sudo rm -f /etc/nginx/sites-enabled/default
sudo nginx -t >>/var/log/tesm-install.log 2>&1 && sudo systemctl reload nginx sudo nginx -t >>/var/log/tesm-install.log 2>&1 && sudo systemctl reload nginx
@@ -159,16 +205,17 @@ print_status "nginx configured"
# ---- Dienste aktivieren ---- # ---- Dienste aktivieren ----
step "Enabling services" step "Enabling services"
sudo systemctl daemon-reload if [ "$UNITS_CHANGED" -eq 1 ]; then
sudo systemctl daemon-reload
fi
sudo systemctl enable --now tesm.service sudo systemctl enable --now tesm.service
sudo systemctl enable --now tesm-check.service tesm-check-restart.timer sudo systemctl enable --now tesm-check.service tesm-check-restart.timer
print_status "Services enabled and started" print_status "Services enabled and started"
# ---- Migrations-Sicherheitsnetz prüfen ---- # ---- Sicherheitsnetz prüfen ----
# Nur relevant, wenn oben tatsächlich eine riskante Migration (abweichende/ # Läuft bei JEDEM Update einer bestehenden Installation (NEEDS_MIGRATION_
# unbekannte SCHEMA_VERSION) erkannt wurde -- beim einfachen In-Place-Update # GUARD=1, siehe oben) -- nur bei einer Frischinstallation ist BACKUP_DIR
# oder einer Frischinstallation ist BACKUP_DIR leer und dieser Block wird # leer und dieser Block wird komplett übersprungen.
# komplett übersprungen.
if [ "$NEEDS_MIGRATION_GUARD" -eq 1 ]; then if [ "$NEEDS_MIGRATION_GUARD" -eq 1 ]; then
step "Verifying migrated installation" step "Verifying migrated installation"
HEALTHY=0 HEALTHY=0
@@ -182,15 +229,23 @@ if [ "$NEEDS_MIGRATION_GUARD" -eq 1 ]; then
if [ "$HEALTHY" -eq 1 ]; then if [ "$HEALTHY" -eq 1 ]; then
print_status "Migration verified healthy" print_status "Migration verified healthy"
rm -rf "$BACKUP_DIR" rm -rf "$BACKUP_DIR"
echo -e "${GREEN}${NC} Migration erfolgreich -- Backup wieder entfernt." echo -e "${GREEN}${NC} Update erfolgreich -- Backup wieder entfernt."
else else
echo -e "${RED}Migration fehlgeschlagen -- Dienst antwortet nicht gesund. Rolle zurück...${NC}" echo -e "${RED}Update fehlgeschlagen -- Dienst antwortet nicht gesund. Rolle zurück...${NC}"
sudo systemctl stop tesm.service 2>/dev/null || true sudo systemctl stop tesm.service 2>/dev/null || true
sudo rm -rf /srv/tesm # Fehlgeschlagene Installation NICHT einfach löschen, sondern separat
sudo mv "$BACKUP_DIR" /srv/tesm # beiseite legen (zur Fehlersuche) -- und das Backup per "cp" statt
# "mv" zurückspielen, damit BEIDES danach getrennt erhalten bleibt
# (sonst würde das Backup beim Zurückspielen selbst zum neuen
# /srv/tesm werden und als eigenständige Sicherung verschwinden).
FAILED_DIR="/srv/tesm-failed-update-$(date +%Y%m%d-%H%M%S)"
sudo mv /srv/tesm "$FAILED_DIR"
sudo cp -a "$BACKUP_DIR" /srv/tesm
sudo systemctl start tesm.service sudo systemctl start tesm.service
echo -e "${YELLOW}Zurückgerollt auf den Stand vor dem Update (alter Code + alte Datenbank zusammen).${NC}" echo -e "${YELLOW}Zurückgerollt auf den Stand vor dem Update (alter Code + alte Datenbank zusammen).${NC}"
echo "Bitte /var/log/tesm/app.log prüfen oder Export/Import nutzen, bevor erneut aktualisiert wird." echo "Backup bleibt erhalten unter: ${BACKUP_DIR}"
echo "Die fehlgeschlagene Installation liegt zur Fehlersuche unter: ${FAILED_DIR}"
echo "Bitte /var/log/tesm/app.log dort prüfen, bevor erneut aktualisiert wird."
fi fi
fi fi
+1 -1
View File
@@ -1 +1 @@
1.0.3 1.0.7
+774 -11
View File
@@ -16,7 +16,8 @@ from simple_websocket.errors import ConnectionClosed
from werkzeug.utils import secure_filename from werkzeug.utils import secure_filename
from cryptography.fernet import Fernet from cryptography.fernet import Fernet
from cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2HMAC 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 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 base64, csv, ipaddress, logging, shlex, shutil, socket, sqlite3, glob, json, os, re, secrets, stat, subprocess, threading, time, traceback
import paramiko import paramiko
@@ -95,12 +96,63 @@ TESM_KEA_LOG_PATH = os.environ.get("TESM_KEA_LOG_PATH", "/var/log/kea/kea-dhcp4.
LOGROTATE_CONFIG_PATH = os.environ.get("TESM_LOGROTATE_CONFIG", "/etc/logrotate.d/tesm") LOGROTATE_CONFIG_PATH = os.environ.get("TESM_LOGROTATE_CONFIG", "/etc/logrotate.d/tesm")
try: try:
os.makedirs(TESM_LOG_DIR, exist_ok=True) os.makedirs(TESM_LOG_DIR, exist_ok=True)
os.chmod(TESM_LOG_DIR, 0o777) # War früher 0o777, als noch nicht jeder schreibende Prozess (tesm.service,
# tesm-check.service) als root lief. Ein world-writable Verzeichnis lässt
# logrotate die Rotation aber aus Sicherheitsgründen komplett verweigern
# ("insecure permissions") -- live reproduziert: ohne Rotation blieb
# live.log unbegrenzt wachsen, siehe auch "su root root" in
# _write_logrotate_config als zusätzliche Absicherung.
os.chmod(TESM_LOG_DIR, 0o755)
except OSError: except OSError:
pass pass
SWITCH_DEFAULT_SSH_PORT = 22 SWITCH_DEFAULT_SSH_PORT = 22
ALLOWED_AVATAR_EXT = {"png", "jpg", "jpeg", "gif", "webp"} 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")
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
# 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)
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 = [ CREDENTIAL_CATEGORIES = [
("switch", "Switch"), ("switch", "Switch"),
("linux", "Linux-Client"), ("linux", "Linux-Client"),
@@ -209,6 +261,7 @@ PERMISSIONS = {
"view_key": "logs_group.view", "view_key": "logs_group.view",
"children": { "children": {
"logs_live": {"label": "Live", "rows": {"view": "logs_live.view"}}, "logs_live": {"label": "Live", "rows": {"view": "logs_live.view"}},
"logs_history": {"label": "Verlauf", "rows": {"view": "logs_history.view"}},
"logs_activity": {"label": "Änderungen", "rows": {"view": "logs_activity.view"}}, "logs_activity": {"label": "Änderungen", "rows": {"view": "logs_activity.view"}},
"logs_kea": {"label": "Kea-DHCP", "rows": {"view": "logs_kea.view"}}, "logs_kea": {"label": "Kea-DHCP", "rows": {"view": "logs_kea.view"}},
}, },
@@ -253,6 +306,10 @@ PERMISSIONS = {
"label": "LDAP", "label": "LDAP",
"rows": {"view": "settings_ldap.view", "edit": "settings_ldap.edit"}, "rows": {"view": "settings_ldap.view", "edit": "settings_ldap.edit"},
}, },
"settings_nginx": {
"label": "NGINX",
"rows": {"view": "settings_nginx.view", "edit": "settings_nginx.edit"},
},
"papierkorb": { "papierkorb": {
"label": "Papierkorb", "label": "Papierkorb",
"rows": {"view": "papierkorb.view", "edit": "papierkorb.edit"}, "rows": {"view": "papierkorb.view", "edit": "papierkorb.edit"},
@@ -278,6 +335,7 @@ PERMISSION_LABELS = {
"papierkorb.edit": "Papierkorb bearbeiten (beliebige Einträge wiederherstellen oder endgültig löschen, unabhängig vom sonstigen Bearbeiten-Recht der jeweiligen Ressource)", "papierkorb.edit": "Papierkorb bearbeiten (beliebige Einträge wiederherstellen oder endgültig löschen, unabhängig vom sonstigen Bearbeiten-Recht der jeweiligen Ressource)",
"logs_live.view": "Live-Log lesen", "logs_activity.view": "Änderungslog lesen", "logs_live.view": "Live-Log lesen", "logs_activity.view": "Änderungslog lesen",
"logs_kea.view": "Kea-DHCP-Log lesen", "logs_kea.view": "Kea-DHCP-Log lesen",
"logs_history.view": "Log-Verlauf lesen (ältere, durch logrotate rotierte Kopien des Live-Logs)",
"users.view": "Benutzer lesen", "users.create": "Benutzer anlegen", "users.view": "Benutzer lesen", "users.create": "Benutzer anlegen",
"users.edit": "Benutzer ändern (inkl. Löschen)", "users.edit": "Benutzer ändern (inkl. Löschen)",
"groups.view": "Gruppen lesen", "groups.create": "Gruppen anlegen", "groups.view": "Gruppen lesen", "groups.create": "Gruppen anlegen",
@@ -290,6 +348,8 @@ PERMISSION_LABELS = {
"settings_dhcp.edit": "DHCP-Konfiguration speichern und Reservierungsdatei schreiben", "settings_dhcp.edit": "DHCP-Konfiguration speichern und Reservierungsdatei schreiben",
"settings_ldap.view": "LDAP/AD-Konfiguration lesen", "settings_ldap.view": "LDAP/AD-Konfiguration lesen",
"settings_ldap.edit": "LDAP/AD-Konfiguration speichern (Bind-Konto, Gruppenzuordnungen)", "settings_ldap.edit": "LDAP/AD-Konfiguration speichern (Bind-Konto, Gruppenzuordnungen)",
"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 = [] ALL_PERMISSION_KEYS = []
@@ -329,10 +389,12 @@ NAV_ITEMS = [
{"key": "settings_ldap", "label": "LDAP", "icon": "users", "endpoint": "settings_ldap"}, {"key": "settings_ldap", "label": "LDAP", "icon": "users", "endpoint": "settings_ldap"},
{"key": "settings_dhcp", "label": "DHCP", "icon": "network", "endpoint": "settings_dhcp"}, {"key": "settings_dhcp", "label": "DHCP", "icon": "network", "endpoint": "settings_dhcp"},
{"key": "settings_importexport", "label": "Im-/Export", "icon": "transfer", "endpoint": "settings_import_export"}, {"key": "settings_importexport", "label": "Im-/Export", "icon": "transfer", "endpoint": "settings_import_export"},
{"key": "settings_nginx", "label": "NGINX", "icon": "server", "endpoint": "settings_nginx"},
{"key": "papierkorb", "label": "Papierkorb", "icon": "trash", "endpoint": "papierkorb"}, {"key": "papierkorb", "label": "Papierkorb", "icon": "trash", "endpoint": "papierkorb"},
]}, ]},
{"key": "logs_group", "label": "Logs", "icon": "terminal", "children": [ {"key": "logs_group", "label": "Logs", "icon": "terminal", "children": [
{"key": "logs_live", "label": "Live", "icon": "terminal", "endpoint": "logs"}, {"key": "logs_live", "label": "Live", "icon": "terminal", "endpoint": "logs"},
{"key": "logs_history", "label": "Verlauf", "icon": "clock", "endpoint": "logs_history"},
{"key": "logs_activity", "label": "Änderungen", "icon": "history", "endpoint": "activity_log"}, {"key": "logs_activity", "label": "Änderungen", "icon": "history", "endpoint": "activity_log"},
{"key": "logs_kea", "label": "Kea-DHCP", "icon": "network", "endpoint": "kea_log"}, {"key": "logs_kea", "label": "Kea-DHCP", "icon": "network", "endpoint": "kea_log"},
]}, ]},
@@ -366,8 +428,12 @@ def _nav_key_visible(key, user):
return user.can_view_settings_dhcp return user.can_view_settings_dhcp
if key == "settings_ldap": if key == "settings_ldap":
return user.can_view_settings_ldap return user.can_view_settings_ldap
if key == "settings_nginx":
return user.has_permission("settings_nginx.view")
if key == "logs_live": if key == "logs_live":
return user.can_view_live_log return user.can_view_live_log
if key == "logs_history":
return user.can_view_log_history
if key == "logs_activity": if key == "logs_activity":
return user.can_view_activity_log return user.can_view_activity_log
if key == "logs_kea": if key == "logs_kea":
@@ -552,6 +618,14 @@ class User(UserMixin):
def can_view_live_log(self): def can_view_live_log(self):
return self.has_permission("logs_live.view") return self.has_permission("logs_live.view")
@property
def can_view_log_history(self):
"""Ältere, durch logrotate rotierte Kopien des Live-Logs (Seite
Verlauf) -- bewusst eigenes Recht statt an logs_live.view gekoppelt,
damit z.B. ein Nutzer den aktuellen Live-Status sehen darf, ohne
automatisch auch durch ältere Logstände blättern zu können."""
return self.has_permission("logs_history.view")
@property @property
def can_view_activity_log(self): def can_view_activity_log(self):
return self.has_permission("logs_activity.view") return self.has_permission("logs_activity.view")
@@ -1170,7 +1244,12 @@ def _write_logrotate_config():
Log-Reopen-Signal (SIGHUP o.ä.) implementiert copytruncate Log-Reopen-Signal (SIGHUP o.ä.) implementiert copytruncate
funktioniert ohne jede Kooperation des schreibenden Prozesses (Kea funktioniert ohne jede Kooperation des schreibenden Prozesses (Kea
eingeschlossen: logrotate läuft als root, unterliegt also nicht Keas eingeschlossen: logrotate läuft als root, unterliegt also nicht Keas
eigenem AppArmor-Profil).""" eigenem AppArmor-Profil). "su root root" ist eine explizite Absicherung
dagegen, dass logrotate die Rotation wegen "insecure permissions"
verweigert, sobald TESM_LOG_DIR aus irgendeinem Grund wieder
gruppen-/world-writable wird (live reproduziert: bei 0o777 rotierte
logrotate live.log gar nicht mehr, siehe auch os.chmod(TESM_LOG_DIR)
weiter oben, das den eigentlichen Regelfall -- 0o755 -- durchsetzt)."""
interval = get_setting("log_rotation_interval", LOG_ROTATION_DEFAULT_INTERVAL) interval = get_setting("log_rotation_interval", LOG_ROTATION_DEFAULT_INTERVAL)
if interval not in LOG_ROTATION_INTERVALS: if interval not in LOG_ROTATION_INTERVALS:
interval = LOG_ROTATION_DEFAULT_INTERVAL interval = LOG_ROTATION_DEFAULT_INTERVAL
@@ -1188,6 +1267,7 @@ def _write_logrotate_config():
f" missingok\n" f" missingok\n"
f" notifempty\n" f" notifempty\n"
f" copytruncate\n" f" copytruncate\n"
f" su root root\n"
f"}}\n" f"}}\n"
) )
try: try:
@@ -1828,6 +1908,137 @@ def _latest_log_file():
return TESM_LIVE_LOG_PATH if os.path.exists(TESM_LIVE_LOG_PATH) else None return TESM_LIVE_LOG_PATH if os.path.exists(TESM_LIVE_LOG_PATH) else None
LIVE_LOG_TAIL_LINES = 300
def _tail_lines(path, n=LIVE_LOG_TAIL_LINES, chunk_size=65536):
"""Liest effizient nur die letzten n Zeilen einer Datei, ohne sie
komplett einzulesen -- sucht dazu vom Dateiende rückwärts in Blöcken,
bis n Zeilenumbrüche gefunden wurden oder der Dateianfang erreicht ist.
Auf größeren Umgebungen (viele Geräte, kurzes Prüfintervall) kann
live.log zwischen zwei logrotate-Läufen mehrere MB groß werden; ein
komplettes Einlesen bei jedem Seitenaufruf/Live-Poll hat dort spürbare
Ladezeiten verursacht -- das war der eigentliche Auslöser dieser
Funktion."""
with open(path, "rb") as f:
f.seek(0, os.SEEK_END)
remaining = f.tell()
data = b""
while remaining > 0 and data.count(b"\n") <= n:
read_size = min(chunk_size, remaining)
remaining -= read_size
f.seek(remaining)
data = f.read(read_size) + data
text = data.decode("utf-8", errors="replace")
lines = text.split("\n")
return "\n".join(lines[-n:]) if len(lines) > n else text
def _count_lines(path):
"""Schnelle Zeilenzählung über rohe Byte-Chunks (nur Zählen von \\n,
kein Aufbau einer Python-Zeilenliste) -- für die "letzte n von insgesamt
X Zeilen"-Anzeige, ohne den Performance-Vorteil von _tail_lines wieder
durch ein volles Einlesen zunichte zu machen."""
try:
count = 0
with open(path, "rb") as f:
for chunk in iter(lambda: f.read(1024 * 1024), b""):
count += chunk.count(b"\n")
return count
except OSError:
return None
def _format_log_size(num_bytes):
size = float(num_bytes)
for unit in ("B", "KB", "MB", "GB"):
if size < 1024 or unit == "GB":
return f"{size:.0f} {unit}" if unit == "B" else f"{size:.1f} {unit}"
size /= 1024
return f"{size:.1f} GB"
_LOG_HISTORY_FILE_RE = re.compile(r"^live\.log\.([1-9][0-9]*)$")
_LOG_HISTORY_DATE_FMT = "%d.%m.%Y %H:%M"
def _live_log_history_files():
"""Alle über die Verlauf-Seite auswählbaren Stände des Live-Logs --
generation 0 ist die aktuelle, noch laufende live.log selbst, 1 die
zuletzt rotierte Kopie, 2 der Lauf davor usw. (copytruncate ohne
compress, siehe _write_logrotate_config, daher reine Textdateien ohne
.gz), sortiert von neu (0) nach alt.
Jede Kopie deckt den Zeitraum zwischen ZWEI Rotationen ab: ihr eigenes
mtime ist der Zeitpunkt, an dem ihr Inhalt eingefroren wurde (Ende des
Zeitraums), das mtime der NÄCHSTälteren Generation der Beginn -- ein
reines rename() bei jeder weiteren Rotation ändert mtime nicht, jede
Generation behält also dauerhaft den Zeitpunkt ihrer eigenen
Entstehung. Für die aktuelle live.log gibt es kein eigenes "Ende",
dort wird stattdessen "laufend" angezeigt."""
results = []
try:
if os.path.isfile(TESM_LIVE_LOG_PATH):
mtime = os.path.getmtime(TESM_LIVE_LOG_PATH)
size = os.path.getsize(TESM_LIVE_LOG_PATH)
results.append({
"filename": "live.log", "generation": 0,
"mtime": mtime, "size_str": _format_log_size(size),
})
except OSError:
pass
try:
entries = os.listdir(TESM_LOG_DIR)
except OSError:
entries = []
for entry in entries:
m = _LOG_HISTORY_FILE_RE.match(entry)
if not m:
continue
full_path = os.path.join(TESM_LOG_DIR, entry)
if not os.path.isfile(full_path):
continue
try:
mtime = os.path.getmtime(full_path)
size = os.path.getsize(full_path)
except OSError:
continue
results.append({
"filename": entry, "generation": int(m.group(1)),
"mtime": mtime, "size_str": _format_log_size(size),
})
results.sort(key=lambda r: r["generation"])
for i, entry in enumerate(results):
older = results[i + 1] if i + 1 < len(results) else None
start_str = datetime.fromtimestamp(older["mtime"]).strftime(_LOG_HISTORY_DATE_FMT) if older else None
if entry["generation"] == 0:
end_str = "laufend"
else:
end_str = datetime.fromtimestamp(entry["mtime"]).strftime(_LOG_HISTORY_DATE_FMT)
entry["range_label"] = f"{start_str or '?'} {end_str}"
return results
def _resolve_log_history_file(filename):
"""Validiert einen vom Client übergebenen Dateinamen (?file=...) gegen
die tatsächlich vorhandene aktuelle live.log bzw. rotierte Kopien davon
in TESM_LOG_DIR -- verhindert Path-Traversal bzw. beliebiges
Datei-Lesen über den Query-Parameter. Gibt den vollen Pfad oder None
zurück."""
if filename == "live.log":
return TESM_LIVE_LOG_PATH if os.path.isfile(TESM_LIVE_LOG_PATH) else None
if not filename or not _LOG_HISTORY_FILE_RE.match(filename):
return None
full_path = os.path.join(TESM_LOG_DIR, filename)
if os.path.dirname(os.path.abspath(full_path)) != os.path.abspath(TESM_LOG_DIR):
return None
if not os.path.isfile(full_path):
return None
return full_path
def get_last_seen(dev_name: str): def get_last_seen(dev_name: str):
"""Letzter Zeitpunkt, zu dem ein Gerät laut Logs erreichbar war. Sucht """Letzter Zeitpunkt, zu dem ein Gerät laut Logs erreichbar war. Sucht
bewusst auch in den von logrotate rotierten Kopien (live.log.1, .2, ), bewusst auch in den von logrotate rotierten Kopien (live.log.1, .2, ),
@@ -2338,6 +2549,467 @@ def _revert_network_config(token):
_pending_network_revert.pop(token, None) _pending_network_revert.pop(token, None)
# ============================================================================
# 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;
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(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 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 + 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 + 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 _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
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
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)
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 alle
zugehörigen Einstellungen (Domain, Ports, SSL, HSTS) 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)
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:
_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 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(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
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_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_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")
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)
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.",
"success",
)
except OSError as e:
flash(f"Zertifikat konnte nicht gespeichert werden: {e}", "danger")
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 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_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.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_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}{port_suffix} "
f"erreichbar ist, bitte unten bestätigen — sonst wird nach {NGINX_REVERT_SECONDS}s "
f"automatisch zurückgerollt.",
"success",
)
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.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.nginx_cert_remove", "upload")
flash("Hochgeladenes Zertifikat entfernt.", "success")
return redirect(url_for("settings_nginx"))
pending_token = next(iter(_pending_nginx_revert), None)
return render_template(
"settings_nginx.html",
cert_info=_current_cert_info(),
ssl_enabled=get_setting("ssl_enabled", "0") == "1",
hsts_enabled=get_setting("hsts_enabled", "0") == "1",
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,
)
@app.route("/settings", methods=["GET", "POST"]) @app.route("/settings", methods=["GET", "POST"])
@login_required @login_required
def settings(): def settings():
@@ -4964,7 +5636,7 @@ def _write_client_update_error_log(name, exit_code, lines):
auch ohne offenes Browser-Fenster im Nachhinein auffindbar bleibt.""" auch ohne offenes Browser-Fenster im Nachhinein auffindbar bleibt."""
try: try:
os.makedirs(TESM_CLIENT_UPDATE_LOG_DIR, exist_ok=True) os.makedirs(TESM_CLIENT_UPDATE_LOG_DIR, exist_ok=True)
os.chmod(TESM_CLIENT_UPDATE_LOG_DIR, 0o777) os.chmod(TESM_CLIENT_UPDATE_LOG_DIR, 0o755)
safe_name = secure_filename(name) or "unbenannt" safe_name = secure_filename(name) or "unbenannt"
path = os.path.join(TESM_CLIENT_UPDATE_LOG_DIR, f"{safe_name}.error") path = os.path.join(TESM_CLIENT_UPDATE_LOG_DIR, f"{safe_name}.error")
with open(path, "w", encoding="utf-8") as f: with open(path, "w", encoding="utf-8") as f:
@@ -5711,21 +6383,107 @@ def _run_ssh_reboot(host, port, username, password, timeout=15):
@app.route("/get_log") @app.route("/get_log")
@login_required @login_required
def get_log(): def get_log():
"""Wird vom Live-Log-Poll (Intervall-Timer + manueller Aktualisieren-
Button) aufgerufen -- liefert bewusst nur die letzten LIVE_LOG_TAIL_LINES
Zeilen statt der kompletten Datei (siehe _tail_lines), damit dieser
wiederkehrende Abruf auf größeren Umgebungen keine spürbaren Ladezeiten
mehr verursacht. Das komplette Log bleibt über get_log_raw erreichbar."""
if not current_user.can_view_live_log: if not current_user.can_view_live_log:
return "Keine Berechtigung.", 403 return "Keine Berechtigung.", 403
latest_log = _latest_log_file() latest_log = _latest_log_file()
if not latest_log: if not latest_log:
return "Keine Logfiles gefunden." return "Keine Logfiles gefunden."
try: try:
with open(latest_log, "r") as f: content = _tail_lines(latest_log)
total_lines = _count_lines(latest_log)
except OSError as e:
content = f"Fehler beim Lesen des Logs: {e}"
total_lines = None
response = app.response_class(content, mimetype="text/plain")
response.headers["X-Log-Name"] = os.path.basename(latest_log)
if total_lines is not None:
response.headers["X-Total-Lines"] = str(total_lines)
return response
@app.route("/logs/live/raw")
@login_required
def get_log_raw():
"""Komplettes, unbearbeitetes Live-Log für das RAW-Popup -- bewusst ein
eigener Endpoint statt eines Query-Parameters an /get_log, damit der
normale Live-Poll (jeder Nutzer mit logs_live.view, in jedem
Prüfintervall) unter keinen Umständen versehentlich die komplette Datei
lädt. Wird nur einmalig auf explizite Nutzeraktion (Button-Klick)
nachgeladen."""
if not current_user.can_view_live_log:
return "Keine Berechtigung.", 403
latest_log = _latest_log_file()
if not latest_log:
return "Keine Logfiles gefunden."
try:
with open(latest_log, "r", encoding="utf-8", errors="replace") as f:
content = f.read() content = f.read()
except Exception as e: except OSError as e:
content = f"Fehler beim Lesen des Logs: {e}" content = f"Fehler beim Lesen des Logs: {e}"
response = app.response_class(content, mimetype="text/plain") response = app.response_class(content, mimetype="text/plain")
response.headers["X-Log-Name"] = os.path.basename(latest_log) response.headers["X-Log-Name"] = os.path.basename(latest_log)
return response return response
@app.route("/logs/history")
@login_required
def logs_history():
"""Verlauf: ältere, von logrotate rotierte Kopien des Live-Logs, als
eigene Unterseite von Live erreichbar (eigenes Recht logs_history.view,
siehe User.can_view_log_history) -- bewusst getrennt vom laufenden
Live-Poll, damit das Durchblättern alter Logstände nicht denselben
Performance-Pfad wie die Live-Ansicht belastet."""
if not current_user.can_view_log_history:
flash("Keine Berechtigung, den Log-Verlauf einzusehen.", "danger")
return redirect(url_for("index"))
files = _live_log_history_files()
requested = request.args.get("file", "")
selected_path = _resolve_log_history_file(requested) if requested else None
selected_name = requested if selected_path else None
if not selected_path and files:
selected_name = files[0]["filename"]
selected_path = _resolve_log_history_file(selected_name)
selected_file = next((f for f in files if f["filename"] == selected_name), None)
log_content = None
total_lines = None
if selected_path:
try:
log_content = _tail_lines(selected_path)
total_lines = _count_lines(selected_path)
except OSError as e:
log_content = f"Fehler beim Lesen des Logs: {e}"
return render_template(
"logs_history.html", files=files, selected_name=selected_name, selected_file=selected_file,
log_content=log_content, total_lines=total_lines, tail_lines=LIVE_LOG_TAIL_LINES,
)
@app.route("/logs/history/raw")
@login_required
def logs_history_raw():
if not current_user.can_view_log_history:
return "Keine Berechtigung.", 403
path = _resolve_log_history_file(request.args.get("file", ""))
if not path:
return "Unbekannte oder ungültige Log-Datei.", 404
try:
with open(path, "r", encoding="utf-8", errors="replace") as f:
content = f.read()
except OSError as e:
content = f"Fehler beim Lesen des Logs: {e}"
response = app.response_class(content, mimetype="text/plain")
response.headers["X-Log-Name"] = os.path.basename(path)
return response
@app.route("/logs") @app.route("/logs")
@login_required @login_required
def logs(): def logs():
@@ -5736,16 +6494,21 @@ def logs():
latest_log = _latest_log_file() latest_log = _latest_log_file()
if not latest_log: if not latest_log:
return render_template("logs.html", log_content="Keine Logfiles gefunden.", interval=interval) return render_template(
"logs.html", log_content="Keine Logfiles gefunden.", interval=interval,
total_lines=None, tail_lines=LIVE_LOG_TAIL_LINES,
)
try: try:
with open(latest_log, "r") as f: log_content = _tail_lines(latest_log)
log_content = f.read() total_lines = _count_lines(latest_log)
except Exception as e: except OSError as e:
log_content = f"Fehler beim Lesen des Logs: {e}" log_content = f"Fehler beim Lesen des Logs: {e}"
total_lines = None
return render_template( return render_template(
"logs.html", log_content=log_content, log_name=os.path.basename(latest_log), interval=interval "logs.html", log_content=log_content, log_name=os.path.basename(latest_log), interval=interval,
total_lines=total_lines, tail_lines=LIVE_LOG_TAIL_LINES,
) )
+15
View File
@@ -1104,6 +1104,21 @@ select {
.log-line.sep { color: #3a4050; } .log-line.sep { color: #3a4050; }
.log-line.restart { color: #ffb454; } .log-line.restart { color: #ffb454; }
.raw-log-content {
flex: 1;
overflow: auto;
margin: 0;
padding: 14px 18px;
font-family: var(--font-mono);
font-size: 12px;
line-height: 1.6;
white-space: pre-wrap;
word-break: break-word;
color: #c7ccd6;
background: #0a0c10;
}
[data-theme="light"] .raw-log-content { background: #0e1116; }
/* ========================================================================== /* ==========================================================================
Permission checklists (Gruppen) Permission checklists (Gruppen)
========================================================================== */ ========================================================================== */
+53
View File
@@ -211,6 +211,59 @@
openModal("save-discard-modal"); openModal("save-discard-modal");
}; };
/* ---------------- RAW-Log-Popup (Live-Log & Verlauf) ---------------- */
/* Gleiches Modal-Grundgerüst wie der Ja/Nein-Dialog für ungespeicherte
Änderungen (modal-overlay/modal-header/modal-body/modal-footer), nur
breiter/höher und mit lazy nachgeladenem Inhalt -- der komplette
Log-Inhalt wird bewusst erst beim tatsächlichen Öffnen per fetch
nachgeladen, nicht schon beim Seitenaufbau, damit genau das Problem
(unnötig große Ladezeiten) nicht durch das Popup selbst zurückkommt. */
function ensureRawLogModal() {
if (document.getElementById("raw-log-modal")) return;
const html = `
<div class="modal-overlay" id="raw-log-modal">
<div class="modal" style="max-width:min(1100px, 92vw); height:85vh;">
<div class="modal-header">
<h3 id="raw-log-modal-title">Komplettes Log (RAW)</h3>
<button type="button" class="modal-close" data-close-modal>&times;</button>
</div>
<div class="modal-body" style="padding:0; display:flex; flex-direction:column; flex:1; min-height:0;">
<pre id="raw-log-modal-content" class="raw-log-content"></pre>
</div>
<div class="modal-footer">
<span class="text-faint mono" id="raw-log-modal-meta" style="margin-right:auto; font-size:11.5px;"></span>
<button type="button" class="btn btn-secondary" data-close-modal>Schließen</button>
</div>
</div>
</div>`;
document.body.insertAdjacentHTML("beforeend", html);
document.querySelectorAll("#raw-log-modal [data-close-modal]").forEach((btn) => {
btn.addEventListener("click", () => closeModal(btn));
});
document.getElementById("raw-log-modal").addEventListener("click", (e) => {
if (e.target.id === "raw-log-modal") closeModal(e.target);
});
}
window.openRawLogModal = function (url, title) {
if (!url) return;
ensureRawLogModal();
document.getElementById("raw-log-modal-title").innerText = title || "Komplettes Log (RAW)";
const content = document.getElementById("raw-log-modal-content");
const meta = document.getElementById("raw-log-modal-meta");
content.textContent = "Lade …";
meta.textContent = "";
openModal("raw-log-modal");
fetch(url)
.then((r) => {
const logName = r.headers.get("X-Log-Name");
if (logName) meta.textContent = logName;
return r.text();
})
.then((text) => { content.textContent = text; })
.catch(() => { content.textContent = "Fehler beim Laden des Logs."; });
};
/* ---------------- Warnung bei ungespeicherten Änderungen ---------------- */ /* ---------------- Warnung bei ungespeicherten Änderungen ---------------- */
/* Erkennt generisch auf JEDER Seite, ob ein Formular mit echten /* Erkennt generisch auf JEDER Seite, ob ein Formular mit echten
Eingabefeldern (nicht nur versteckten Aktions-Feldern wie bei Löschen/ Eingabefeldern (nicht nur versteckten Aktions-Feldern wie bei Löschen/
+3
View File
@@ -19,6 +19,9 @@
"key": '<circle cx="8" cy="15" r="4"/><path d="M11 12l9-9M17 6l3 3M14 9l2 2"/>', "key": '<circle cx="8" cy="15" r="4"/><path d="M11 12l9-9M17 6l3 3M14 9l2 2"/>',
"terminal": '<rect x="2" y="4" width="20" height="16" rx="2"/><path d="M6 9l4 3-4 3M13 15h5"/>', "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"/>', "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"/>', "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"/>', "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"/>', "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"/>',
+20 -3
View File
@@ -8,18 +8,31 @@
<div class="section-head"> <div class="section-head">
<div> <div>
<h2 style="font-size:16px;">Live-Log</h2> <h2 style="font-size:16px;">Live-Log</h2>
<div class="hint">Laufende Erreichbarkeitsprüfung von poe.sh, farblich markiert (online/offline).</div> <div class="hint">Laufende Erreichbarkeitsprüfung von poe.sh, farblich markiert (online/offline). Zeigt aus Performance-Gründen nur die letzten {{ tail_lines }} Zeilen.</div>
</div> </div>
<div class="flex gap-2">
{% if current_user.can_view_log_history %}
<a href="{{ url_for('logs_history') }}" class="btn btn-secondary">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="9"/><path d="M12 7v5l3 3"/></svg>
Verlauf
</a>
{% endif %}
<button type="button" class="btn btn-secondary" onclick="openRawLogModal('{{ url_for('get_log_raw') }}', 'Komplettes Live-Log (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>
Komplettes Log (RAW)
</button>
<button id="refresh-btn" class="btn btn-secondary"> <button id="refresh-btn" class="btn btn-secondary">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12a9 9 0 11-3.2-6.9M21 4v5h-5"/></svg> <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12a9 9 0 11-3.2-6.9M21 4v5h-5"/></svg>
Aktualisieren Aktualisieren
</button> </button>
</div>
</div> </div>
<div class="log-shell"> <div class="log-shell">
<div class="log-toolbar"> <div class="log-toolbar">
<div class="log-dots"><span></span><span></span><span></span></div> <div class="log-dots"><span></span><span></span><span></span></div>
<span class="text-faint mono" style="font-size:11.5px;" data-log-name>{{ log_name or "" }}</span> <span class="text-faint mono" style="font-size:11.5px;" data-log-name>{{ log_name or "" }}</span>
<span class="text-faint mono" style="font-size:11.5px;" id="log-line-info">{% if total_lines %}letzte {{ tail_lines }} von {{ total_lines }} Zeilen{% endif %}</span>
</div> </div>
<div id="log-box">{{ log_content or "Keine Logfiles gefunden." }}</div> <div id="log-box">{{ log_content or "Keine Logfiles gefunden." }}</div>
</div> </div>
@@ -43,7 +56,7 @@ document.addEventListener("DOMContentLoaded", () => {
const intervalMinutes = {{ global_check_interval | int }}; const intervalMinutes = {{ global_check_interval | int }};
const intervalMilliseconds = intervalMinutes * 60 * 1000; const intervalMilliseconds = intervalMinutes * 60 * 1000;
function renderLog(text, logName) { function renderLog(text, logName, totalLines) {
const box = document.getElementById("log-box"); const box = document.getElementById("log-box");
box.innerHTML = ""; box.innerHTML = "";
const lines = text.split("\n"); const lines = text.split("\n");
@@ -55,11 +68,15 @@ document.addEventListener("DOMContentLoaded", () => {
if (logName) { if (logName) {
document.querySelectorAll("[data-log-name]").forEach((el) => { el.textContent = logName; }); document.querySelectorAll("[data-log-name]").forEach((el) => { el.textContent = logName; });
} }
const info = document.getElementById("log-line-info");
if (info && totalLines) {
info.textContent = "letzte {{ tail_lines }} von " + totalLines + " Zeilen";
}
} }
function fetchLog() { function fetchLog() {
fetch("{{ url_for('get_log') }}") fetch("{{ url_for('get_log') }}")
.then(r => r.text().then((text) => renderLog(text, r.headers.get("X-Log-Name")))) .then(r => r.text().then((text) => renderLog(text, r.headers.get("X-Log-Name"), r.headers.get("X-Total-Lines"))))
.catch(err => console.error(err)); .catch(err => console.error(err));
} }
+62
View File
@@ -0,0 +1,62 @@
{% extends "base.html" %}
{% set active_page = "logs" %}
{% block page_title %}Verlauf{% endblock %}
{% block page_sub %}<div class="topbar-sub">{{ selected_file.range_label if selected_file else "kein Log" }}</div>{% endblock %}
{% block content %}
<div class="section-head">
<div>
<h2 style="font-size:16px;">Log-Verlauf</h2>
<div class="hint">Aktuelles Live-Log sowie ältere, von logrotate rotierte Kopien davon — {{ files|length }} Stand{{ 'e' if files|length != 1 else '' }} verfügbar, auswählbar nach Zeitraum. Aus Performance-Gründen unformatiert (RAW) dargestellt, ohne farbliche Aufbereitung.</div>
</div>
<div class="flex gap-2">
<a href="{{ url_for('logs') }}" class="btn btn-secondary">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M19 12H5M12 19l-7-7 7-7"/></svg>
Zurück zur Live-Ansicht
</a>
<button type="button" class="btn btn-secondary"
onclick="openRawLogModal('{{ url_for('logs_history_raw', file=selected_name) if selected_name else '' }}', 'Komplettes Log (RAW) — {{ selected_file.range_label if selected_file else '' }}')"
{% if not selected_name %}disabled{% endif %}>
<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>
Komplettes Log (RAW)
</button>
</div>
</div>
{% if not files %}
<p class="text-faint" style="font-size:12.5px;">
Kein Live-Log gefunden.
</p>
{% else %}
<div class="flex gap-2" style="align-items:center; margin-bottom:14px;">
<label for="history-file-select" class="text-faint" style="font-size:12.5px; font-weight:600;">Zeitraum:</label>
<select id="history-file-select" onchange="window.location.href=this.value;" class="mono" style="max-width:460px;">
{% for f in files %}
<option value="{{ url_for('logs_history', file=f.filename) }}" {% if f.filename == selected_name %}selected{% endif %}>
{% if f.generation == 0 %}Aktuell — {{ f.range_label }}{% else %}{{ f.range_label }}{% endif %} ({{ f.size_str }})
</option>
{% endfor %}
</select>
</div>
<div class="log-shell">
<div class="log-toolbar">
<div class="log-dots"><span></span><span></span><span></span></div>
<span class="text-faint mono" style="font-size:11.5px;">{{ selected_file.range_label if selected_file else "" }}</span>
<span class="text-faint mono" style="font-size:11.5px;">{% if total_lines %}letzte {{ tail_lines }} von {{ total_lines }} Zeilen — RAW{% endif %}</span>
</div>
<pre id="log-box" class="raw-log-content" style="height:100%;">{{ log_content or "" }}</pre>
</div>
{% endif %}
{% endblock %}
{% block scripts %}
<script>
document.addEventListener("DOMContentLoaded", () => {
const box = document.getElementById("log-box");
if (box) box.scrollTop = box.scrollHeight;
});
</script>
{% endblock %}
+181
View File
@@ -0,0 +1,181 @@
{% extends "base.html" %}
{% 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">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">
<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 %}
<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>
<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ü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>
</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 Anfordern/Upload/Entfernen fehlt das Recht „NGINX ä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- # automatischem Backup + Migrations-Sicherheitsnetz samt Rückroll-
# Möglichkeit -- siehe install.sh für Details). # Möglichkeit -- siehe install.sh für Details).
# #
# Dieses Skript selbst ist nur noch für das Ablösen ÄLTERER, TESM # Dieses Skript ist reiner Bootstrapper: Download + Entpacken + Aufruf von
# vorausgehender Installationsstufen auf diesem Host zuständig: # install.sh. Die frühere Sonderbehandlung zweier TESM vorausgehender
# 1. Das ursprüngliche, reine Bash-Setup aus dem separaten Repo # Installationsstufen (rpi-check.service aus dem ursprünglichen reinen
# https://gitea.int.eertmoed.net/WiS/Aruba-PoE (rpi-check.service + # Bash-Setup, poe_web.service/poe_manager als Flask-Vorgänger von TESM)
# statische ips.list, keine Datenbank/Web-UI). # wurde entfernt -- diese Stufen sind auf allen aktuellen Installationen
# 2. Die spätere Flask-App "PoE Manager" unter /srv/poe_manager # längst migriert, die dazugehörigen Unit-Dateien wurden dort manuell
# (poe_web.service, Vorgänger von TESM in diesem Repo vor der # entfernt. Ein heute noch unmigriertes System wäre ohnehin so alt, dass
# Umbenennung). # ein manueller Blick vor dem Update angebracht ist statt automatischer
# Beide werden NICHT gelöscht, sondern nur gestoppt/deaktiviert und # stillschweigender Bereinigung.
# (falls vorhanden) beiseite verschoben.
# #
# Auszuführen als root auf dem Zielsystem: # Auszuführen als root auf dem Zielsystem:
# sudo ./update.sh # sudo ./update.sh
@@ -38,7 +37,6 @@ GITEA_TOKEN="${TESM_GITEA_TOKEN:-}"
PACKAGE_NAME="tesm-${RELEASE_TAG}.tar.gz" PACKAGE_NAME="tesm-${RELEASE_TAG}.tar.gz"
DOWNLOAD_URL="${GITEA_BASE}/${REPO_OWNER}/${REPO_NAME}/releases/download/${RELEASE_TAG}/${PACKAGE_NAME}" 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)" WORK_DIR="/tmp/tesm-update-$(date +%s)"
PACKAGE_FILE="$WORK_DIR/${PACKAGE_NAME}" 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 "Installation oder ein Update einer bestehenden handelt, und sichert"
echo "die Datenbank bei einer Schema-Änderung automatisch vorsorglich ab." echo "die Datenbank bei einer Schema-Änderung automatisch vorsorglich ab."
echo 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 read -r -p "Fortfahren? [y/N] " confirm
if [[ "$confirm" != "y" && "$confirm" != "Y" ]]; then 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 tar xzf "$PACKAGE_FILE" -C "$WORK_DIR/pkg" --strip-components=1
echo -e "${GREEN}${NC} Paket entpackt." 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..." echo -e "${RED}${NC} Starte install.sh aus dem Paket..."
bash "$WORK_DIR/pkg/install.sh" bash "$WORK_DIR/pkg/install.sh"