Logging & Log-Rotation, Zeitzone, Dashboard-UX, Design-Politur

Logging-Infrastruktur (Logs können nach einem Import mit vielen Geräten
schnell vollaufen):
- Vier eigene, rotierte Log-Dateien statt allem in /var/log vermischt:
  Live (poe.sh, jetzt eine einzige durchgehend fortgeschriebene Datei
  statt vorher ein neues rpi-<timestamp>.log pro Dienst-Neustart),
  Änderungen (Spiegel der audit_log-Tabelle), App (Flask-Fehler/
  Warnungen, vorher nur im journalctl-Output), Kea-DHCP (eigene
  Logging-Konfiguration in der generierten kea-dhcp4.conf)
- Kea-DHCP-Log bewusst NICHT im gemeinsamen Verzeichnis: Keas
  AppArmor-Profil erlaubt Schreibzugriff exklusiv auf
  /var/log/kea/kea-dhcp4.log (kein Verzeichnis-Wildcard) -- live als
  "DENIED ... mknod ... .lock" reproduziert; die App nutzt Keas eigenen,
  bereits erlaubten Pfad statt das AppArmor-Profil eines Systemdienstes
  aufzuweichen. logrotate deckt trotzdem beide Verzeichnisse mit einer
  Regel ab
- Neue Log-Seite "Kea-DHCP" (Logs-Bereich, eigenes Recht logs_kea.view)
- Rotation (Intervall täglich/wöchentlich/monatlich, Aufbewahrung als
  Anzahl Rotationen) unter Systemeinstellungen → Logs einstellbar,
  generiert /etc/logrotate.d/poe-manager; copytruncate, da keiner der
  drei schreibenden Prozesse ein Reopen-Signal implementiert
- Einmalige Migration räumt alte, verwaiste rpi-*.log-Dateien auf

Zeitzone (Systemeinstellungen, mit Hostname in einer Kachel "Host"):
setzt die System-Zeitzone per timedatectl, damit Logs/Änderungsverlauf
die tatsächliche lokale Zeit statt UTC zeigen. time.tzset() direkt nach
dem Setzen lässt die bereits laufende poe_web.service-Instanz die neue
Zeitzone sofort übernehmen, ohne Neustart.

Systemeinstellungen neu sortiert: Host + Netzwerkeinstellungen (beide
Host-Konfiguration) nebeneinander, dann Logs, dann Prüfintervall.

Netzwerkeinstellungen: DNS-Anzeige zeigte bisher den lokalen
systemd-resolved-Stub (127.0.0.53) aus /etc/resolv.conf statt der
echten Server -- jetzt über "resolvectl dns <interface>", inkl. Fix
für eine falsch geparste IPv6-Fortsetzungszeile ohne Label, und
gefiltert auf IPv4 (diese Seite verwaltet nur IPv4-Konfiguration, per
IPv6-Router-Advertisement gelernte Server sind hier irreführend).

Dashboard: die vier Stat-Kacheln (Online/Offline/Deaktiviert/Gesamt)
sind jetzt klickbare Filter (blenden alle anderen Abschnitte aus, Klick
auf die aktive Kachel oder "Gesamt" hebt den Filter auf); jeder
Abschnitt lässt sich unabhängig davon per Klick auf seinen Titel ein-/
ausklappen. Beide Zustände bleiben über die automatischen Live-Updates
hinweg erhalten.

Design-Politur:
- Hauptinhaltsbereich nutzt jetzt die volle verfügbare Breite (kein
  Bootstrap-typisches festes max-width mehr) -- Modals bleiben bewusst
  bei ihrer kompakten, festen Breite
  Akzentfarbe exakt auf das Orange aus dem WiS-Logo abgestimmt
  (#ff7100, per Pixel-Sampling ermittelt) statt einer nur ähnlichen,
  frei gewählten Nuance
- Abmelden führt zurück auf das (auch ohne Login einsehbare) Dashboard
  statt auf die Login-Seite

Live gegen die echte Test-VM verifiziert: alle vier Logs werden
geschrieben, logrotate-Konfiguration korrekt generiert, ein
Zeitzonenwechsel (UTC → Europe/Berlin) wirkte sofort, DNS-Anzeige zeigt
jetzt die echten IPv4-Server (1.1.1.1, 8.8.8.8) statt Stub/IPv6,
Dashboard-Filter/-Collapse und Logout-Redirect per Playwright bestätigt.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-12 13:30:56 +02:00
co-authored by Claude Sonnet 5
parent d525ef2e0a
commit 7b30ebce7b
9 changed files with 714 additions and 93 deletions
+327 -18
View File
@@ -18,7 +18,7 @@ from cryptography.fernet import Fernet
from cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2HMAC
from cryptography.hazmat.primitives import hashes
from datetime import datetime
import base64, csv, ipaddress, shlex, shutil, socket, sqlite3, glob, json, os, re, secrets, subprocess, threading, time, traceback
import base64, csv, ipaddress, logging, shlex, shutil, socket, sqlite3, glob, json, os, re, secrets, subprocess, threading, time, traceback
import paramiko
import yaml
@@ -74,11 +74,38 @@ BASE_DIR = os.path.dirname(os.path.abspath(__file__))
# ---------------------------------------------------------------------------
DB_PATH = os.environ.get("POE_DB_PATH", os.path.join(BASE_DIR, "sqlite.db"))
POE_SCRIPT = os.environ.get("POE_SCRIPT", "/usr/local/bin/custom/poe.sh")
LOG_GLOB = os.environ.get("POE_LOG_GLOB", "/var/log/rpi-*.log")
FERNET_KEY_PATH = os.environ.get("POE_FERNET_KEY", os.path.join(BASE_DIR, "fernet.key"))
SSH_KNOWN_HOSTS_PATH = os.environ.get("POE_KNOWN_HOSTS", os.path.join(BASE_DIR, "known_hosts"))
SECRET_KEY_PATH = os.environ.get("POE_SECRET_KEY_FILE", os.path.join(BASE_DIR, "secret.key"))
AVATAR_DIR = os.path.join(BASE_DIR, "static", "uploads", "avatars")
# Alle App-eigenen Logs leben unter einem gemeinsamen Verzeichnis statt
# verstreut in /var/log — vier getrennte Dateien, damit Live-Check,
# Änderungen, App-Fehler und Kea-DHCP sich nicht gegenseitig zutexten.
# Das Verzeichnis wird bewusst weltweit beschreibbar angelegt (siehe unten):
# poe_web.service/poe.sh laufen als root, kea-dhcp4-server dagegen i.d.R.
# als eigener, unprivilegierter Systembenutzer (_kea) — beide müssen in
# dasselbe Verzeichnis schreiben können, ohne dass wir hier exakt den
# richtigen UID/GID-Abgleich nachbilden müssen.
POE_LOG_DIR = os.environ.get("POE_LOG_DIR", "/var/log/poe-manager")
POE_LIVE_LOG_PATH = os.path.join(POE_LOG_DIR, "live.log")
POE_CHANGES_LOG_PATH = os.path.join(POE_LOG_DIR, "changes.log")
POE_APP_LOG_PATH = os.path.join(POE_LOG_DIR, "app.log")
# NICHT unter POE_LOG_DIR: kea-dhcp4s AppArmor-Profil
# (/etc/apparmor.d/usr.sbin.kea-dhcp4) erlaubt Schreibzugriff exklusiv auf
# genau diesen Pfad (Dateiname hart einprogrammiert, kein Verzeichnis-
# Wildcard) — ein anderer Pfad wird mit "DENIED ... mknod ...
# kea-dhcp.log.lock" abgelehnt (live so reproduziert). Bewusst NICHT das
# AppArmor-Profil aufgeweicht, um die Sandbox eines Systemdienstes auf
# einem geteilten Host nicht zu schwächen — die App arbeitet stattdessen
# mit Keas eigenem, bereits erlaubtem Pfad.
POE_KEA_LOG_PATH = os.environ.get("POE_KEA_LOG_PATH", "/var/log/kea/kea-dhcp4.log")
LOGROTATE_CONFIG_PATH = os.environ.get("POE_LOGROTATE_CONFIG", "/etc/logrotate.d/poe-manager")
try:
os.makedirs(POE_LOG_DIR, exist_ok=True)
os.chmod(POE_LOG_DIR, 0o777)
except OSError:
pass
# Wird verwendet, wenn ein Switch keinen eigenen ssh_port hinterlegt hat
# (Feld leer gelassen) — deckt den Standardfall ab, ohne dass er überall
# explizit eingetragen werden muss.
@@ -103,6 +130,20 @@ os.makedirs(AVATAR_DIR, exist_ok=True)
app = Flask(__name__)
# Eigene, rotierte Datei für App-seitige Fehler/Warnungen ("Syslog"-Äquivalent
# der App) — vorher landete das nur im journalctl-Output von poe_web.service,
# ohne eigene, von der App aus kontrollierbare Aufbewahrung. Bewusst nur
# WARNING und höher (kein INFO/Request-Rauschen) — reine Fehlerdiagnose,
# kein Access-Log.
try:
_app_log_handler = logging.FileHandler(POE_APP_LOG_PATH, encoding="utf-8")
_app_log_handler.setLevel(logging.WARNING)
_app_log_handler.setFormatter(logging.Formatter("%(asctime)s %(levelname)s %(message)s", "%Y-%m-%d %H:%M:%S"))
app.logger.addHandler(_app_log_handler)
app.logger.setLevel(logging.WARNING)
except OSError:
pass
def _load_or_create_secret() -> str:
env_secret = os.environ.get("POE_SECRET_KEY")
@@ -215,6 +256,7 @@ PERMISSIONS = {
"children": {
"logs_live": {"label": "Live", "rows": {"view": "logs_live.view"}},
"logs_activity": {"label": "Änderungen", "rows": {"view": "logs_activity.view"}},
"logs_kea": {"label": "Kea-DHCP", "rows": {"view": "logs_kea.view"}},
},
},
"settings_group": {
@@ -272,6 +314,7 @@ PERMISSION_LABELS = {
"maintenance.view": "Wartungsseite ansehen (Geräte-Status, Job-Verlauf)",
"maintenance.edit": "Wartungsaktionen ausführen (SSH-Update, SSH-Neustart)",
"logs_live.view": "Live-Log lesen", "logs_activity.view": "Änderungslog lesen",
"logs_kea.view": "Kea-DHCP-Log lesen",
"users.view": "Benutzer lesen", "users.create": "Benutzer anlegen",
"users.edit": "Benutzer ändern (inkl. Löschen)",
"groups.view": "Gruppen lesen", "groups.create": "Gruppen anlegen",
@@ -342,6 +385,7 @@ NAV_ITEMS = [
{"key": "logs_group", "label": "Logs", "icon": "terminal", "children": [
{"key": "logs_live", "label": "Live", "icon": "terminal", "endpoint": "logs"},
{"key": "logs_activity", "label": "Änderungen", "icon": "history", "endpoint": "activity_log"},
{"key": "logs_kea", "label": "Kea-DHCP", "icon": "network", "endpoint": "kea_log"},
]},
]
DEFAULT_NAV_ORDER = [item["key"] for item in NAV_ITEMS]
@@ -373,6 +417,8 @@ def _nav_key_visible(key, user):
return user.can_view_live_log
if key == "logs_activity":
return user.can_view_activity_log
if key == "logs_kea":
return user.can_view_kea_log
return False
@@ -500,6 +546,10 @@ class User(UserMixin):
def can_view_activity_log(self):
return self.has_permission("logs_activity.view")
@property
def can_view_kea_log(self):
return self.has_permission("logs_kea.view")
@property
def can_manage_users(self):
return self.has_any_permission(
@@ -993,6 +1043,23 @@ def _ensure_schema():
"INSERT OR IGNORE INTO settings (key, value) VALUES (?, '1')", (_migration_key_v2,)
)
# Einmalige Aufräum-Migration: die alten, bei jedem Dienst-Neustart neu
# angelegten rpi-<timestamp>.log-Dateien unter /var/log sind seit der
# Umstellung auf eine einzige, per logrotate rotierte Datei
# (POE_LIVE_LOG_PATH unter /var/log/poe-manager/) verwaist — poe.sh
# räumt sie nicht mehr selbst auf (das war vorher sein eigener
# 30-Tage-`find -delete`), und die neue logrotate-Konfiguration
# überwacht ein anderes Verzeichnis. Ohne diese Migration blieben sie
# für immer liegen.
_migration_key_old_logs = "_cleaned_up_legacy_rpi_log_files_v1"
if not conn.execute("SELECT 1 FROM settings WHERE key=?", (_migration_key_old_logs,)).fetchone():
for stale_log in glob.glob("/var/log/rpi-*.log"):
try:
os.remove(stale_log)
except OSError:
pass
conn.execute("INSERT OR IGNORE INTO settings (key, value) VALUES (?, '1')", (_migration_key_old_logs,))
# Standardgruppe 'Benutzer' — SYSTEMGRUPPE: Name und Rechte sind fix und
# werden bei jedem Start auf den vorgegebenen Satz zurückgesetzt (nicht
# nur bei Erstanlage). Das ist unbedenklich, weil is_system die Gruppe in
@@ -1062,17 +1129,39 @@ def _build_user(row):
)
def _append_changes_log(ts, who, action, target, details):
"""Spiegelt jeden Änderungslog-Eintrag zusätzlich in eine eigene, per
logrotate rotierte Datei (POE_CHANGES_LOG_PATH) — die audit_log-Tabelle
bleibt die Quelle für die durchsuchbare/sortierbare Seite unter
Logs → Änderungen, wächst aber unbegrenzt weiter; die Datei bekommt
dieselbe wöchentliche Rotation/Aufbewahrung wie die anderen drei Logs.
Ein Schreibfehler hier darf den eigentlichen, DB-basierten Audit-Trail
nicht gefährden — daher bewusst best-effort mit breitem except."""
try:
line = f"{ts} [{who}] {action}"
if target:
line += f"{target}"
if details:
line += f": {details}"
with open(POE_CHANGES_LOG_PATH, "a", encoding="utf-8") as f:
f.write(line + "\n")
except OSError:
pass
def log_action(action, target=None, details=None):
"""Schreibt einen Eintrag ins Änderungsverlauf-Log. Absichtlich NICHT für
PoE-Neustarts genutzt (die stehen bereits im Live-Log von poe.sh)."""
who = current_user.username if current_user.is_authenticated else "system"
ts = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
conn = get_db_connection()
conn.execute(
"INSERT INTO audit_log (ts, username, action, target, details) VALUES (?, ?, ?, ?, ?)",
(datetime.now().strftime("%Y-%m-%d %H:%M:%S"), who, action, target, details),
(ts, who, action, target, details),
)
conn.commit()
conn.close()
_append_changes_log(ts, who, action, target, details)
def log_action_system(action, target=None, details=None):
@@ -1080,13 +1169,15 @@ def log_action_system(action, target=None, details=None):
Code, der außerhalb eines Request-Kontexts läuft (z.B. der
Netzwerk-Auto-Revert-Timer in einem eigenen Thread, wo current_user
nicht auflösbar ist und einen AttributeError werfen würde)."""
ts = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
conn = get_db_connection()
conn.execute(
"INSERT INTO audit_log (ts, username, action, target, details) VALUES (?, ?, ?, ?, ?)",
(datetime.now().strftime("%Y-%m-%d %H:%M:%S"), "system", action, target, details),
(ts, "system", action, target, details),
)
conn.commit()
conn.close()
_append_changes_log(ts, "system", action, target, details)
def touch_record(conn, table, key_col, key_val):
@@ -1134,6 +1225,55 @@ def set_setting(key, value):
conn.close()
LOG_ROTATION_INTERVALS = {"daily", "weekly", "monthly"}
LOG_ROTATION_DEFAULT_INTERVAL = "weekly"
LOG_ROTATION_DEFAULT_KEEP = 4
def _write_logrotate_config():
"""Schreibt/aktualisiert die logrotate-Konfiguration für alle vier
App-Logs (Live, Änderungen, App unter POE_LOG_DIR; Kea-DHCP separat
unter POE_KEA_LOG_PATH — Keas AppArmor-Profil erlaubt nur genau diesen
einen Pfad, siehe Kommentar bei POE_KEA_LOG_PATH) anhand der aktuellen
Einstellungen (Systemeinstellungen → Logs). Läuft sowohl beim App-Start
(Rotation ist dadurch auch ohne einen expliziten Besuch der
Settings-Seite von Anfang an aktiv) als auch nach jedem Speichern dort.
"copytruncate" statt Signal-/Neustart-basierter Rotation, da keiner der
drei schreibenden Prozesse (poe.sh, Flask, kea-dhcp4-server) ein
Log-Reopen-Signal (SIGHUP o.ä.) implementiert — copytruncate
funktioniert ohne jede Kooperation des schreibenden Prozesses (Kea
eingeschlossen: logrotate läuft als root, unterliegt also nicht Keas
eigenem AppArmor-Profil)."""
interval = get_setting("log_rotation_interval", LOG_ROTATION_DEFAULT_INTERVAL)
if interval not in LOG_ROTATION_INTERVALS:
interval = LOG_ROTATION_DEFAULT_INTERVAL
try:
keep = int(get_setting("log_rotation_keep", LOG_ROTATION_DEFAULT_KEEP))
if keep < 1:
keep = LOG_ROTATION_DEFAULT_KEEP
except (TypeError, ValueError):
keep = LOG_ROTATION_DEFAULT_KEEP
content = (
f"{POE_LOG_DIR}/*.log {POE_KEA_LOG_PATH} {{\n"
f" {interval}\n"
f" rotate {keep}\n"
f" missingok\n"
f" notifempty\n"
f" copytruncate\n"
f"}}\n"
)
try:
with open(LOGROTATE_CONFIG_PATH, "w", encoding="utf-8") as f:
f.write(content)
return True
except OSError:
return False
_write_logrotate_config()
@login_manager.user_loader
def load_user(user_id):
conn = get_db_connection()
@@ -1167,7 +1307,11 @@ def login():
@login_required
def logout():
logout_user()
return redirect(url_for("login"))
# Landet auf dem (auch ohne Login erreichbaren) Dashboard statt auf
# /login -- wer sich abmeldet, will i.d.R. trotzdem noch den
# Online/Offline-Status sehen können, ohne sich extra erneut einloggen
# zu müssen, nur um zur Übersicht zu gelangen.
return redirect(url_for("index"))
# ---------------------------------------------------------------------------
@@ -1253,21 +1397,21 @@ def profile():
# ---------------------------------------------------------------------------
def _latest_log_file():
"""Neuestes Logfile ermitteln. Bewusst über den Dateinamen sortiert
(rpi-YYYYMMDDHHMMSS.log ist lexikografisch = chronologisch sortierbar),
NICHT über Dateisystem-Metadaten wie ctime/mtime — die sind je nach
Dateisystem (z.B. unter WSL2 auf einem gemounteten Windows-Laufwerk)
unzuverlässig und haben in der Praxis nicht immer das tatsächlich
zuletzt geschriebene Logfile geliefert."""
log_files = glob.glob(LOG_GLOB)
if not log_files:
return None
return max(log_files)
"""Aktuelles Live-Log — seit der Umstellung auf eine einzige, per
logrotate rotierte Datei (statt vorher bei jedem Dienst-Neustart ein
neues rpi-<timestamp>.log) gibt es nur noch GENAU eine "aktuelle"
Datei, kein Ermitteln der "neuesten von vielen" mehr nötig."""
return POE_LIVE_LOG_PATH if os.path.exists(POE_LIVE_LOG_PATH) else None
def get_last_seen(dev_name: str):
"""Letzter Zeitpunkt, zu dem ein Gerät laut Logs erreichbar war."""
log_files = glob.glob(LOG_GLOB)
"""Letzter Zeitpunkt, zu dem ein Gerät laut Logs erreichbar war. Sucht
bewusst auch in den von logrotate rotierten Kopien (live.log.1, .2, …),
nicht nur in der aktuellen Datei — sonst würde nach einer Rotation der
"zuletzt online"-Zeitpunkt eines länger schon offline befindlichen
Geräts verloren gehen, obwohl er in einer älteren Rotation noch
vorhanden wäre."""
log_files = glob.glob(POE_LIVE_LOG_PATH + "*")
if not log_files:
return None
@@ -1563,6 +1707,9 @@ def _detect_network_backend():
return "unknown"
_RESOLVED_STUB_ADDRESSES = {"127.0.0.53", "127.0.0.1", "::1"}
def _read_resolv_conf_dns():
try:
with open("/etc/resolv.conf", encoding="utf-8") as f:
@@ -1571,6 +1718,49 @@ def _read_resolv_conf_dns():
return []
def _read_configured_dns(interface):
"""Die tatsächlich konfigurierten (Upstream-)DNS-Server statt des
lokalen systemd-resolved-Stub-Resolvers: /etc/resolv.conf zeigt unter
systemd-resolved (Standard auf aktuellem Ubuntu) immer nur "nameserver
127.0.0.53" — die echten Server stehen stattdessen pro Link in
"resolvectl dns <interface>". Fällt auf resolv.conf zurück, wenn
resolvectl nicht verfügbar ist (z.B. anderes DNS-Setup ohne
systemd-resolved); 127.0.0.53/127.0.0.1/::1 werden in beiden Fällen
herausgefiltert, da sie nie ein sinnvoller "tatsächlicher" Server sind."""
servers = []
if shutil.which("resolvectl"):
ok, out = _dhcp_run_privileged(["resolvectl", "dns", interface], timeout=5)
if ok:
# Nur die erste Zeile trägt das "Link N (iface):"-Label; bei
# mehr Servern, als auf eine Zeile passen, folgen reine
# Fortsetzungszeilen OHNE Label -- ein naives split(":", 1) auf
# JEDER Zeile würde bei einer IPv6-Fortsetzungszeile (die selbst
# Doppelpunkte enthält) fälschlich deren Anfang abschneiden
# (live reproduziert: "2003:fe:7714:..." wurde zu "fe:7714:...").
for line in out.splitlines():
stripped = line.strip()
if not stripped:
continue
m = re.match(r"^Link \d+ \([^)]*\):\s*(.*)$", stripped)
servers.extend((m.group(1) if m else stripped).split())
if not servers:
servers = _read_resolv_conf_dns()
servers = [s for s in servers if s not in _RESOLVED_STUB_ADDRESSES]
# Nur IPv4: diese Seite verwaltet ausschließlich die IPv4-Konfiguration
# des Hosts (IP-Adresse/Prefix/Gateway sind alles IPv4-Felder) — per
# IPv6-Router-Advertisement gelernte DNS-Server (die resolvectl
# ungefragt mit auflistet) gehören nicht zu dieser Konfiguration und
# wirken hier nur verwirrend/falsch neben den IPv4-Werten.
ipv4_servers = []
for s in servers:
try:
ipaddress.IPv4Address(s)
except ValueError:
continue
ipv4_servers.append(s)
return ipv4_servers
def _read_network_state(interface, backend):
"""Aktueller Ist-Zustand — IP/Prefix/Gateway aus _detect_interface_network
(bereits für die DHCP-Server-Funktion gebaut), DNS aus resolv.conf,
@@ -1609,7 +1799,7 @@ def _read_network_state(interface, backend):
# /etc/netplan/ zu parsen (mehrere Dateien können sich überlagern).
if net_info.get("ok"):
mode = "dhcp" if net_info.get("dynamic") else "static"
return {**net_info, "dns": _read_resolv_conf_dns(), "mode": mode, "backend": backend}
return {**net_info, "dns": _read_configured_dns(interface), "mode": mode, "backend": backend}
def _backup_network_config(interface, backend):
@@ -1858,6 +2048,25 @@ def settings():
else:
flash(f"Hostname konnte nicht gesetzt werden: {out}", "danger")
elif "timezone" in request.form:
new_tz = request.form.get("timezone", "").strip()
if new_tz not in _list_timezones():
flash("Ungültige Zeitzone.", "danger")
else:
ok, out = _dhcp_run_privileged(["timedatectl", "set-timezone", new_tz], timeout=10)
if ok:
# Ohne tzset() würde diese bereits laufende poe_web.service-
# Instanz die neue Zeitzone für datetime.now() (Änderungslog,
# App-Log) erst nach einem Neustart übernehmen -- glibc
# cached die Zeitzone sonst ab dem ersten Zugriff im Prozess.
# poe.sh/date liest sie dagegen ohnehin bei jedem Aufruf frisch,
# da es ein neuer Prozess pro Durchlauf ist.
time.tzset()
log_action("settings.update", "Zeitzone", new_tz)
flash(f"Zeitzone auf „{new_tz}“ gesetzt.", "success")
else:
flash(f"Zeitzone konnte nicht gesetzt werden: {out}", "danger")
elif "confirm_network" in request.form:
token = request.form.get("confirm_network")
entry = _pending_network_revert.pop(token, None)
@@ -1868,6 +2077,22 @@ def settings():
else:
flash("Keine ausstehende Bestätigung gefunden (evtl. bereits abgelaufen).", "danger")
elif "save_log_rotation" in request.form:
new_log_interval = request.form.get("log_rotation_interval", "")
new_log_keep_raw = request.form.get("log_rotation_keep", "")
if new_log_interval not in LOG_ROTATION_INTERVALS:
flash("Ungültiges Rotations-Intervall.", "danger")
elif not new_log_keep_raw.isdigit() or int(new_log_keep_raw) < 1:
flash("Aufbewahrung muss eine Zahl ≥ 1 sein.", "danger")
else:
set_setting("log_rotation_interval", new_log_interval)
set_setting("log_rotation_keep", int(new_log_keep_raw))
if _write_logrotate_config():
log_action("settings.update", "Log-Rotation", f"{new_log_interval}, {new_log_keep_raw} Rotationen")
flash(f"Log-Rotation gespeichert: {new_log_interval}, {new_log_keep_raw} Rotationen aufbewahrt.", "success")
else:
flash(f"Einstellung gespeichert, aber {LOGROTATE_CONFIG_PATH} konnte nicht geschrieben werden.", "danger")
return redirect(url_for("settings"))
net_backend = _detect_network_backend()
@@ -1888,6 +2113,16 @@ def settings():
net_state=net_state,
net_revert_seconds=NETWORK_REVERT_SECONDS,
pending_network_token=pending_token,
log_rotation_interval=get_setting("log_rotation_interval", LOG_ROTATION_DEFAULT_INTERVAL),
log_rotation_keep=get_setting("log_rotation_keep", LOG_ROTATION_DEFAULT_KEEP),
log_paths={
"live": POE_LIVE_LOG_PATH,
"changes": POE_CHANGES_LOG_PATH,
"app": POE_APP_LOG_PATH,
"kea": POE_KEA_LOG_PATH,
},
current_timezone=_current_timezone(),
timezones=_list_timezones(),
)
@@ -2373,6 +2608,26 @@ def _render_kea_config(cfg, valid_subnets, reservations_by_subnet, option_defs,
"option-def": option_def_list,
"option-data": global_option_data,
"subnet4": subnet4,
# Eigene Log-Datei statt Keas Distributions-Default (meist
# Syslog) — damit sie sich zusammen mit den anderen drei
# App-Logs (Live, Änderungen, App) über dieselbe
# logrotate-Konfiguration verwalten lässt. Bewusst OHNE
# maxsize/maxver (Keas eigene, größenbasierte Rotation) — die
# externe, zeitbasierte logrotate-Rotation (siehe
# _write_logrotate_config) soll die einzige Quelle für alle
# vier Logs sein, nicht zwei parallele, unterschiedliche
# Schemata. "loggers" gehört NACH Live-Test gegen eine echte
# Kea-Instanz zwingend UNTER "Dhcp4" -- ein Top-Level-
# "Logging"-Geschwisterobjekt (wie in älterer/anderer
# Dokumentation zu finden) lehnt Kea 2.4.1 mit einem
# Syntax-Fehler ab.
"loggers": [
{
"name": "kea-dhcp4",
"output_options": [{"output": POE_KEA_LOG_PATH}],
"severity": "INFO",
}
],
}
}
@@ -2388,6 +2643,21 @@ def _dhcp_run_privileged(args, timeout):
return False, str(e)
def _list_timezones():
"""Alle von diesem System unterstützten IANA-Zeitzonen fürs
Auswahl-Dropdown (Systemeinstellungen → Zeitzone) — timedatectl selbst
validiert beim tatsächlichen Setzen ohnehin noch einmal serverseitig."""
ok, out = _dhcp_run_privileged(["timedatectl", "list-timezones"], timeout=10)
if not ok:
return []
return [line.strip() for line in out.splitlines() if line.strip()]
def _current_timezone():
ok, out = _dhcp_run_privileged(["timedatectl", "show", "--property=Timezone", "--value"], timeout=5)
return out.strip() if ok else None
@app.route("/settings/dhcp", methods=["GET", "POST"])
@login_required
def settings_dhcp():
@@ -4101,6 +4371,45 @@ def logs():
)
@app.route("/get_kea_log")
@login_required
def get_kea_log():
if not current_user.can_view_kea_log:
return "Keine Berechtigung.", 403
try:
with open(POE_KEA_LOG_PATH, "r", encoding="utf-8", errors="replace") as f:
content = f.read()
except FileNotFoundError:
content = "Noch kein Kea-Log vorhanden — der Dienst muss dafür mindestens einmal mit der aktuellen, " \
"über \"In Datei schreiben\" erzeugten Konfiguration gelaufen sein."
except OSError as e:
content = f"Fehler beim Lesen des Logs: {e}"
return app.response_class(content, mimetype="text/plain")
@app.route("/logs/kea")
@login_required
def kea_log():
"""Eigene Log-Seite für den Kea-DHCP-Dienst, konsistent mit Live-Log/
Änderungen — die Konfiguration (siehe _render_kea_config) leitet Keas
eigene Logging-Ausgabe explizit in POE_KEA_LOG_PATH statt in den
distributionsabhängigen Default (meist Syslog), damit sie sich hier
genauso wie die anderen Logs einsehen und per logrotate rotieren
lässt."""
if not current_user.can_view_kea_log:
flash("Keine Berechtigung, das Kea-DHCP-Log einzusehen.", "danger")
return redirect(url_for("index"))
try:
with open(POE_KEA_LOG_PATH, "r", encoding="utf-8", errors="replace") as f:
log_content = f.read()
except FileNotFoundError:
log_content = "Noch kein Kea-Log vorhanden — der Dienst muss dafür mindestens einmal mit der aktuellen, " \
"über \"In Datei schreiben\" erzeugten Konfiguration gelaufen sein."
except OSError as e:
log_content = f"Fehler beim Lesen des Logs: {e}"
return render_template("logs_kea.html", log_content=log_content)
@app.route("/logs/aenderungen")
@login_required
def activity_log():