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:
+327
-18
@@ -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():
|
||||
|
||||
Reference in New Issue
Block a user