Compare commits
7
Commits
v1.0.5
..
0074e48e09
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0074e48e09 | ||
|
|
a3f5f028f2 | ||
|
|
5bca29f3da | ||
|
|
4de5828573 | ||
|
|
f27dde466b | ||
|
|
8253ab3f0e | ||
|
|
befc76819e |
@@ -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
|
||||||
|
|
||||||
|
|||||||
+45
-13
@@ -44,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
|
||||||
@@ -99,16 +114,21 @@ 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"
|
||||||
@@ -145,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) ----
|
||||||
@@ -157,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 \
|
||||||
@@ -166,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"
|
||||||
|
if [ ! -f /etc/nginx/sites-available/tesm ]; then
|
||||||
sudo cp "$REPO_DIR/etc/nginx/sites-available/tesm" /etc/nginx/sites-available/tesm
|
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
|
||||||
@@ -175,7 +205,9 @@ print_status "nginx configured"
|
|||||||
|
|
||||||
# ---- Dienste aktivieren ----
|
# ---- Dienste aktivieren ----
|
||||||
step "Enabling services"
|
step "Enabling services"
|
||||||
|
if [ "$UNITS_CHANGED" -eq 1 ]; then
|
||||||
sudo systemctl daemon-reload
|
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"
|
||||||
|
|||||||
+1
-1
@@ -1 +1 @@
|
|||||||
1.0.5
|
1.0.10
|
||||||
|
|||||||
+622
-18
@@ -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"),
|
||||||
@@ -254,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"},
|
||||||
@@ -292,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 = []
|
||||||
@@ -331,6 +389,7 @@ 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": [
|
||||||
@@ -369,6 +428,8 @@ 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":
|
if key == "logs_history":
|
||||||
@@ -1183,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
|
||||||
@@ -1201,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:
|
||||||
@@ -1892,18 +1959,39 @@ def _format_log_size(num_bytes):
|
|||||||
|
|
||||||
|
|
||||||
_LOG_HISTORY_FILE_RE = re.compile(r"^live\.log\.([1-9][0-9]*)$")
|
_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():
|
def _live_log_history_files():
|
||||||
"""Ältere, von logrotate rotierte Kopien des Live-Logs (live.log.1 =
|
"""Alle über die Verlauf-Seite auswählbaren Stände des Live-Logs --
|
||||||
zuletzt rotiert, .2 der Lauf davor, usw. -- copytruncate ohne compress,
|
generation 0 ist die aktuelle, noch laufende live.log selbst, 1 die
|
||||||
siehe _write_logrotate_config, daher reine Textdateien ohne .gz),
|
zuletzt rotierte Kopie, 2 der Lauf davor usw. (copytruncate ohne
|
||||||
sortiert von neu (1) nach alt."""
|
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 = []
|
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:
|
try:
|
||||||
entries = os.listdir(TESM_LOG_DIR)
|
entries = os.listdir(TESM_LOG_DIR)
|
||||||
except OSError:
|
except OSError:
|
||||||
return results
|
entries = []
|
||||||
for entry in entries:
|
for entry in entries:
|
||||||
m = _LOG_HISTORY_FILE_RE.match(entry)
|
m = _LOG_HISTORY_FILE_RE.match(entry)
|
||||||
if not m:
|
if not m:
|
||||||
@@ -1917,20 +2005,30 @@ def _live_log_history_files():
|
|||||||
except OSError:
|
except OSError:
|
||||||
continue
|
continue
|
||||||
results.append({
|
results.append({
|
||||||
"filename": entry,
|
"filename": entry, "generation": int(m.group(1)),
|
||||||
"generation": int(m.group(1)),
|
"mtime": mtime, "size_str": _format_log_size(size),
|
||||||
"mtime_str": datetime.fromtimestamp(mtime).strftime("%d.%m.%Y %H:%M"),
|
|
||||||
"size_str": _format_log_size(size),
|
|
||||||
})
|
})
|
||||||
results.sort(key=lambda r: r["generation"])
|
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
|
return results
|
||||||
|
|
||||||
|
|
||||||
def _resolve_log_history_file(filename):
|
def _resolve_log_history_file(filename):
|
||||||
"""Validiert einen vom Client übergebenen Dateinamen (?file=...) gegen
|
"""Validiert einen vom Client übergebenen Dateinamen (?file=...) gegen
|
||||||
die tatsächlich vorhandenen rotierten Live-Log-Kopien in TESM_LOG_DIR --
|
die tatsächlich vorhandene aktuelle live.log bzw. rotierte Kopien davon
|
||||||
verhindert Path-Traversal bzw. beliebiges Datei-Lesen über den
|
in TESM_LOG_DIR -- verhindert Path-Traversal bzw. beliebiges
|
||||||
Query-Parameter. Gibt den vollen Pfad oder None zurück."""
|
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):
|
if not filename or not _LOG_HISTORY_FILE_RE.match(filename):
|
||||||
return None
|
return None
|
||||||
full_path = os.path.join(TESM_LOG_DIR, filename)
|
full_path = os.path.join(TESM_LOG_DIR, filename)
|
||||||
@@ -2451,6 +2549,511 @@ 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)
|
||||||
|
|
||||||
|
|
||||||
|
def _certbot_timer_status():
|
||||||
|
"""Status von certbot.timer -- dem systemd-Timer, der die automatische
|
||||||
|
Verlängerung tatsächlich antreibt (Standard-Paketverhalten unter
|
||||||
|
Debian/Ubuntu, zweimal täglich). Rein informativ für die NGINX-Seite:
|
||||||
|
"automatische Verlängerung" soll für den Admin sichtbar/nachprüfbar
|
||||||
|
sein, nicht nur eine Behauptung im Quelltext bleiben. None, wenn
|
||||||
|
certbot (noch) nicht installiert ist."""
|
||||||
|
active_ok, active_out = _dhcp_run_privileged(["systemctl", "is-active", "certbot.timer"], timeout=5)
|
||||||
|
if not active_ok and "could not be found" in active_out.lower():
|
||||||
|
return None
|
||||||
|
next_run = None
|
||||||
|
ok, out = _dhcp_run_privileged(["systemctl", "list-timers", "certbot.timer", "--no-legend"], timeout=5)
|
||||||
|
if ok and out.strip():
|
||||||
|
parts = out.strip().split(None, 4)
|
||||||
|
if len(parts) >= 4:
|
||||||
|
next_run = " ".join(parts[0:4])
|
||||||
|
return {"active": active_out.strip() == "active", "next_run": next_run}
|
||||||
|
|
||||||
|
|
||||||
|
@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 "test_renewal" in request.form:
|
||||||
|
if get_setting("ssl_source", "upload") != "letsencrypt":
|
||||||
|
flash("Verlängerungs-Test setzt ein per Let's Encrypt ausgestelltes Zertifikat voraus.", "danger")
|
||||||
|
else:
|
||||||
|
domain = get_setting("nginx_server_name", "_")
|
||||||
|
ok, out = _dhcp_run_privileged(["certbot", "renew", "--dry-run", "--cert-name", domain], timeout=120)
|
||||||
|
if ok:
|
||||||
|
log_action("settings.nginx_letsencrypt_test_renewal", domain)
|
||||||
|
flash("Verlängerungs-Test erfolgreich (Dry-Run — es wurde nichts tatsächlich verändert).", "success")
|
||||||
|
else:
|
||||||
|
flash(f"Verlängerungs-Test fehlgeschlagen: {out.strip()[-800:]}", "danger")
|
||||||
|
|
||||||
|
elif "force_renew" in request.form:
|
||||||
|
if get_setting("ssl_source", "upload") != "letsencrypt":
|
||||||
|
flash("Manuelle Verlängerung setzt ein per Let's Encrypt ausgestelltes Zertifikat voraus.", "danger")
|
||||||
|
else:
|
||||||
|
domain = get_setting("nginx_server_name", "_")
|
||||||
|
ok, out = _dhcp_run_privileged(["certbot", "renew", "--force-renewal", "--cert-name", domain], timeout=120)
|
||||||
|
if ok:
|
||||||
|
log_action("settings.nginx_letsencrypt_force_renew", domain)
|
||||||
|
flash("Zertifikat manuell erneuert — nginx wurde über den Deploy-Hook automatisch neu geladen.", "success")
|
||||||
|
else:
|
||||||
|
flash(f"Manuelle Verlängerung fehlgeschlagen: {out.strip()[-800:]}", "danger")
|
||||||
|
|
||||||
|
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,
|
||||||
|
certbot_timer=_certbot_timer_status(),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@app.route("/settings", methods=["GET", "POST"])
|
@app.route("/settings", methods=["GET", "POST"])
|
||||||
@login_required
|
@login_required
|
||||||
def settings():
|
def settings():
|
||||||
@@ -5077,7 +5680,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:
|
||||||
@@ -5889,7 +6492,8 @@ def logs_history():
|
|||||||
selected_name = requested if selected_path else None
|
selected_name = requested if selected_path else None
|
||||||
if not selected_path and files:
|
if not selected_path and files:
|
||||||
selected_name = files[0]["filename"]
|
selected_name = files[0]["filename"]
|
||||||
selected_path = os.path.join(TESM_LOG_DIR, selected_name)
|
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
|
log_content = None
|
||||||
total_lines = None
|
total_lines = None
|
||||||
@@ -5901,7 +6505,7 @@ def logs_history():
|
|||||||
log_content = f"Fehler beim Lesen des Logs: {e}"
|
log_content = f"Fehler beim Lesen des Logs: {e}"
|
||||||
|
|
||||||
return render_template(
|
return render_template(
|
||||||
"logs_history.html", files=files, selected_name=selected_name,
|
"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,
|
log_content=log_content, total_lines=total_lines, tail_lines=LIVE_LOG_TAIL_LINES,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -197,13 +197,23 @@ button { font-family: inherit; }
|
|||||||
.nav-group.expanded .nav-group-children { display: flex; }
|
.nav-group.expanded .nav-group-children { display: flex; }
|
||||||
.nav-group-children .nav-item { font-size: 13px; padding: 8px 12px; }
|
.nav-group-children .nav-item { font-size: 13px; padding: 8px 12px; }
|
||||||
|
|
||||||
/* Kacheln nebeneinander (z.B. Im-/Export, Konto-Seite) */
|
/* Kacheln nebeneinander (z.B. Im-/Export, Konto-Seite). minmax(min(...,
|
||||||
|
100%), 1fr) statt eines nackten Pixelwerts -- eine Spalten-Mindestbreite
|
||||||
|
von z.B. 340px würde auf einem 375px-iPhone (abzüglich .content-Padding)
|
||||||
|
sonst waagerechten Overflow der ganzen Seite erzwingen, weil "auto-fit"
|
||||||
|
erst ab einer Breite größer als der Mindestwert umbricht. */
|
||||||
.settings-grid {
|
.settings-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(auto-fit, minmax(340px, 1fr));
|
grid-template-columns: repeat(auto-fit, minmax(min(340px, 100%), 1fr));
|
||||||
gap: 24px;
|
gap: 24px;
|
||||||
align-items: start;
|
align-items: start;
|
||||||
}
|
}
|
||||||
|
/* Modifier für Seiten, die etwas breitere Karten brauchen (z.B. NGINX,
|
||||||
|
LDAP) -- ersetzt frühere inline style="grid-template-columns:..."
|
||||||
|
Overrides, die denselben 100%-Fallback nicht hatten. */
|
||||||
|
.settings-grid--wide {
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(min(420px, 100%), 1fr));
|
||||||
|
}
|
||||||
|
|
||||||
/* Navbar-Reihenfolge (Settings) */
|
/* Navbar-Reihenfolge (Settings) */
|
||||||
.nav-order-list {
|
.nav-order-list {
|
||||||
@@ -683,8 +693,19 @@ button { font-family: inherit; }
|
|||||||
background: var(--bg-card);
|
background: var(--bg-card);
|
||||||
border: 1px solid var(--border-soft);
|
border: 1px solid var(--border-soft);
|
||||||
border-radius: var(--radius);
|
border-radius: var(--radius);
|
||||||
|
/* Zurück auf overflow:hidden (nur für die abgerundeten Ecken) --
|
||||||
|
JEDES Template, das .table-wrap verwendet, hat bereits einen eigenen
|
||||||
|
<div style="overflow-x:auto;"> exakt um die <table> herum (nicht um
|
||||||
|
die ganze Karte). overflow-x:auto zusätzlich HIER hätte einen zweiten,
|
||||||
|
verschachtelten Scroll-Container um denselben Inhalt erzeugt -- live
|
||||||
|
auf einem iPhone-Viewport nachgewiesen: bei der aufklappbaren
|
||||||
|
Rechte-Tabelle (Gruppen-Seite) landete der Inhalt dadurch dauerhaft
|
||||||
|
unsichtbar bei negativem x. Die Tabellen scrollen also weiterhin
|
||||||
|
horizontal, nur über den bereits vorhandenen inneren Wrapper, nicht
|
||||||
|
zusätzlich über die Karte selbst. */
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
.table-wrap table.data-table { min-width: 560px; }
|
||||||
|
|
||||||
.table-toolbar {
|
.table-toolbar {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -792,6 +813,12 @@ table.data-table {
|
|||||||
========================================================================== */
|
========================================================================== */
|
||||||
|
|
||||||
.field { margin-bottom: 15px; }
|
.field { margin-bottom: 15px; }
|
||||||
|
/* Zwei .field nebeneinander in einer .flex-Zeile (z.B. HTTP-/HTTPS-Port
|
||||||
|
bei NGINX) -- als Klasse statt inline style="flex:1", damit die
|
||||||
|
Mobile-Stapel-Regel (siehe @media max-width:640px weiter unten) sie
|
||||||
|
per externem Stylesheet überschreiben kann; ein inline style gewinnt
|
||||||
|
sonst grundsätzlich gegen jede @media-Regel, ganz gleich wie spezifisch. */
|
||||||
|
.field--half { flex: 1; min-width: 0; }
|
||||||
.field label {
|
.field label {
|
||||||
display: block;
|
display: block;
|
||||||
font-size: 12.5px;
|
font-size: 12.5px;
|
||||||
@@ -1156,8 +1183,14 @@ select {
|
|||||||
Logs mit nur R). Feste/zu schmale Breiten haben die D-Spalte bei
|
Logs mit nur R). Feste/zu schmale Breiten haben die D-Spalte bei
|
||||||
Geräte zuvor lautlos in den Overflow geschoben. */
|
Geräte zuvor lautlos in den Overflow geschoben. */
|
||||||
flex: 0 0 auto;
|
flex: 0 0 auto;
|
||||||
|
max-width: 100%;
|
||||||
padding-right: 28px;
|
padding-right: 28px;
|
||||||
border-right: 1px solid var(--border-soft);
|
border-right: 1px solid var(--border-soft);
|
||||||
|
/* Fällt eine einzelne Spalte (z.B. Geräte mit vielen Rechten-Spalten)
|
||||||
|
trotz .permission-groups-row's flex-wrap auf einem schmalen Screen
|
||||||
|
für sich genommen noch zu breit aus, scrollt nur diese eine Spalte
|
||||||
|
waagerecht statt die Seite aufzureißen. */
|
||||||
|
overflow-x: auto;
|
||||||
}
|
}
|
||||||
.permission-group-col:last-child { border-right: none; padding-right: 0; }
|
.permission-group-col:last-child { border-right: none; padding-right: 0; }
|
||||||
.permission-group-header-cell { padding-left: 0 !important; }
|
.permission-group-header-cell { padding-left: 0 !important; }
|
||||||
@@ -1286,14 +1319,57 @@ select {
|
|||||||
.content { padding: 18px 16px 40px; }
|
.content { padding: 18px 16px 40px; }
|
||||||
.topbar { padding: 10px 16px; }
|
.topbar { padding: 10px 16px; }
|
||||||
.topbar-logo { opacity: 0.5; }
|
.topbar-logo { opacity: 0.5; }
|
||||||
|
|
||||||
|
/* Ab hier iPad-Breite (900px ist bereits der bestehende Sidebar-
|
||||||
|
Umschaltpunkt) und schmaler: generische Button-/Toolbar-Zeilen
|
||||||
|
(.flex) sollen umbrechen statt seitlich überzulaufen -- betrifft vor
|
||||||
|
allem Kopfzeilen mit mehreren Aktions-Buttons (z.B. NGINX-, Logs-,
|
||||||
|
Verlauf-Seite) und nebeneinander angeordnete Formularfelder. */
|
||||||
|
.flex { flex-wrap: wrap; }
|
||||||
|
.section-head { flex-wrap: wrap; }
|
||||||
|
.term-toolbar, .log-toolbar { flex-wrap: wrap; row-gap: 8px; }
|
||||||
|
.detail-row { flex-wrap: wrap; }
|
||||||
|
.detail-row .v { text-align: left; }
|
||||||
|
|
||||||
|
/* Modal-Fußzeilen (Speichern/Abbrechen) und Tabellen-Kopfzeilen
|
||||||
|
(Suche + Aktion) sollen bei wenig Platz ebenfalls umbrechen statt
|
||||||
|
Buttons/Suchfeld ineinanderzuschieben. */
|
||||||
|
.modal-footer { flex-wrap: wrap; }
|
||||||
|
.table-toolbar .search-input { min-width: 0; flex: 1 1 160px; }
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 640px) {
|
@media (max-width: 640px) {
|
||||||
.topbar-logo { display: none; }
|
.topbar-logo { display: none; }
|
||||||
|
|
||||||
|
/* Formulare mit zwei nebeneinander angeordneten Feldern (class="flex
|
||||||
|
gap-2" um zwei .field-Divs, z.B. HTTP-/HTTPS-Port bei NGINX) stapeln
|
||||||
|
auf Handy-Breite volle Breite pro Feld statt zwei sehr schmale
|
||||||
|
Eingaben nebeneinander zu erzwingen. */
|
||||||
|
form .flex > .field { flex-basis: 100%; }
|
||||||
|
|
||||||
|
.modal-header, .modal-body, .modal-footer { padding-left: 16px; padding-right: 16px; }
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 560px) {
|
@media (max-width: 560px) {
|
||||||
.device-grid { grid-template-columns: repeat(auto-fill, minmax(140px, 1fr)); }
|
.device-grid { grid-template-columns: repeat(auto-fill, minmax(140px, 1fr)); }
|
||||||
.topbar-title { font-size: 16px; }
|
.topbar-title { font-size: 16px; }
|
||||||
.stat-row { grid-template-columns: repeat(2, 1fr); }
|
.stat-row { grid-template-columns: repeat(2, 1fr); }
|
||||||
|
|
||||||
|
/* Button-Leisten in Kopfzeilen (z.B. section-head/card-Kopf mit
|
||||||
|
"RAW anzeigen" o.ä.) nehmen auf sehr schmalen Screens die volle
|
||||||
|
Breite ein statt als schmaler Block rechts zu kleben. */
|
||||||
|
.section-head > .btn,
|
||||||
|
.section-head > .btn-sm { flex: 1 1 100%; }
|
||||||
|
|
||||||
|
/* Auf einem iPhone quetschen die beiden rechten Topbar-Pillen (Prüf-
|
||||||
|
Countdown + DHCP) .topbar-left (flex:1 1 0%, min-width:0) so weit
|
||||||
|
zusammen, dass der Seitentitel über seine eigene Spaltenbreite
|
||||||
|
hinausläuft und sichtbar mit der Pille kollidiert (per Playwright-
|
||||||
|
Screenshot auf 393px Breite nachgewiesen: .topbar-title ragte 24.5px
|
||||||
|
über .topbar-lefts berechnete Breite hinaus). Titel und Pillen
|
||||||
|
bekommen ab hier je eine eigene Zeile statt sich einen Platz zu
|
||||||
|
teilen, der für beide zusammen nicht reicht. */
|
||||||
|
.topbar { flex-wrap: wrap; row-gap: 6px; }
|
||||||
|
.topbar-left { flex: 1 1 100%; }
|
||||||
|
.topbar-right { flex: 1 1 100%; justify-content: flex-start; }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,6 +20,8 @@
|
|||||||
"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"/>',
|
"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"/>',
|
||||||
|
|||||||
@@ -1,14 +1,14 @@
|
|||||||
{% extends "base.html" %}
|
{% extends "base.html" %}
|
||||||
{% set active_page = "logs" %}
|
{% set active_page = "logs" %}
|
||||||
{% block page_title %}Verlauf{% endblock %}
|
{% block page_title %}Verlauf{% endblock %}
|
||||||
{% block page_sub %}<div class="topbar-sub">{{ selected_name or "kein Archiv-Log" }}</div>{% endblock %}
|
{% block page_sub %}<div class="topbar-sub">{{ selected_file.range_label if selected_file else "kein Log" }}</div>{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
|
|
||||||
<div class="section-head">
|
<div class="section-head">
|
||||||
<div>
|
<div>
|
||||||
<h2 style="font-size:16px;">Log-Verlauf</h2>
|
<h2 style="font-size:16px;">Log-Verlauf</h2>
|
||||||
<div class="hint">Ältere, von logrotate rotierte Kopien des Live-Logs — {{ files|length }} Stand{{ 'e' if files|length != 1 else '' }} verfügbar.</div>
|
<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>
|
||||||
<div class="flex gap-2">
|
<div class="flex gap-2">
|
||||||
<a href="{{ url_for('logs') }}" class="btn btn-secondary">
|
<a href="{{ url_for('logs') }}" class="btn btn-secondary">
|
||||||
@@ -16,7 +16,7 @@
|
|||||||
Zurück zur Live-Ansicht
|
Zurück zur Live-Ansicht
|
||||||
</a>
|
</a>
|
||||||
<button type="button" class="btn btn-secondary"
|
<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_name or '' }}')"
|
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 %}>
|
{% 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>
|
<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)
|
Komplettes Log (RAW)
|
||||||
@@ -26,18 +26,16 @@
|
|||||||
|
|
||||||
{% if not files %}
|
{% if not files %}
|
||||||
<p class="text-faint" style="font-size:12.5px;">
|
<p class="text-faint" style="font-size:12.5px;">
|
||||||
Noch keine rotierten Live-Logs vorhanden — sobald logrotate das aktuelle
|
Kein Live-Log gefunden.
|
||||||
<a href="{{ url_for('logs') }}" style="color:var(--accent-strong); font-weight:600;">Live-Log</a>
|
|
||||||
zum ersten Mal rotiert (siehe Rotations-Intervall unter Systemeinstellungen), erscheinen ältere Stände hier.
|
|
||||||
</p>
|
</p>
|
||||||
{% else %}
|
{% else %}
|
||||||
|
|
||||||
<div class="flex gap-2" style="align-items:center; margin-bottom:14px;">
|
<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;">Archiv-Stand:</label>
|
<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:420px;">
|
<select id="history-file-select" onchange="window.location.href=this.value;" class="mono" style="max-width:460px;">
|
||||||
{% for f in files %}
|
{% for f in files %}
|
||||||
<option value="{{ url_for('logs_history', file=f.filename) }}" {% if f.filename == selected_name %}selected{% endif %}>
|
<option value="{{ url_for('logs_history', file=f.filename) }}" {% if f.filename == selected_name %}selected{% endif %}>
|
||||||
{{ f.filename }} — rotiert am {{ f.mtime_str }} ({{ f.size_str }})
|
{% if f.generation == 0 %}Aktuell — {{ f.range_label }}{% else %}{{ f.range_label }}{% endif %} ({{ f.size_str }})
|
||||||
</option>
|
</option>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</select>
|
</select>
|
||||||
@@ -46,39 +44,19 @@
|
|||||||
<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;">{{ selected_name }}</span>
|
<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{% endif %}</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>
|
</div>
|
||||||
<div id="log-box">{{ log_content or "" }}</div>
|
<pre id="log-box" class="raw-log-content" style="height:100%;">{{ log_content or "" }}</pre>
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block scripts %}
|
{% block scripts %}
|
||||||
<script>
|
<script>
|
||||||
function colorizeLine(line) {
|
|
||||||
let cls = "";
|
|
||||||
if (line.includes(" ist erreichbar!")) cls = "online";
|
|
||||||
else if (line.includes(" ist nicht erreichbar!")) cls = "offline";
|
|
||||||
else if (line.startsWith("----")) cls = "sep";
|
|
||||||
else if (line.toLowerCase().includes("manuell") || line.includes("PoE")) cls = "restart";
|
|
||||||
const span = document.createElement("span");
|
|
||||||
span.className = "log-line" + (cls ? " " + cls : "");
|
|
||||||
span.textContent = line;
|
|
||||||
return span;
|
|
||||||
}
|
|
||||||
|
|
||||||
document.addEventListener("DOMContentLoaded", () => {
|
document.addEventListener("DOMContentLoaded", () => {
|
||||||
const box = document.getElementById("log-box");
|
const box = document.getElementById("log-box");
|
||||||
if (!box) return;
|
if (box) box.scrollTop = box.scrollHeight;
|
||||||
const text = box.textContent;
|
|
||||||
box.innerHTML = "";
|
|
||||||
const lines = text.split("\n");
|
|
||||||
lines.forEach((line, i) => {
|
|
||||||
box.appendChild(colorizeLine(line));
|
|
||||||
if (i < lines.length - 1) box.appendChild(document.createElement("br"));
|
|
||||||
});
|
|
||||||
box.scrollTop = box.scrollHeight;
|
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
{% block page_sub %}<div class="topbar-sub">Anmeldung mit dem Domänen-Passwort, zusätzlich zu lokalen Konten</div>{% endblock %}
|
{% block page_sub %}<div class="topbar-sub">Anmeldung mit dem Domänen-Passwort, zusätzlich zu lokalen Konten</div>{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<div class="settings-grid" style="grid-template-columns: repeat(auto-fit, minmax(420px, 1fr));">
|
<div class="settings-grid settings-grid--wide">
|
||||||
|
|
||||||
<div class="card card-pad">
|
<div class="card card-pad">
|
||||||
<div class="section-head" style="margin-bottom:16px;">
|
<div class="section-head" style="margin-bottom:16px;">
|
||||||
|
|||||||
@@ -0,0 +1,206 @@
|
|||||||
|
{% 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 settings-grid--wide">
|
||||||
|
|
||||||
|
<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 field--half">
|
||||||
|
<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 field--half">
|
||||||
|
<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>
|
||||||
|
{% if cert_info.source == "letsencrypt" %}
|
||||||
|
<div class="detail-row">
|
||||||
|
<span class="k">Automatische Verlängerung</span>
|
||||||
|
<span class="v">
|
||||||
|
{% if certbot_timer and certbot_timer.active %}
|
||||||
|
<span class="pill online">aktiv</span>
|
||||||
|
{% if certbot_timer.next_run %}<span class="text-faint" style="font-size:11px; display:block; margin-top:2px;">nächster Lauf: {{ certbot_timer.next_run }}</span>{% endif %}
|
||||||
|
{% elif certbot_timer %}
|
||||||
|
<span class="pill offline">inaktiv</span>
|
||||||
|
{% else %}
|
||||||
|
<span class="pill unknown">unbekannt</span>
|
||||||
|
{% endif %}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% if cert_info.source == "letsencrypt" and can_edit %}
|
||||||
|
<div class="flex gap-2" style="margin-bottom:18px;">
|
||||||
|
<form method="post" style="flex:1;">
|
||||||
|
<button type="submit" name="test_renewal" value="1" class="btn btn-secondary btn-block btn-sm">Verlängerung testen (Dry-Run)</button>
|
||||||
|
</form>
|
||||||
|
<form method="post" style="flex:1;" data-confirm="Zertifikat jetzt sofort erneuern (zählt gegen Let's Encrypts Rate-Limits)?">
|
||||||
|
<button type="submit" name="force_renew" value="1" class="btn btn-secondary btn-block btn-sm">Jetzt manuell verlängern</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% 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. Verlängerung läuft danach automatisch über certbots eigenen Timer{% if certbot_timer %} ({{ "aktiv" if certbot_timer.active else "inaktiv!" }}){% endif %}, zweimal täglich.</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,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"
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user