Files
tesm/srv/tesm/app.py
T
alientim 93bcf4f6b6 DHCP: Auto-Write+Neustart nach Änderungen, Lease löschen, Dedup, Autostart-Slider
- Automatisches Schreiben+Neustart: jede relevante Änderung (Reservierung,
  Option, Subnetz, globale Einstellung) ruft jetzt _dhcp_write_and_restart()
  direkt nach dem Speichern auf -- schreibt die Kea-Config neu und startet
  den Dienst neu, FALLS er gerade aktiv ist (kein ungefragtes Hochfahren
  eines bewusst gestoppten Dienstes). Ein manuelles "Datei schreiben" +
  "Neu starten" kann dadurch nicht mehr vergessen werden. Der bisherige
  "Speichern"-Button (write_dhcp_file) restartet jetzt ebenfalls
  automatisch statt nur zu schreiben.
- Leases-Übersicht dedupliziert: Keas memfile-Backend hängt bei jeder
  Verlängerung eine neue Zeile an, statt die alte zu ersetzen -- dieselbe
  Adresse stand oft dutzendfach in der CSV. _dhcp_read_leases() zeigt jetzt
  nur noch die jeweils neueste (per "expire") Zeile pro IP.
- Einzelne Leases löschen (neuer Button + Route): entfernt alle Zeilen
  einer Adresse aus der Lease-CSV und startet den Dienst neu, damit Kea sie
  beim Neuladen nicht aus dem Speicher zurückschreibt.
- Eigenständiger Autostart-Slider (wie beim Aktivieren/Deaktivieren von
  Clients) -- steuert nur systemctl enable/disable, entkoppelt vom
  aktuellen Start/Stopp-Zustand.
- KRITISCH, live gefunden und behoben: die erste Version von
  _dhcp_delete_lease() hat Kea beim Neustart zum Absturz gebracht --
  Pythons csv-Modul schreibt standardmäßig "\r\n"-Zeilenenden, Kea erwartet
  aber reines "\n" und lehnte die Datei mit "invalid column: 'pool_id^M'"
  komplett ab. Zusätzlich hätte das Neuanlegen der Datei als root (der
  App-Prozess) sie root:root statt dem Kea-Dienstnutzer gehören lassen,
  wodurch Kea keine neuen Leases mehr hätte schreiben können. Beides
  gefixt (lineterminator="\n", Eigentümer/Rechte der Originaldatei nach dem
  Schreiben wiederhergestellt) und auf POETEST bestätigt: Dienst übersteht
  den Neustart jetzt, Datei bleibt _kea:_kea, keine ^M mehr.
2026-08-13 19:11:34 +02:00

7010 lines
327 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env python3
"""
TESM (TimEShepManager) — Web-App zur Überwachung und Steuerung von
PoE-Devices über Aruba-Switche.
Funktional identisch zur Ursprungsversion (Login, Dashboard, Device-/
Switch-/User-Verwaltung, Live-Log, Settings, manueller PoE-Neustart),
lediglich mit modernisiertem Frontend und aufgeräumten/konfigurierbaren
Pfaden im Backend.
"""
from flask import Flask, render_template, request, redirect, url_for, flash, jsonify
from flask_login import LoginManager, login_user, login_required, logout_user, UserMixin, current_user
from flask_bcrypt import Bcrypt
from flask_sock import Sock
from simple_websocket.errors import ConnectionClosed
from werkzeug.utils import secure_filename
from cryptography.fernet import Fernet
from cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2HMAC
from cryptography.hazmat.primitives import hashes
from datetime import datetime, timedelta
import base64, csv, ipaddress, logging, shlex, shutil, socket, sqlite3, glob, json, os, re, secrets, stat, subprocess, threading, time, traceback
import paramiko
import yaml
import ssl
from ldap3 import Server, Connection, Tls, ALL, SIMPLE, BASE
from ldap3.core.exceptions import LDAPException
from ldap3.utils.conv import escape_filter_chars
# LDAP_MATCHING_RULE_IN_CHAIN — Microsofts OID für rekursive Gruppen-
# mitgliedschaft (löst auch verschachtelte Gruppen auf), statt nur das
# flache, nicht-rekursive memberOf-Attribut eines Benutzers zu lesen.
LDAP_MATCHING_RULE_IN_CHAIN = "1.2.840.113556.1.4.1941"
class _SendAllSocketProxy:
"""Leitet alles an das echte Socket weiter, außer .send() -> .sendall()."""
def __init__(self, sock):
object.__setattr__(self, "_sock", sock)
def send(self, data):
return self._sock.sendall(data)
def __getattr__(self, name):
return getattr(self._sock, name)
def _patch_simple_websocket_partial_writes():
"""
simple-websocket (genutzt von Flask-Sock) schreibt WebSocket-Frames per
`socket.send()`, das laut Doku aber nur *irgendeine* Anzahl Bytes
schreiben muss — nicht zwingend alle. Folgen zwei Frames sehr schnell
aufeinander (z.B. eine Nachricht direkt gefolgt vom Schließen der
Verbindung, wie beim SSH-Terminal), kann ein unvollständig geschriebenes
Frame den Frame-Stream für den Client zerstören ("Invalid frame header").
Patch: das Socket-Objekt jeder Verbindung durch einen Proxy ersetzen,
dessen .send() intern .sendall() aufruft. Rein additiv/defensiv — falls
die Bibliothek ihre interne Struktur ändert, wird der Patch übersprungen
statt den Start zu blockieren.
"""
try:
from simple_websocket.ws import Base
original_init = Base.__init__
def patched_init(self, *args, **kwargs):
original_init(self, *args, **kwargs)
if self.sock is not None and not isinstance(self.sock, _SendAllSocketProxy):
self.sock = _SendAllSocketProxy(self.sock)
Base.__init__ = patched_init
except Exception:
pass
_patch_simple_websocket_partial_writes()
BASE_DIR = os.path.dirname(os.path.abspath(__file__))
# ---------------------------------------------------------------------------
# Konfiguration (per Umgebungsvariable überschreibbar, sinnvolle Defaults
# passend zum produktiven Deployment unter /srv/tesm)
# ---------------------------------------------------------------------------
DB_PATH = os.environ.get("TESM_DB_PATH", os.path.join(BASE_DIR, "sqlite.db"))
POE_SCRIPT = os.environ.get("POE_SCRIPT", "/usr/local/bin/custom/poe.sh")
FERNET_KEY_PATH = os.environ.get("TESM_FERNET_KEY", os.path.join(BASE_DIR, "fernet.key"))
SSH_KNOWN_HOSTS_PATH = os.environ.get("TESM_KNOWN_HOSTS", os.path.join(BASE_DIR, "known_hosts"))
SECRET_KEY_PATH = os.environ.get("TESM_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):
# tesm.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.
TESM_LOG_DIR = os.environ.get("TESM_LOG_DIR", "/var/log/tesm")
TESM_LIVE_LOG_PATH = os.path.join(TESM_LOG_DIR, "live.log")
TESM_CHANGES_LOG_PATH = os.path.join(TESM_LOG_DIR, "changes.log")
TESM_APP_LOG_PATH = os.path.join(TESM_LOG_DIR, "app.log")
# NICHT unter TESM_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.
TESM_KEA_LOG_PATH = os.environ.get("TESM_KEA_LOG_PATH", "/var/log/kea/kea-dhcp4.log")
LOGROTATE_CONFIG_PATH = os.environ.get("TESM_LOGROTATE_CONFIG", "/etc/logrotate.d/tesm")
try:
os.makedirs(TESM_LOG_DIR, exist_ok=True)
os.chmod(TESM_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.
SWITCH_DEFAULT_SSH_PORT = 22
ALLOWED_AVATAR_EXT = {"png", "jpg", "jpeg", "gif", "webp"}
# Zugangsdaten-Kategorien — legt der Admin explizit fest (siehe
# _ensure_schema()-Kommentar für die Begründung gegen automatische
# Ping-basierte Erkennung). Nur "linux" ist aktuell für eine Aktion nutzbar
# (SSH-Update unter "Wartung"); die anderen sind reine Einordnung, z.B. für
# spätere Erweiterungen (Windows/PowerShell folgt separat).
CREDENTIAL_CATEGORIES = [
("switch", "Switch"),
("linux", "Linux-Client"),
("windows", "Windows-Client"),
("router", "Router/Sonstiges"),
]
CREDENTIAL_CATEGORY_KEYS = {c[0] for c in CREDENTIAL_CATEGORIES}
# Kategorie, für die die SSH-Update-Aktion unter "Wartung" angeboten wird.
DEVICE_MAINTENANCE_CATEGORY = "linux"
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 tesm.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(TESM_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("TESM_SECRET_KEY")
if env_secret:
return env_secret
if os.path.exists(SECRET_KEY_PATH):
with open(SECRET_KEY_PATH, "r") as f:
return f.read().strip()
new_secret = secrets.token_hex(32)
with open(SECRET_KEY_PATH, "w") as f:
f.write(new_secret)
return new_secret
def _load_or_create_fernet() -> Fernet:
if os.path.exists(FERNET_KEY_PATH):
with open(FERNET_KEY_PATH, "rb") as f:
key = f.read()
else:
key = Fernet.generate_key()
with open(FERNET_KEY_PATH, "wb") as f:
f.write(key)
return Fernet(key)
app.secret_key = _load_or_create_secret()
bcrypt = Bcrypt(app)
fernet = _load_or_create_fernet()
sock = Sock(app)
login_manager = LoginManager()
login_manager.login_view = "login"
login_manager.init_app(app)
# ---------------------------------------------------------------------------
# Rechtesystem (Gruppen): Admins (is_admin=1) dürfen immer alles — inkl.
# Benutzer-/Gruppen-/Settingsverwaltung, die es nur für Admins gibt. Für
# normale Benutzer lassen sich einzelne Verwaltungsrechte über Gruppen
# gezielt vergeben, ohne sie zu Admins zu machen.
# ---------------------------------------------------------------------------
# Zweistufig verschachtelt, gespiegelt an der Navbar-Struktur: jeder
# Top-Level-Bereich (Geräte/Logs/Einstellungen) hat ein eigenes "Bereich
# anzeigen"-Recht (view_key) und darunter mehrere Unterpunkte (Spalten in der
# Gruppen-Tabelle). Jeder Unterpunkt liefert "rows": eine Teilmenge der vier
# kanonischen Rechte-Zeilen (view/create/edit/delete = L/S/Ä/D, siehe
# PERMISSION_ROW_TYPES) — fehlt eine Zeile für einen Unterpunkt (z.B.
# "delete" bei Systemeinstellungen), wird die Zelle in der Tabelle grau und
# nicht setzbar dargestellt statt einfach zu fehlen, damit alle Spalten
# eines Bereichs bündig nebeneinander in einer kompakten Tabelle stehen.
PERMISSION_ROW_TYPES = [
("view", "R", "Lesen (Read)"),
("create", "W", "Schreiben/Anlegen (Write)"),
("edit", "E", "Ändern inkl. Löschen (Edit)"),
("extra", "D", "Dashboard-Neustart (nur bei Clients — kein Löschen-Recht)"),
("export", "X", "Export (nur bei Im-/Export — Export-Datei enthält Passwörter im Klartext)"),
]
PERMISSIONS = {
"devices_group": {
"label": "Geräte",
"view_key": "devices_group.view",
"children": {
"devices": {
"label": "Clients",
# "Ändern" deckt Löschen überall mit ab, auch hier — es gibt
# nirgends ein eigenes Löschen-Recht. Die vierte Spalte (D)
# ist bei Clients KEIN Löschen, sondern das Recht, einen
# PoE-Neustart über das Dashboard-Popup auszulösen
# (automatische Neustarts bei Ausfall bleiben davon unberührt).
"rows": {
"view": "devices.view",
"create": "devices.create",
"edit": "devices.edit",
"extra": "devices.restart",
},
},
"switches": {
"label": "Switche",
# Kein eigenes Löschen-Recht — "Ändern" deckt das mit ab.
"rows": {
"view": "switches.view",
"create": "switches.create",
"edit": "switches.edit",
},
},
"credentials": {
"label": "Zugangsdaten",
"rows": {
"view": "credentials.view",
"create": "credentials.create",
"edit": "credentials.edit",
},
},
"maintenance": {
"label": "Wartung",
# Kein eigenes Löschen — "Ändern" (E) steht hier für das
# Ausführen von Wartungsaktionen (SSH-Update, SSH-Neustart),
# nicht für Löschen. Getrennt von devices.restart (D oben):
# das ist ausschließlich der PoE-Neustart über den Switch.
"rows": {
"view": "maintenance.view",
"edit": "maintenance.edit",
},
},
},
},
"logs_group": {
"label": "Logs",
"view_key": "logs_group.view",
"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": {
"label": "Einstellungen",
"view_key": "settings_group.view",
"children": {
"users": {
"label": "Benutzer",
"rows": {
"view": "users.view",
"create": "users.create",
"edit": "users.edit",
},
},
"groups": {
"label": "Gruppen",
"rows": {
"view": "groups.view",
"create": "groups.create",
"edit": "groups.edit",
},
},
"settings_system": {
"label": "Systemeinstellungen",
"rows": {"view": "settings_system.view", "edit": "settings_system.edit"},
},
"settings_importexport": {
"label": "Im-/Export",
# R = Seite ansehen (wie überall). E = Import ausführen
# (verändert Bestand, passt zu "Ändern"). X = Export
# ausführen (eigene Zeile, NICHT an E gekoppelt) -- die
# Export-Datei enthält Passwörter im Klartext, daher bewusst
# als eigenständiges, separat vergebbares Recht.
"rows": {
"view": "settings_importexport.view",
"edit": "settings_importexport.edit",
"export": "settings_importexport.export",
},
},
"settings_dhcp": {
"label": "DHCP",
# L = Konfiguration/Status/Vorschau ansehen, Ä = Konfiguration
# speichern und die generierte Reservierungsdatei schreiben.
"rows": {"view": "settings_dhcp.view", "edit": "settings_dhcp.edit"},
},
"settings_ldap": {
"label": "LDAP",
"rows": {"view": "settings_ldap.view", "edit": "settings_ldap.edit"},
},
},
},
}
# Menschenlesbare Einzel-Labels je Recht (Tooltips auf den Checkboxen in der
# Tabelle) — aus PERMISSIONS abgeleitet, damit es nur eine Quelle gibt.
PERMISSION_LABELS = {
"devices_group.view": "Geräte-Bereich anzeigen",
"logs_group.view": "Logs-Bereich anzeigen",
"settings_group.view": "Einstellungen-Bereich anzeigen",
"devices.view": "Clients lesen", "devices.create": "Clients anlegen",
"devices.edit": "Clients ändern (inkl. Aktivieren/Deaktivieren, Löschen)",
"devices.restart": "PoE-Neustart über das Dashboard ausführen (automatische Neustarts bei Ausfall bleiben unberührt)",
"switches.view": "Switche lesen", "switches.create": "Switche anlegen",
"switches.edit": "Switche ändern (inkl. Löschen)",
"credentials.view": "Zugangsdaten lesen", "credentials.create": "Zugangsdaten anlegen",
"credentials.edit": "Zugangsdaten ändern (inkl. Löschen)",
"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",
"groups.edit": "Gruppen ändern (inkl. Löschen)",
"settings_system.view": "Systemeinstellungen lesen", "settings_system.edit": "Systemeinstellungen ändern",
"settings_importexport.view": "Im-/Export-Seite ansehen",
"settings_importexport.edit": "Daten importieren",
"settings_importexport.export": "Daten exportieren (Export-Datei enthält Passwörter im Klartext)",
"settings_dhcp.view": "DHCP-Status und -Konfiguration lesen",
"settings_dhcp.edit": "DHCP-Konfiguration speichern und Reservierungsdatei schreiben",
"settings_ldap.view": "LDAP/AD-Konfiguration lesen",
"settings_ldap.edit": "LDAP/AD-Konfiguration speichern (Bind-Konto, Gruppenzuordnungen)",
}
# Aus der verschachtelten Struktur abgeleitet: alle vergebbaren Keys, sowie
# eine Zuordnung jedes Kind-Rechts zum "Bereich anzeigen"-Recht seines
# Top-Level-Bereichs. Ist das übergeordnete Recht einer Gruppe nicht
# gesetzt, greift KEIN darunterliegendes Recht mehr — auch wenn es einzeln
# gewährt wurde (Kill-Switch, siehe User.has_permission). So lässt sich
# z.B. der gesamte "Geräte"-Bereich für eine Gruppe mit einem Klick sperren,
# ohne jedes Kind-Recht einzeln zurücknehmen zu müssen.
ALL_PERMISSION_KEYS = []
PERMISSION_PARENT_GROUP = {}
for _group in PERMISSIONS.values():
ALL_PERMISSION_KEYS.append(_group["view_key"])
for _child in _group["children"].values():
for _key in _child["rows"].values():
ALL_PERMISSION_KEYS.append(_key)
PERMISSION_PARENT_GROUP[_key] = _group["view_key"]
# Welche Spalten (R/W/E/D) für einen Bereich überhaupt vorkommen — z.B. hat
# nur "Geräte" (wegen Clients' Dashboard-Neustart) eine D-Spalte, Logs/
# Einstellungen brauchen sie gar nicht erst anzuzeigen. Auf der Gruppen-
# Seite bekommt so jeder Bereich nur die Spalten, die er tatsächlich nutzt,
# statt überall gleich viele (teils komplett ungenutzte) Spalten zu zeigen.
GROUP_ROW_TYPES = {}
for _group_key, _group in PERMISSIONS.items():
_used_row_keys = {"view"}
for _child in _group["children"].values():
_used_row_keys.update(_child["rows"].keys())
GROUP_ROW_TYPES[_group_key] = [rt for rt in PERMISSION_ROW_TYPES if rt[0] in _used_row_keys]
DEFAULT_GROUP_NAME = "Benutzer"
DEFAULT_GROUP_PERMISSIONS = [
"devices_group.view", "devices.view", "switches.view",
"logs_group.view", "logs_live.view",
]
# ---------------------------------------------------------------------------
# Navbar — die Reihenfolge der TOP-LEVEL-Punkte ist admin-konfigurierbar
# (siehe /account, gespeichert als JSON-Liste von Keys unter
# settings.nav_order); standardmäßig wird pro Benutzer nur angezeigt, wofür
# er auch tatsächlich eine Berechtigung hat — das gilt auch pro Unterpunkt
# innerhalb einer Gruppe: eine Gruppe erscheint nur, wenn mindestens ein
# Unterpunkt sichtbar ist, und zeigt dann auch nur die sichtbaren Unterpunkte.
# ---------------------------------------------------------------------------
NAV_ITEMS = [
{"key": "index", "label": "Dashboard", "icon": "grid", "endpoint": "index"},
{"key": "devices_group", "label": "Geräte", "icon": "cpu", "children": [
{"key": "devices", "label": "Clients", "icon": "cpu", "endpoint": "devices"},
{"key": "switches", "label": "Switche", "icon": "share", "endpoint": "switches"},
{"key": "credentials", "label": "Zugangsdaten", "icon": "key", "endpoint": "credentials"},
{"key": "maintenance", "label": "Wartung", "icon": "wrench", "endpoint": "maintenance"},
{"key": "papierkorb", "label": "Papierkorb", "icon": "trash", "endpoint": "papierkorb"},
]},
{"key": "users", "label": "Benutzer", "icon": "users", "endpoint": "users"},
{"key": "groups", "label": "Gruppen", "icon": "groups", "endpoint": "groups"},
{"key": "settings_group", "label": "Einstellungen", "icon": "sliders", "children": [
{"key": "settings_system", "label": "Systemeinstellungen", "icon": "sliders", "endpoint": "settings"},
{"key": "settings_ldap", "label": "LDAP", "icon": "users", "endpoint": "settings_ldap"},
{"key": "settings_dhcp", "label": "DHCP", "icon": "network", "endpoint": "settings_dhcp"},
{"key": "settings_importexport", "label": "Im-/Export", "icon": "transfer", "endpoint": "settings_import_export"},
]},
{"key": "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]
NAV_ITEMS_BY_KEY = {item["key"]: item for item in NAV_ITEMS}
def _nav_key_visible(key, user):
if key == "index":
return True
if key == "devices":
return user.can_view_devices
if key == "switches":
return user.can_manage_switches
if key == "credentials":
return user.can_manage_credentials
if key == "maintenance":
return user.can_view_maintenance
if key == "papierkorb":
return user.can_view_papierkorb
if key == "users":
return user.can_manage_users
if key == "groups":
return user.can_manage_groups
if key == "settings_system":
return user.can_view_settings_system
if key == "settings_importexport":
return user.can_view_settings_importexport
if key == "settings_dhcp":
return user.can_view_settings_dhcp
if key == "settings_ldap":
return user.can_view_settings_ldap
if key == "logs_live":
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
def _ordered_nav_items():
"""Komplette Navbar-Struktur (Top-Level + Unterpunkte je Gruppe) in der
gespeicherten Reihenfolge, OHNE Sichtbarkeitsfilter — Grundlage sowohl
für die Sidebar (dort wird danach noch nach Berechtigung gefiltert) als
auch für den Reihenfolge-Editor auf der Account-Seite (der die
komplette Navbar zeigen soll, unabhängig davon, was der bearbeitende
Admin selbst sehen dürfte — als Admin ohnehin alles)."""
try:
stored_order = json.loads(get_setting("nav_order", "") or "[]")
except (ValueError, TypeError):
stored_order = []
ordered_keys = [k for k in stored_order if k in NAV_ITEMS_BY_KEY]
ordered_keys += [k for k in DEFAULT_NAV_ORDER if k not in ordered_keys]
try:
stored_child_order = json.loads(get_setting("nav_child_order", "") or "{}")
except (ValueError, TypeError):
stored_child_order = {}
items = []
for k in ordered_keys:
item = NAV_ITEMS_BY_KEY[k]
if item.get("children"):
children_by_key = {c["key"]: c for c in item["children"]}
stored = stored_child_order.get(k, [])
child_keys = [ck for ck in stored if ck in children_by_key]
child_keys += [c["key"] for c in item["children"] if c["key"] not in child_keys]
items.append({**item, "children": [children_by_key[ck] for ck in child_keys]})
else:
items.append(item)
return items
@app.context_processor
def inject_asset_version():
"""Cache-Busting für statische Dateien (CSS/JS): hängt die letzte
Änderungszeit der Datei als ?v=... an, damit Browser nach einem Update
(z.B. app.js) nicht stillschweigend eine veraltete, gecachte Version
weiterverwenden — sonst können neue Funktionen scheinbar "nicht
funktionieren", obwohl der Code auf dem Server längst aktuell ist."""
def asset_url(filename):
try:
mtime = int(os.path.getmtime(os.path.join(app.static_folder, filename)))
except OSError:
mtime = 0
return f"{url_for('static', filename=filename)}?v={mtime}"
return {"asset_url": asset_url}
@app.context_processor
def inject_current_year():
"""Für die Copyright-Zeile im Sidebar-Footer (siehe base.html)."""
return {"current_year": datetime.now().year}
@app.context_processor
def inject_nav():
if not current_user.is_authenticated:
return {}
nav_items_ordered = []
for item in _ordered_nav_items():
if item.get("children"):
visible_children = [c for c in item["children"] if _nav_key_visible(c["key"], current_user)]
if visible_children:
nav_items_ordered.append({**item, "children": visible_children})
elif _nav_key_visible(item["key"], current_user):
nav_items_ordered.append(item)
return {"nav_items_ordered": nav_items_ordered}
class User(UserMixin):
def __init__(self, id_, username, is_admin, permissions=None, group_names=None,
first_name=None, last_name=None, avatar_filename=None, auth_source="local"):
self.id = id_
self.username = username
self.is_admin = bool(is_admin)
self.permissions = permissions or set()
self.group_names = group_names or ""
self.first_name = first_name
self.last_name = last_name
self.avatar_filename = avatar_filename
self.auth_source = auth_source
@property
def is_ldap_user(self):
return self.auth_source == "ldap"
def has_permission(self, key):
"""Admins dürfen immer alles. Sonst muss das Recht selbst gesetzt
sein UND — falls es ein übergeordnetes Bereichs-Recht gibt (siehe
PERMISSION_PARENT_GROUP) — dieses ebenfalls: das übergeordnete Recht
wirkt als Kill-Switch für den ganzen Bereich, selbst wenn ein
einzelnes Kind-Recht noch gesetzt ist."""
if self.is_admin:
return True
if key not in self.permissions:
return False
parent = PERMISSION_PARENT_GROUP.get(key)
return parent is None or parent in self.permissions
def has_any_permission(self, keys):
return any(self.has_permission(k) for k in keys)
@property
def can_view_devices(self):
return self.has_permission("devices.view")
@property
def can_manage_switches(self):
return self.has_any_permission(
["switches.view", "switches.create", "switches.edit"]
)
@property
def can_manage_credentials(self):
return self.has_any_permission(
["credentials.view", "credentials.create", "credentials.edit"]
)
@property
def can_view_maintenance(self):
return self.has_any_permission(["maintenance.view", "maintenance.edit"])
@property
def can_run_maintenance(self):
return self.has_permission("maintenance.edit")
@property
def can_view_papierkorb(self):
"""Papierkorb-Seite: sichtbar sobald mind. eine der fünf dort
gelisteten Ressourcen (Geräte/Switche/Zugangsdaten/Benutzer/Gruppen)
View-Recht hat — jede Kachel darin ist ohnehin einzeln je Ressource
gated (siehe papierkorb.html)."""
return self.has_any_permission(
["devices.view", "switches.view", "credentials.view", "users.view", "groups.view"]
)
@property
def can_view_live_log(self):
return self.has_permission("logs_live.view")
@property
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(
["users.view", "users.create", "users.edit"]
)
@property
def can_manage_groups(self):
return self.has_any_permission(
["groups.view", "groups.create", "groups.edit"]
)
@property
def can_view_settings_system(self):
return self.has_permission("settings_system.view")
@property
def can_view_settings_importexport(self):
# Jedes der drei Rechte (Seite ansehen/Import/Export) muss für sich
# allein zu Seiten-/Nav-Sichtbarkeit führen -- sonst wäre z.B. ein
# Nutzer mit ausschließlich dem Export-Recht (ohne .view) nie in der
# Lage, die Seite überhaupt zu erreichen, um sein Recht zu nutzen.
return self.has_any_permission([
"settings_importexport.view", "settings_importexport.edit", "settings_importexport.export",
])
@property
def can_view_settings_dhcp(self):
return self.has_permission("settings_dhcp.view")
@property
def can_view_settings_ldap(self):
return self.has_any_permission(["settings_ldap.view", "settings_ldap.edit"])
@property
def full_name(self):
parts = [p for p in (self.first_name, self.last_name) if p]
return " ".join(parts)
@property
def display_name(self):
return self.full_name or self.username
@property
def avatar_url(self):
if self.avatar_filename:
return url_for("static", filename=f"uploads/avatars/{self.avatar_filename}")
return None
def get_db_connection():
conn = sqlite3.connect(DB_PATH)
conn.row_factory = sqlite3.Row
return conn
# Kuratierte Standard-DHCPv4-Optionen (analog den "Predefined Options" eines
# Windows-DHCP-Servers) — vorbefüllt in _ensure_schema(), siehe dort für die
# Begründung. Muss vor dem _ensure_schema()-Aufruf am Ende der Datei stehen.
# (code, Kea-Name, Anzeige-Typ, Beschreibung). Router (3)/DNS-Server (6)/
# Domain-Name (15)/Lease-Zeiten (51/58/59) sind bereits eigene, dedizierte
# Felder und deshalb bewusst nicht enthalten. Client-/Protokoll-interne
# Optionen (message-type, parameter-request-list, requested-address,
# client-identifier, server-identifier, ...) ebenfalls bewusst nicht
# enthalten — die sind serverseitig nicht sinnvoll setzbar (auch Windows
# Server listet sie nicht unter "Predefined Options"). "host-name" (12)
# ebenfalls bewusst NICHT enthalten: Kea beantwortet die Host-Name-Anfrage
# eines Clients bei einer Reservierung bereits automatisch mit deren
# eigenem "hostname"-Feld (aus dem Namen der Reservierung abgeleitet, siehe
# _dhcp_safe_hostname) — ein zusätzlicher option-data-Override für dieselbe
# Option in derselben Reservierung wäre eine mit der Kea-Doku belegte
# Dopplung/Quelle für Widersprüche, kein zusätzlicher Nutzen. Alle
# restlichen Namen live gegen eine echte Kea-2.4.1-Instanz verifiziert
# (kea-dhcp4 -t je Option einzeln).
DHCP_STANDARD_OPTIONS = [
(4, "time-servers", "ipv4-address", "Zeitserver (RFC 868)"),
(7, "log-servers", "ipv4-address", "Log-Server (MIT-LCS UDP)"),
(8, "cookie-servers", "ipv4-address", "Cookie-Server"),
(9, "lpr-servers", "ipv4-address", "LPR-Druckserver"),
(10, "impress-servers", "ipv4-address", "Impress-Server"),
(11, "resource-location-servers", "ipv4-address", "Resource Location Server"),
(13, "boot-size", "uint16", "Boot-Image-Größe (Blöcke)"),
(14, "merit-dump", "string", "Crash-Dump-Datei"),
(16, "swap-server", "ipv4-address", "Swap-Server"),
(17, "root-path", "string", "Root-Pfad (NFS)"),
(18, "extensions-path", "string", "Extensions-Pfad (BOOTP)"),
(19, "ip-forwarding", "boolean", "IP-Forwarding aktivieren"),
(20, "non-local-source-routing", "boolean", "Non-Local Source Routing"),
(21, "policy-filter", "string", "Policy Filter (Ziel,Maske,...)"),
(22, "max-dgram-reassembly", "uint16", "Maximale Datagramm-Reassemblierungsgröße"),
(23, "default-ip-ttl", "uint8", "Standard-TTL für IP"),
(24, "path-mtu-aging-timeout", "uint32", "Path-MTU-Aging-Timeout (Sek.)"),
(25, "path-mtu-plateau-table", "uint16", "Path-MTU-Plateau-Tabelle"),
(26, "interface-mtu", "uint16", "MTU des Interfaces"),
(27, "all-subnets-local", "boolean", "Alle Subnetze lokal erreichbar"),
(28, "broadcast-address", "ipv4-address", "Broadcast-Adresse"),
(29, "perform-mask-discovery", "boolean", "Subnetzmasken-Discovery"),
(30, "mask-supplier", "boolean", "Subnetzmasken-Anbieter"),
(31, "router-discovery", "boolean", "Router Discovery (RFC 1256)"),
(32, "router-solicitation-address", "ipv4-address", "Router-Solicitation-Adresse"),
(33, "static-routes", "string", "Statische Routen (Ziel,Gateway,...)"),
(34, "trailer-encapsulation", "boolean", "Trailer Encapsulation"),
(35, "arp-cache-timeout", "uint32", "ARP-Cache-Timeout (Sek.)"),
(36, "ieee802-3-encapsulation", "boolean", "Ethernet-Encapsulation (IEEE 802.3)"),
(37, "default-tcp-ttl", "uint8", "Standard-TTL für TCP"),
(38, "tcp-keepalive-interval", "uint32", "TCP-Keepalive-Intervall (Sek.)"),
(40, "nis-domain", "string", "NIS-Domain"),
(41, "nis-servers", "ipv4-address", "NIS-Server"),
(42, "ntp-servers", "ipv4-address", "NTP-Server"),
(44, "netbios-name-servers", "ipv4-address", "WINS/NBNS-Server"),
(45, "netbios-dd-server", "ipv4-address", "NetBIOS Datagram Distribution Server"),
(46, "netbios-node-type", "uint8", "WINS/NBT-Knotentyp"),
(47, "netbios-scope", "string", "NetBIOS-Scope-ID"),
(48, "font-servers", "ipv4-address", "X-Window-Font-Server"),
(49, "x-display-manager", "ipv4-address", "X-Window Display Manager"),
(60, "vendor-class-identifier", "string", "Vendor Class Identifier"),
(64, "nisplus-domain-name", "string", "NIS+-Domain"),
(65, "nisplus-servers", "ipv4-address", "NIS+-Server"),
(66, "tftp-server-name", "string", "Boot-Server (TFTP-Hostname)"),
(67, "boot-file-name", "string", "Bootdatei-Name"),
(68, "mobile-ip-home-agent", "ipv4-address", "Mobile-IP-Home-Agent"),
(69, "smtp-server", "ipv4-address", "SMTP-Server"),
(70, "pop-server", "ipv4-address", "POP3-Server"),
(71, "nntp-server", "ipv4-address", "NNTP-Server"),
(72, "www-server", "ipv4-address", "WWW-Server"),
(73, "finger-server", "ipv4-address", "Finger-Server"),
(74, "irc-server", "ipv4-address", "IRC-Server"),
(75, "streettalk-server", "ipv4-address", "StreetTalk-Server"),
(76, "streettalk-directory-assistance-server", "ipv4-address", "StreetTalk Directory Assistance Server"),
(118, "subnet-selection", "ipv4-address", "Subnet Selection (RFC 3011)"),
(119, "domain-search", "string", "DNS-Suchliste (Domain Search)"),
]
def _ensure_schema():
"""Legt neue Tabellen (Gruppen/Rechte) an, falls die DB noch aus einer
älteren Version stammt — idempotent, sicher bei jedem Start aufzurufen.
Sorgt außerdem dafür, dass die Standardgruppe 'Benutzer' existiert und
jeder Benutzer ohne Gruppe ihr zugeordnet ist."""
conn = get_db_connection()
# Für den Migrations-Einmal-Guard weiter unten (settings existiert normalerweise
# schon durch create_db.py, hier nur zur Absicherung defensiv angelegt).
conn.execute("CREATE TABLE IF NOT EXISTS settings (key TEXT PRIMARY KEY, value TEXT NOT NULL)")
conn.execute("CREATE TABLE IF NOT EXISTS groups (id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT UNIQUE NOT NULL)")
conn.execute("""
CREATE TABLE IF NOT EXISTS group_permissions (
group_id INTEGER NOT NULL,
permission TEXT NOT NULL,
PRIMARY KEY (group_id, permission),
FOREIGN KEY (group_id) REFERENCES groups(id)
)
""")
conn.execute("""
CREATE TABLE IF NOT EXISTS user_groups (
user_id INTEGER NOT NULL,
group_id INTEGER NOT NULL,
PRIMARY KEY (user_id, group_id),
FOREIGN KEY (user_id) REFERENCES users(id),
FOREIGN KEY (group_id) REFERENCES groups(id)
)
""")
# Migration: 'is_default'-/'is_system'-Spalte nachrüsten, falls die
# Tabelle noch aus einer älteren Version ohne diese Spalten stammt.
# is_system sperrt Name UND Rechte einer Gruppe komplett gegen Änderungen
# über die UI (aktuell nur die Standardgruppe 'Benutzer') — getrennt von
# is_default (steuert nur die automatische Zuordnung neuer Benutzer),
# damit künftig auch andere, nicht-default Systemgruppen denkbar wären.
existing_cols = {row["name"] for row in conn.execute("PRAGMA table_info(groups)").fetchall()}
if "is_default" not in existing_cols:
conn.execute("ALTER TABLE groups ADD COLUMN is_default INTEGER DEFAULT 0")
if "is_system" not in existing_cols:
conn.execute("ALTER TABLE groups ADD COLUMN is_system INTEGER DEFAULT 0")
# Migration: Vor-/Nachname + Profilbild für Benutzer nachrüsten.
user_cols = {row["name"] for row in conn.execute("PRAGMA table_info(users)").fetchall()}
if "first_name" not in user_cols:
conn.execute("ALTER TABLE users ADD COLUMN first_name TEXT")
if "last_name" not in user_cols:
conn.execute("ALTER TABLE users ADD COLUMN last_name TEXT")
if "avatar_filename" not in user_cols:
conn.execute("ALTER TABLE users ADD COLUMN avatar_filename TEXT")
# 'local' (Passwort in dieser DB, bcrypt) oder 'ldap' (Konto stammt aus
# einem beim ersten erfolgreichen LDAP/AD-Login automatisch angelegten
# Eintrag -- das Passwort wird NIE lokal geprüft, siehe _ldap_authenticate()
# bzw. den Login-Handler; das gespeicherte "password" ist dafür nur ein
# nie verwendeter Platzhalter-Hash, um die NOT-NULL-Spalte zu befüllen).
if "auth_source" not in user_cols:
conn.execute("ALTER TABLE users ADD COLUMN auth_source TEXT NOT NULL DEFAULT 'local'")
# E-Mail-Adresse — für lokale Konten frei editierbar (Login zusätzlich
# per E-Mail statt nur Benutzername möglich), für LDAP/AD-Konten aus
# dem "mail"-Attribut übernommen und bei jedem Login aufgefrischt.
if "email" not in user_cols:
conn.execute("ALTER TABLE users ADD COLUMN email TEXT")
# Sperrt einen Login unabhängig vom Passwort/AD-Status -- der einzige
# Weg, einem AD-Konto den Zugriff wirklich zu entziehen, ohne es in AD
# selbst zu deaktivieren (ein simples Löschen des lokalen Schatten-
# Kontos reicht dafür NICHT: bei erneuter gültiger AD-Anmeldung würde
# es sonst automatisch neu angelegt).
if "is_locked" not in user_cols:
conn.execute("ALTER TABLE users ADD COLUMN is_locked INTEGER NOT NULL DEFAULT 0")
# Service-Accounts (aktuell nur das LDAP-Bind-Konto) -- bewusst NICHT in
# der users-Tabelle (kein echter, einloggbarer App-Benutzer) und NICHT
# in der credentials-Tabelle (die ist für Switch-/Geräte-Zugangsdaten,
# ein anderer Anwendungsfall) -- eine eigene, kleine Tabelle. "purpose"
# ist UNIQUE, ein neu gespeichertes Konto für denselben Zweck ersetzt
# das alte (siehe save_ldap-Handler: INSERT ... ON CONFLICT DO UPDATE).
conn.execute("""
CREATE TABLE IF NOT EXISTS service_accounts (
id INTEGER PRIMARY KEY AUTOINCREMENT,
purpose TEXT UNIQUE NOT NULL,
username TEXT NOT NULL,
password TEXT NOT NULL
)
""")
# AD-Gruppe -> App-Rechtegruppe: mehrere AD-Gruppen können jeweils einer
# App-Gruppe zugeordnet werden. Bei Login/Provisionierung wird geprüft,
# in welchen dieser AD-Gruppen ein Benutzer (auch über verschachtelte
# Gruppen) Mitglied ist, und die entsprechenden App-Gruppen werden ihm
# zugewiesen (additiv, siehe _ldap_resolve_app_groups) -- passt zu
# keine dieser Gruppen, greift die Standardgruppe (ldap_default_group).
conn.execute("""
CREATE TABLE IF NOT EXISTS ldap_group_mappings (
id INTEGER PRIMARY KEY AUTOINCREMENT,
ad_group_dn TEXT UNIQUE NOT NULL,
ad_group_name TEXT NOT NULL,
app_group_id INTEGER NOT NULL,
FOREIGN KEY (app_group_id) REFERENCES groups(id)
)
""")
# Änderungsverlauf (Audit-Log): wer hat wann was geändert. Bewusst ohne
# PoE-Neustarts (die stehen bereits im Live-Log von poe.sh).
conn.execute("""
CREATE TABLE IF NOT EXISTS audit_log (
id INTEGER PRIMARY KEY AUTOINCREMENT,
ts TEXT NOT NULL,
username TEXT NOT NULL,
action TEXT NOT NULL,
target TEXT,
details TEXT
)
""")
# Migration: rpi_ip -> ip (Namensrelikt aus einer früheren, Raspberry-Pi-
# spezifischen Version -- Geräte sind längst nicht mehr auf Pis
# beschränkt). RENAME COLUMN erhält bestehende Werte unverändert.
device_cols = {row["name"] for row in conn.execute("PRAGMA table_info(devices)").fetchall()}
if "rpi_ip" in device_cols and "ip" not in device_cols:
conn.execute("ALTER TABLE devices RENAME COLUMN rpi_ip TO ip")
device_cols = {row["name"] for row in conn.execute("PRAGMA table_info(devices)").fetchall()}
# "Wer hat zuletzt geändert" direkt am Objekt, zusätzlich zum Audit-Log.
if "last_modified_by" not in device_cols:
conn.execute("ALTER TABLE devices ADD COLUMN last_modified_by TEXT")
if "last_modified_at" not in device_cols:
conn.execute("ALTER TABLE devices ADD COLUMN last_modified_at TEXT")
# Zugangsdaten (Credentials): eigenständige, wiederverwendbare
# SSH-Logins für Switche, statt Username/Passwort direkt am Switch.
conn.execute("""
CREATE TABLE IF NOT EXISTS credentials (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT UNIQUE NOT NULL,
username TEXT NOT NULL,
password TEXT NOT NULL,
category TEXT NOT NULL DEFAULT 'switch'
)
""")
# Kategorie (Switch/Linux-Client/Windows-Client/Router-Sonstiges) —
# bestimmt u.a., ob für ein Gerät mit diesen Zugangsdaten die
# SSH-Update-Aktion unter "Wartung" angeboten wird (siehe
# DEVICE_MAINTENANCE_CATEGORY). Bewusst KEINE automatische Erkennung
# per Ping o.ä. — ein reiner TTL-Hinweis ist keine verlässliche
# Grundlage dafür, unbeaufsichtigt Systembefehle auszuführen; der Admin
# legt die Kategorie explizit fest.
credential_cols = {row["name"] for row in conn.execute("PRAGMA table_info(credentials)").fetchall()}
if "category" not in credential_cols:
conn.execute("ALTER TABLE credentials ADD COLUMN category TEXT NOT NULL DEFAULT 'switch'")
switch_cols = {row["name"] for row in conn.execute("PRAGMA table_info(switches)").fetchall()}
if "credential_id" not in switch_cols:
conn.execute("ALTER TABLE switches ADD COLUMN credential_id INTEGER")
if "last_modified_by" not in switch_cols:
conn.execute("ALTER TABLE switches ADD COLUMN last_modified_by TEXT")
if "last_modified_at" not in switch_cols:
conn.execute("ALTER TABLE switches ADD COLUMN last_modified_at TEXT")
# SSH-Port je Switch (falls nicht Standard 22) — NULL/leer bedeutet
# überall "22 verwenden" (siehe SWITCH_DEFAULT_SSH_PORT-Fallback).
if "ssh_port" not in switch_cols:
conn.execute("ALTER TABLE switches ADD COLUMN ssh_port INTEGER")
# Optionale SSH-Anbindung eines Clients (devices) — analog zu Switchen,
# damit z.B. ein Linux-Client für die Wartungs-/Update-Aktion erreichbar
# ist. NULL/kein credential_id = kein SSH-Zugriff für dieses Gerät.
device_ssh_cols = {row["name"] for row in conn.execute("PRAGMA table_info(devices)").fetchall()}
if "credential_id" not in device_ssh_cols:
conn.execute("ALTER TABLE devices ADD COLUMN credential_id INTEGER")
if "ssh_port" not in device_ssh_cols:
conn.execute("ALTER TABLE devices ADD COLUMN ssh_port INTEGER")
# Papierkorb: "Löschen" setzt für Clients/Switche/Zugangsdaten/lokale
# Benutzer/Custom-Gruppen nur noch deleted_at statt die Zeile wirklich zu
# entfernen -- Wiederherstellen setzt es zurück auf NULL. Endgültiges
# Löschen (manuell über den Papierkorb oder automatisch nach Ablauf der
# Aufbewahrungsfrist, siehe trash_retention_days) führt DANN erst das
# echte DELETE aus. AD/LDAP-Benutzer durchlaufen den Papierkorb nicht
# (siehe users()-Route delete_user) -- ihr Konto wird beim nächsten
# gültigen Login ohnehin automatisch neu angelegt.
for _table in ("devices", "switches", "credentials", "users", "groups"):
_cols = {row["name"] for row in conn.execute(f"PRAGMA table_info({_table})").fetchall()}
if "deleted_at" not in _cols:
conn.execute(f"ALTER TABLE {_table} ADD COLUMN deleted_at TEXT")
# DHCP: eigene Options-Definitionen + Werte (global/per-Client,
# siehe create_db.py für die ausführliche Begründung von device_mac='').
conn.execute("""
CREATE TABLE IF NOT EXISTS dhcp_option_defs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
code INTEGER NOT NULL UNIQUE,
name TEXT NOT NULL UNIQUE,
type TEXT NOT NULL,
description TEXT,
is_standard INTEGER NOT NULL DEFAULT 0
)
""")
# Migration: "is_standard"-Spalte nachrüsten, falls die Tabelle noch aus
# einer Version ohne vorbefüllte Standard-Optionen stammt.
option_def_cols = {row["name"] for row in conn.execute("PRAGMA table_info(dhcp_option_defs)").fetchall()}
if "is_standard" not in option_def_cols:
conn.execute("ALTER TABLE dhcp_option_defs ADD COLUMN is_standard INTEGER NOT NULL DEFAULT 0")
conn.execute("""
CREATE TABLE IF NOT EXISTS dhcp_option_values (
id INTEGER PRIMARY KEY AUTOINCREMENT,
option_def_id INTEGER NOT NULL,
device_mac TEXT NOT NULL DEFAULT '',
value TEXT NOT NULL,
FOREIGN KEY (option_def_id) REFERENCES dhcp_option_defs(id),
UNIQUE (option_def_id, device_mac)
)
""")
# Einmalig eine kuratierte Auswahl an Standard-DHCP-Optionen vorbefüllen
# (analog den "Predefined Options" eines Windows-DHCP-Servers) — Router/
# DNS-Server/Domain-Name/Lease-Zeiten sind bereits eigene, dedizierte
# Felder (siehe dhcp_subnets/DHCP_DEFAULTS) und deshalb hier bewusst
# ausgenommen. Alle Namen live gegen eine echte Kea-2.4.1-Instanz
# verifiziert (kea-dhcp4 -t) — Kea kennt diese Standard-Optionen bereits
# nativ, sie bekommen deshalb (anders als eigene Options) KEIN eigenes
# option-def in der generierten Konfiguration (siehe _render_kea_config),
# sonst würde die eingebaute Definition dupliziert. Per Guard nur
# einmalig, damit ein bewusst vom Admin gelöschter Eintrag nach einem
# Neustart nicht stillschweigend wieder auftaucht.
_migration_key_std_opts = "_seeded_dhcp_standard_options_v1"
if not conn.execute("SELECT 1 FROM settings WHERE key=?", (_migration_key_std_opts,)).fetchone():
for code, name, opt_type, description in DHCP_STANDARD_OPTIONS:
conn.execute(
"INSERT OR IGNORE INTO dhcp_option_defs (code, name, type, description, is_standard) "
"VALUES (?, ?, ?, ?, 1)",
(code, name, opt_type, description),
)
conn.execute("INSERT OR IGNORE INTO settings (key, value) VALUES (?, '1')", (_migration_key_std_opts,))
# v2: Liste um weitere verifizierte Standard-Optionen erweitert — eigener
# Guard, damit bereits auf v1 migrierte Installationen die neu
# hinzugekommenen Optionen ebenfalls bekommen, ohne die gesamte
# Seed-Logik erneut über alle Zeilen laufen zu lassen.
_migration_key_std_opts_v2 = "_seeded_dhcp_standard_options_v2"
if not conn.execute("SELECT 1 FROM settings WHERE key=?", (_migration_key_std_opts_v2,)).fetchone():
for code, name, opt_type, description in DHCP_STANDARD_OPTIONS:
conn.execute(
"INSERT OR IGNORE INTO dhcp_option_defs (code, name, type, description, is_standard) "
"VALUES (?, ?, ?, ?, 1)",
(code, name, opt_type, description),
)
conn.execute("INSERT OR IGNORE INTO settings (key, value) VALUES (?, '1')", (_migration_key_std_opts_v2,))
# v3: "host-name" (12) als Standard-Option wieder entfernt — Kea
# beantwortet die Host-Name-Anfrage eines Clients bei einer Reservierung
# bereits automatisch über deren eigenes "hostname"-Feld; ein
# zusätzlicher option-data-Override dafür ist laut Kea-Doku eine
# Dopplung/Quelle für Widersprüche (live an einer echten Reservierung
# nachvollzogen, wo beide gleichzeitig gesetzt waren). Löscht auch
# bereits gesetzte Werte für diese Option mit, da sie ohnehin nie
# sinnvoll gewirkt hätten.
_migration_key_std_opts_v3 = "_removed_dhcp_standard_option_hostname_v3"
if not conn.execute("SELECT 1 FROM settings WHERE key=?", (_migration_key_std_opts_v3,)).fetchone():
stale = conn.execute("SELECT id FROM dhcp_option_defs WHERE name='host-name' AND is_standard=1").fetchone()
if stale:
conn.execute("DELETE FROM dhcp_option_values WHERE option_def_id=?", (stale["id"],))
conn.execute("DELETE FROM dhcp_option_defs WHERE id=?", (stale["id"],))
conn.execute("INSERT OR IGNORE INTO settings (key, value) VALUES (?, '1')", (_migration_key_std_opts_v3,))
# DHCP: manuelle Reservierungen — im Unterschied zu den automatisch aus
# den Clients (devices-Tabelle) erzeugten Reservierungen für Geräte, die
# NICHT als Client in dieser App gepflegt werden (z.B. ein fremdes/
# externes Gerät, dem bewusst und mit voller Kontrolle eine feste IP
# gegeben werden soll, statt es unüberwacht automatisch anzubinden).
# Können im Gegensatz zu automatischen Reservierungen gelöscht werden.
conn.execute("""
CREATE TABLE IF NOT EXISTS dhcp_manual_reservations (
id INTEGER PRIMARY KEY AUTOINCREMENT,
mac TEXT NOT NULL UNIQUE,
ip TEXT NOT NULL,
name TEXT NOT NULL
)
""")
# DHCP: mehrere gleichzeitige Subnetze statt einer einzelnen globalen
# Range — ein Host kann mehrere IPs/Interfaces mit jeweils eigenem Netz
# haben, für die alle DHCP angeboten werden soll. gateway/dns sind je
# Subnetz optional (leer = automatisch erkanntes Gateway des Interfaces
# bzw. kein DNS-Override); domain/Lease-Zeiten/Ausgabepfad bleiben
# bewusst global (siehe DHCP_DEFAULTS), da sie Kea-weit gelten.
conn.execute("""
CREATE TABLE IF NOT EXISTS dhcp_subnets (
id INTEGER PRIMARY KEY AUTOINCREMENT,
interface TEXT NOT NULL,
range_start TEXT NOT NULL,
range_end TEXT NOT NULL,
gateway TEXT,
dns TEXT,
sort_order INTEGER NOT NULL DEFAULT 0,
enabled INTEGER NOT NULL DEFAULT 1
)
""")
# Migration: "enabled"-Spalte nachrüsten, falls die Tabelle noch aus
# einer Version ohne Deaktivieren-Funktion stammt.
dhcp_subnet_cols = {row["name"] for row in conn.execute("PRAGMA table_info(dhcp_subnets)").fetchall()}
if "enabled" not in dhcp_subnet_cols:
conn.execute("ALTER TABLE dhcp_subnets ADD COLUMN enabled INTEGER NOT NULL DEFAULT 1")
# Migration: bisherige globale Einzel-Range (dhcp_interface/_range_*/
# _gateway/_dns in der settings-Tabelle) einmalig in eine erste Zeile
# der neuen Tabelle überführen, statt eine bereits funktionierende
# Konfiguration beim Upgrade stillschweigend zu verlieren. Per Guard nur
# einmalig, da die alten settings-Schlüssel absichtlich stehen bleiben
# (Altlast, wird nicht mehr gelesen) und sonst bei jedem Start erneut
# eine (dann ggf. schon gelöschte) Zeile anlegen würde.
_migration_key_dhcp = "_migrated_dhcp_single_range_to_subnets_v1"
if not conn.execute("SELECT 1 FROM settings WHERE key=?", (_migration_key_dhcp,)).fetchone():
legacy_start = conn.execute("SELECT value FROM settings WHERE key='dhcp_range_start'").fetchone()
legacy_end = conn.execute("SELECT value FROM settings WHERE key='dhcp_range_end'").fetchone()
if legacy_start and legacy_end and legacy_start["value"] and legacy_end["value"]:
legacy_interface = conn.execute("SELECT value FROM settings WHERE key='dhcp_interface'").fetchone()
legacy_gateway = conn.execute("SELECT value FROM settings WHERE key='dhcp_gateway'").fetchone()
legacy_dns = conn.execute("SELECT value FROM settings WHERE key='dhcp_dns'").fetchone()
conn.execute(
"INSERT INTO dhcp_subnets (interface, range_start, range_end, gateway, dns, sort_order) "
"VALUES (?, ?, ?, ?, ?, 0)",
(
(legacy_interface["value"] if legacy_interface else None) or "eth0",
legacy_start["value"], legacy_end["value"],
legacy_gateway["value"] if legacy_gateway else None,
legacy_dns["value"] if legacy_dns else None,
),
)
conn.execute("INSERT OR IGNORE INTO settings (key, value) VALUES (?, '1')", (_migration_key_dhcp,))
# Migration: bestehende, direkt am Switch hinterlegte Zugangsdaten
# (ältere DB-Version) in eigene Credentials-Datensätze überführen.
if "username" in switch_cols and "password" in switch_cols:
legacy_switches = conn.execute(
"SELECT hostname, username, password FROM switches WHERE credential_id IS NULL"
).fetchall()
for sw in legacy_switches:
base_name = f"{sw['hostname']} (migriert)"
final_name, suffix = base_name, 1
while conn.execute("SELECT 1 FROM credentials WHERE name=?", (final_name,)).fetchone():
suffix += 1
final_name = f"{base_name} {suffix}"
cur = conn.execute(
"INSERT INTO credentials (name, username, password) VALUES (?, ?, ?)",
(final_name, sw["username"], sw["password"]),
)
conn.execute(
"UPDATE switches SET credential_id=? WHERE hostname=?",
(cur.lastrowid, sw["hostname"]),
)
# Migration (granularer Rechtebaum): devices.toggle -> devices.edit,
# switches.* -> gespiegelte credentials.*, sowie Backfill des neuen
# "Bereich sichtbar"-Rechts für Gruppen mit bereits gesetzten Kind-Rechten.
# WICHTIG: nur EINMALIG (Guard über settings) — sonst würde das
# Bereichs-Recht bei jedem Neustart automatisch neu gesetzt und den
# Kill-Switch (siehe User.has_permission) wirkungslos machen.
_migration_key = "_migrated_permission_hierarchy_v1"
if not conn.execute("SELECT 1 FROM settings WHERE key=?", (_migration_key,)).fetchone():
toggle_groups = conn.execute(
"SELECT DISTINCT group_id FROM group_permissions WHERE permission='devices.toggle'"
).fetchall()
for row in toggle_groups:
conn.execute(
"INSERT OR IGNORE INTO group_permissions (group_id, permission) VALUES (?, 'devices.edit')",
(row["group_id"],),
)
conn.execute("DELETE FROM group_permissions WHERE permission='devices.toggle'")
_switch_to_cred = {
"switches.view": "credentials.view",
"switches.create": "credentials.create",
"switches.edit": "credentials.edit",
"switches.delete": "credentials.delete",
}
for switch_perm, cred_perm in _switch_to_cred.items():
rows = conn.execute(
"SELECT DISTINCT group_id FROM group_permissions WHERE permission=?", (switch_perm,)
).fetchall()
for row in rows:
conn.execute(
"INSERT OR IGNORE INTO group_permissions (group_id, permission) VALUES (?, ?)",
(row["group_id"], cred_perm),
)
_parent_backfill = [
("devices_group.view", [
"devices.view", "devices.create", "devices.edit", "devices.delete", "devices.restart",
"switches.view", "switches.create", "switches.edit", "switches.delete",
"credentials.view", "credentials.create", "credentials.edit", "credentials.delete",
]),
("logs_group.view", ["logs_live.view", "logs_activity.view"]),
("settings_group.view", [
"users.view", "users.create", "users.edit", "users.delete",
"groups.view", "groups.create", "groups.edit", "groups.delete",
"settings_system.view", "settings_system.edit",
"settings_importexport.view", "settings_importexport.edit",
]),
]
for parent_key, child_keys in _parent_backfill:
placeholders = ",".join("?" * len(child_keys))
rows = conn.execute(
f"SELECT DISTINCT group_id FROM group_permissions WHERE permission IN ({placeholders})",
child_keys,
).fetchall()
for row in rows:
conn.execute(
"INSERT OR IGNORE INTO group_permissions (group_id, permission) VALUES (?, ?)",
(row["group_id"], parent_key),
)
conn.execute(
"INSERT OR IGNORE INTO settings (key, value) VALUES (?, '1')", (_migration_key,)
)
# Migration v2: "Löschen" als eigenes Recht wieder abgeschafft — "Ändern"
# deckt das Löschen jetzt überall mit ab (auch bei Clients, keine
# Ausnahme mehr). Gruppen, die bisher nur *.delete ohne *.edit hatten,
# würden sonst durch das Update ihr Löschen-Recht stillschweigend
# verlieren — deshalb erst *.edit nachtragen, dann die veralteten
# *.delete-Zeilen entfernen. Ebenfalls einmalig per Guard, aus demselben
# Grund wie bei v1 (kein Zurückschreiben bei jedem Neustart nötig, da
# *.delete als Recht komplett entfällt und nie wieder vorkommen kann).
_migration_key_v2 = "_migrated_permission_delete_fold_v2"
if not conn.execute("SELECT 1 FROM settings WHERE key=?", (_migration_key_v2,)).fetchone():
_delete_to_edit = {
"devices.delete": "devices.edit",
"switches.delete": "switches.edit",
"credentials.delete": "credentials.edit",
"users.delete": "users.edit",
"groups.delete": "groups.edit",
}
for delete_perm, edit_perm in _delete_to_edit.items():
rows = conn.execute(
"SELECT DISTINCT group_id FROM group_permissions WHERE permission=?", (delete_perm,)
).fetchall()
for row in rows:
conn.execute(
"INSERT OR IGNORE INTO group_permissions (group_id, permission) VALUES (?, ?)",
(row["group_id"], edit_perm),
)
conn.execute("DELETE FROM group_permissions WHERE permission=?", (delete_perm,))
conn.execute(
"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
# (TESM_LIVE_LOG_PATH unter /var/log/tesm/) 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
# der UI ohnehin komplett gegen Umbenennen/Rechteänderung sperrt —
# Mitgliedschaft bleibt weiterhin frei verwaltbar.
cur = conn.execute(
"INSERT OR IGNORE INTO groups (name, is_default, is_system) VALUES (?, 1, 1)",
(DEFAULT_GROUP_NAME,),
)
default_group_id = cur.lastrowid if cur.rowcount > 0 else conn.execute(
"SELECT id FROM groups WHERE name=?", (DEFAULT_GROUP_NAME,)
).fetchone()["id"]
conn.execute("UPDATE groups SET is_default=1, is_system=1 WHERE id=?", (default_group_id,))
conn.execute("DELETE FROM group_permissions WHERE group_id=?", (default_group_id,))
conn.executemany(
"INSERT INTO group_permissions (group_id, permission) VALUES (?, ?)",
[(default_group_id, p) for p in DEFAULT_GROUP_PERMISSIONS],
)
# Jeden nicht-admin Benutzer ohne jegliche Gruppenzugehörigkeit der
# Standardgruppe zuordnen (Migrationsfall: bestehende Benutzer sollen
# durch die Einführung des Rechtesystems keinen Zugriff verlieren).
orphan_users = conn.execute("""
SELECT users.id FROM users
LEFT JOIN user_groups ON user_groups.user_id = users.id
WHERE users.is_admin = 0 AND user_groups.user_id IS NULL AND users.deleted_at IS NULL
""").fetchall()
conn.executemany(
"INSERT OR IGNORE INTO user_groups (user_id, group_id) VALUES (?, ?)",
[(u["id"], default_group_id) for u in orphan_users],
)
conn.commit()
conn.close()
_ensure_schema()
def _build_user(row):
"""Baut ein vollständiges User-Objekt inkl. effektiver Rechte aus allen
Gruppen, denen der Benutzer angehört (Vereinigung, nicht Schnittmenge)."""
conn = get_db_connection()
# deleted_at IS NULL: Mitglied einer in den Papierkorb verschobenen
# Gruppe verliert deren Rechte sofort (nicht erst nach Wiederherstellen/
# endgültigem Löschen) -- einziger Ort, der geändert werden muss, damit
# das app-weit für jede has_permission()-Prüfung greift.
group_rows = conn.execute("""
SELECT g.id, g.name FROM groups g
JOIN user_groups ug ON ug.group_id = g.id
WHERE ug.user_id = ? AND g.deleted_at IS NULL
ORDER BY g.name ASC
""", (row["id"],)).fetchall()
permissions = set()
if group_rows:
placeholders = ",".join("?" * len(group_rows))
perm_rows = conn.execute(
f"SELECT DISTINCT permission FROM group_permissions WHERE group_id IN ({placeholders})",
[g["id"] for g in group_rows],
).fetchall()
permissions = {p["permission"] for p in perm_rows}
conn.close()
group_names = ", ".join(g["name"] for g in group_rows)
return User(
row["id"], row["username"], row["is_admin"], permissions, group_names,
first_name=row["first_name"] if "first_name" in row.keys() else None,
last_name=row["last_name"] if "last_name" in row.keys() else None,
avatar_filename=row["avatar_filename"] if "avatar_filename" in row.keys() else None,
auth_source=row["auth_source"] if "auth_source" in row.keys() else "local",
)
def _append_changes_log(ts, who, action, target, details):
"""Spiegelt jeden Änderungslog-Eintrag zusätzlich in eine eigene, per
logrotate rotierte Datei (TESM_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(TESM_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 (?, ?, ?, ?, ?)",
(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):
"""Wie log_action(), aber ohne Abhängigkeit von current_user — für
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 (?, ?, ?, ?, ?)",
(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):
"""Trägt 'zuletzt geändert von/am' direkt am Datensatz ein (Devices/Switches)."""
who = current_user.username if current_user.is_authenticated else "system"
now = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
conn.execute(
f"UPDATE {table} SET last_modified_by=?, last_modified_at=? WHERE {key_col}=?",
(who, now, key_val),
)
def _derive_export_fernet(passphrase: str, salt: bytes) -> Fernet:
"""Leitet aus einer Passphrase + Salt einen Fernet-Schlüssel ab (PBKDF2) —
für portable, umgebungsunabhängige Ver-/Entschlüsselung beim Import/Export
(im Gegensatz zum eigentlichen fernet.key, der pro Installation neu ist)."""
kdf = PBKDF2HMAC(algorithm=hashes.SHA256(), length=32, salt=salt, iterations=390000)
key = base64.urlsafe_b64encode(kdf.derive(passphrase.encode("utf-8")))
return Fernet(key)
def encrypt_password(password: str) -> str:
return fernet.encrypt(password.encode()).decode()
def decrypt_password(token: str) -> str:
return fernet.decrypt(token.encode()).decode()
def get_setting(key, default=None):
conn = get_db_connection()
row = conn.execute("SELECT value FROM settings WHERE key=?", (key,)).fetchone()
conn.close()
return row["value"] if row else default
def set_setting(key, value):
conn = get_db_connection()
conn.execute(
"INSERT INTO settings (key, value) VALUES (?, ?) "
"ON CONFLICT(key) DO UPDATE SET value=excluded.value",
(key, value),
)
conn.commit()
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 TESM_LOG_DIR; Kea-DHCP separat
unter TESM_KEA_LOG_PATH — Keas AppArmor-Profil erlaubt nur genau diesen
einen Pfad, siehe Kommentar bei TESM_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"{TESM_LOG_DIR}/*.log {TESM_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()
# deleted_at IS NULL: eine in den Papierkorb verschobene Benutzerin
# verliert damit sofort jede bereits laufende Sitzung, nicht erst beim
# nächsten Login-Versuch.
user = conn.execute("SELECT * FROM users WHERE id = ? AND deleted_at IS NULL", (user_id,)).fetchone()
conn.close()
if user:
return _build_user(user)
return None
# ---------------------------------------------------------------------------
# LDAP / Active Directory — optionale Anmeldung mit dem Domänen-Passwort
# zusätzlich zu lokalen Konten (siehe Systemeinstellungen → LDAP).
#
# WICHTIG: das ist "gleiches Passwort" (Same Sign-On), KEIN automatisches,
# promptloses Single Sign-On -- der Benutzer tippt sein AD-Passwort weiterhin
# auf der Login-Seite ein, es wird nur live gegen AD statt gegen ein lokal
# gespeichertes Passwort geprüft. Echtes automatisches SSO bräuchte
# zusätzlich Kerberos/SPNEGO (eigenes SPN + Keytab vom AD-Admin) oder
# SAML/OIDC über Entra ID -- beides technisch klar unterschiedliche, deutlich
# aufwändigere Bausteine, die sich später darauf aufsetzen ließen.
#
# Search+Bind-Muster statt eines direkten UPN-Binds: zuerst mit dem
# konfigurierten Service-Konto verbinden, den Benutzer per konfigurierbarem
# Attribut (Standard sAMAccountName, für generisches LDAP z.B. "uid") suchen,
# dann eine ZWEITE, separate Verbindung mit dessen exakter DN + dem
# eingegebenen Passwort aufbauen, um es zu prüfen. Das funktioniert
# unabhängig davon, ob sAMAccountName und der UPN-Präfix übereinstimmen, und
# damit auch mit generischem (Nicht-AD-)LDAP.
# ---------------------------------------------------------------------------
LDAP_DEFAULT_PORT = 389
LDAP_DEFAULT_FILTER_ATTR = "sAMAccountName"
def _ldap_groups_for_dropdown():
conn = get_db_connection()
rows = conn.execute(
"SELECT id, name, is_default FROM groups WHERE deleted_at IS NULL ORDER BY is_default DESC, name ASC"
).fetchall()
conn.close()
return rows
LDAP_BIND_SERVICE_ACCOUNT_PURPOSE = "ldap_bind"
def _ldap_bind_account():
"""Liest das LDAP-Bind-Konto aus der service_accounts-Tabelle (nicht
aus settings — siehe Kommentar bei der Tabellen-Definition)."""
conn = get_db_connection()
row = conn.execute(
"SELECT username, password FROM service_accounts WHERE purpose=?",
(LDAP_BIND_SERVICE_ACCOUNT_PURPOSE,),
).fetchone()
conn.close()
return (row["username"], row["password"]) if row else ("", "")
def _ldap_settings():
bind_dn, bind_password_enc = _ldap_bind_account()
return {
"enabled": get_setting("ldap_enabled", "0") == "1",
"server": get_setting("ldap_server", "") or "",
"port": get_setting("ldap_port", str(LDAP_DEFAULT_PORT)) or str(LDAP_DEFAULT_PORT),
"use_ssl": get_setting("ldap_use_ssl", "0") == "1",
"tls_skip_verify": get_setting("ldap_tls_skip_verify", "0") == "1",
"bind_dn": bind_dn,
"bind_password_enc": bind_password_enc,
"base_dn": get_setting("ldap_base_dn", "") or "",
"filter_attr": get_setting("ldap_user_filter_attr", LDAP_DEFAULT_FILTER_ATTR) or LDAP_DEFAULT_FILTER_ATTR,
# Fallback für Benutzer, die zu KEINER konfigurierten AD-Gruppen-
# Zuordnung passen (siehe ldap_group_mappings/_ldap_resolve_app_groups)
# -- leer = Systemstandardgruppe.
"default_group": get_setting("ldap_default_group", "") or "",
}
def _ldap_connect_service(cfg):
"""Baut Server-Objekt + Service-Konto-Verbindung auf. Wirft nie —
liefert (server, connection) oder (None, None) samt Fehlermeldung im
zweiten Rückgabewert bei Bedarf über die aufrufende Funktion."""
try:
port = int(cfg["port"])
except (TypeError, ValueError):
port = LDAP_DEFAULT_PORT
try:
bind_password = decrypt_password(cfg["bind_password_enc"]) if cfg["bind_password_enc"] else ""
except Exception:
return None, "Bind-Passwort konnte nicht entschlüsselt werden."
try:
# tls nur relevant, wenn use_ssl gesetzt ist -- ldap3 ignoriert es
# sonst ohnehin, aber so bleibt die Absicht im Code eindeutig.
tls = None
if cfg["use_ssl"] and cfg.get("tls_skip_verify"):
tls = Tls(validate=ssl.CERT_NONE)
server = Server(cfg["server"], port=port, use_ssl=cfg["use_ssl"], tls=tls, get_info=ALL, connect_timeout=5)
conn = Connection(
server, user=cfg["bind_dn"], password=bind_password,
authentication=SIMPLE, auto_bind=True, receive_timeout=5,
)
return (server, conn), None
except LDAPException as e:
return None, str(e)
def _ldap_base_dn(server, cfg):
if cfg["base_dn"].strip():
return cfg["base_dn"].strip()
try:
return str(server.info.other["defaultNamingContext"][0])
except (KeyError, IndexError, TypeError, AttributeError):
return None
def _ldap_test_connection(cfg=None):
"""Für den 'Verbindung testen'-Button in den Systemeinstellungen —
bindet mit dem Service-Konto und ermittelt die (automatische oder
konfigurierte) Base-DN, ohne einen echten Benutzer zu prüfen. Testet
standardmäßig die zuletzt gespeicherten Einstellungen, akzeptiert aber
ein cfg-Override, damit "Verbindung testen" wie beim SSH-Terminal von
Switchen/Geräten die aktuell im Formular eingetragenen (noch nicht
gespeicherten) Werte prüfen kann. Gibt (True, meldung) oder
(False, fehler) zurück."""
cfg = cfg or _ldap_settings()
if not cfg["server"] or not cfg["bind_dn"]:
return False, "Server und Bind-Konto müssen ausgefüllt sein."
result, error = _ldap_connect_service(cfg)
if error:
return False, f"Verbindung/Bind fehlgeschlagen: {error}"
server, conn = result
base_dn = _ldap_base_dn(server, cfg)
conn.unbind()
if not base_dn:
return False, "Verbunden, aber Base-DN konnte nicht automatisch ermittelt werden — bitte manuell angeben."
return True, f"Verbindung erfolgreich. Base-DN: {base_dn}"
_LDAP_ENTRY_ATTRS = ["distinguishedName", "givenName", "sn", "displayName", "userPrincipalName", "userAccountControl"]
def _ldap_find_entry(service_conn, base_dn, cfg, value):
"""Sucht GENAU einen Verzeichniseintrag, dessen konfiguriertes
Login-Attribut (Standard sAMAccountName) ODER UPN (userPrincipalName,
dient hier zugleich als "E-Mail" -- siehe _ldap_entry_info) mit dem
übergebenen Wert übereinstimmt — deckt damit Login per bloßem
Benutzernamen UND per user@domain in einer einzigen Suche ab. Fällt bei
einem LDAP-Fehler (z.B. ein Server ohne userPrincipalName-Attribut im
Schema) auf eine einfache Suche nur nach dem Login-Attribut zurück.
Gibt das ldap3-Entry-Objekt oder None zurück (None auch bei mehr als
einem Treffer — mehrdeutig soll wie "nicht gefunden" behandelt werden)."""
safe_value = escape_filter_chars(value)
combined_filter = f"(|({cfg['filter_attr']}={safe_value})(userPrincipalName={safe_value}))"
try:
service_conn.search(search_base=base_dn, search_filter=combined_filter, attributes=_LDAP_ENTRY_ATTRS)
except LDAPException:
try:
service_conn.search(
search_base=base_dn, search_filter=f"({cfg['filter_attr']}={safe_value})",
attributes=_LDAP_ENTRY_ATTRS,
)
except LDAPException:
return None
if len(service_conn.entries) != 1:
return None
return service_conn.entries[0]
def _ldap_entry_info(entry, cfg):
"""Extrahiert Benutzername/Name/E-Mail/Sperrstatus aus einem
ldap3-Entry. Nutzt givenName/sn (echte AD-Attribute) statt eine
Vermutung anhand von displayName — fällt auf ein Aufsplitten von
displayName nur zurück, wenn givenName/sn nicht gesetzt sind (z.B.
minimalistisches generisches LDAP ohne diese Attribute)."""
def _attr(name):
val = getattr(entry, name, None)
return str(val) if val is not None and val.value else None
canonical_username = _attr(cfg["filter_attr"])
first_name = _attr("givenName")
last_name = _attr("sn")
if not first_name and not last_name:
display_name = _attr("displayName") or ""
first_name, _, last_name = display_name.partition(" ")
first_name = first_name or None
last_name = last_name or None
disabled = False
uac = _attr("userAccountControl")
if uac is not None:
try:
disabled = bool(int(uac) & 2) # ACCOUNTDISABLE-Bit, nur bei AD vorhanden
except ValueError:
pass
return {
"username": canonical_username,
"first_name": first_name,
"last_name": last_name,
# "E-Mail" meint hier bewusst userPrincipalName (z.B.
# "user@ad.firma.local"), nicht das separate "mail"-Attribut -- in AD
# ist der UPN die praxisübliche Login-/Kontaktadresse, "mail" ist oft
# gar nicht gepflegt (genau wie beim Test-Account, der zu dieser
# Entscheidung geführt hat) und wird deshalb hier gar nicht erst
# abgefragt.
"email": _attr("userPrincipalName"),
"disabled": disabled,
}
def _ldap_lookup(username):
"""Wie _ldap_authenticate(), aber OHNE Passwort-Prüfung — für die
Vorab-Zuweisung eines AD-Benutzers zu einer Gruppe, bevor er sich das
erste Mal angemeldet hat (siehe /users/ldap_add). Gibt (True, info)
oder (False, None) zurück."""
cfg = _ldap_settings()
if not cfg["enabled"] or not username:
return False, None
if not cfg["server"] or not cfg["bind_dn"]:
return False, None
result, error = _ldap_connect_service(cfg)
if error:
return False, None
server, service_conn = result
try:
base_dn = _ldap_base_dn(server, cfg)
if not base_dn:
return False, None
entry = _ldap_find_entry(service_conn, base_dn, cfg, username)
if not entry:
return False, None
return True, _ldap_entry_info(entry, cfg)
except LDAPException:
return False, None
finally:
try:
service_conn.unbind()
except Exception:
pass
def _ldap_search_users(query, limit=25):
"""Durchsucht das Verzeichnis per Substring-Suche über Login-Attribut,
Vor-/Nachname und E-Mail — für "Aus Active Directory hinzufügen" in
der Benutzerverwaltung (Vorab-Zuweisung, siehe _ldap_lookup). Gibt eine
Liste von Dicts zurück (siehe _ldap_entry_info), leer bei Fehler/
deaktiviertem LDAP."""
cfg = _ldap_settings()
if not cfg["enabled"] or not query.strip():
return []
if not cfg["server"] or not cfg["bind_dn"]:
return []
result, error = _ldap_connect_service(cfg)
if error:
return []
server, service_conn = result
try:
base_dn = _ldap_base_dn(server, cfg)
if not base_dn:
return []
safe_q = escape_filter_chars(query.strip())
filt = (
f"(&(objectClass=person)"
f"(|({cfg['filter_attr']}=*{safe_q}*)(displayName=*{safe_q}*)(givenName=*{safe_q}*)(sn=*{safe_q}*)(userPrincipalName=*{safe_q}*)))"
)
try:
service_conn.search(search_base=base_dn, search_filter=filt, attributes=_LDAP_ENTRY_ATTRS, size_limit=limit)
except LDAPException:
return []
results = []
for entry in service_conn.entries:
info = _ldap_entry_info(entry, cfg)
if info["username"]:
results.append(info)
return results
except LDAPException:
return []
finally:
try:
service_conn.unbind()
except Exception:
pass
def _ldap_is_member_of(service_conn, user_dn, group_dn):
"""Prüft AD-Gruppenmitgliedschaft REKURSIV (auch über verschachtelte
Gruppen) über LDAP_MATCHING_RULE_IN_CHAIN, statt nur das flache,
nicht-rekursive memberOf-Attribut des Benutzers direkt zu lesen —
jemand in einer Gruppe, die wiederum Mitglied der erforderlichen
Gruppe ist, soll sich genauso anmelden dürfen wie ein direktes
Mitglied."""
try:
service_conn.search(
search_base=user_dn, search_scope=BASE,
search_filter=f"(memberOf:{LDAP_MATCHING_RULE_IN_CHAIN}:={escape_filter_chars(group_dn)})",
attributes=[],
)
return len(service_conn.entries) == 1
except LDAPException:
return False
def _ldap_list_groups(limit=300):
"""Alle AD-Gruppen fürs Auswahl-Dropdown der erforderlichen Gruppe in
den Systemeinstellungen — per Service-Konto, keine Benutzer-Zugangsdaten
nötig. Gibt eine Liste von {"dn":..., "name":...} zurück, sortiert nach
Name, leer bei Fehler."""
cfg = _ldap_settings()
if not cfg["server"] or not cfg["bind_dn"]:
return []
result, error = _ldap_connect_service(cfg)
if error:
return []
server, service_conn = result
try:
base_dn = _ldap_base_dn(server, cfg)
if not base_dn:
return []
try:
service_conn.search(
search_base=base_dn, search_filter="(objectClass=group)",
attributes=["distinguishedName", "cn", "sAMAccountName"], size_limit=limit,
)
except LDAPException:
return []
groups = []
for entry in service_conn.entries:
dn = str(entry.distinguishedName) if "distinguishedName" in entry and entry.distinguishedName.value else None
if not dn:
continue
name = None
if "cn" in entry and entry.cn.value:
name = str(entry.cn)
elif "sAMAccountName" in entry and entry.sAMAccountName.value:
name = str(entry.sAMAccountName)
groups.append({"dn": dn, "name": name or dn})
groups.sort(key=lambda g: g["name"].lower())
return groups
except LDAPException:
return []
finally:
try:
service_conn.unbind()
except Exception:
pass
def _ldap_group_mappings():
conn = get_db_connection()
# LEFT JOIN mit deleted_at-Filter: eine inzwischen in den Papierkorb
# verschobene (oder endgültig gelöschte) Rechtegruppe lässt die
# Zuordnung sichtbar als "(gelöscht)" statt sie stillschweigend so
# anzuzeigen, als würde sie noch normal funktionieren.
rows = conn.execute("""
SELECT m.id, m.ad_group_dn, m.ad_group_name, m.app_group_id,
CASE
WHEN m.app_group_id = 'admin' THEN 'Admin'
WHEN g.name IS NULL THEN '(gelöscht)'
ELSE g.name
END AS app_group_name
FROM ldap_group_mappings m
LEFT JOIN groups g ON g.id = m.app_group_id AND g.deleted_at IS NULL
ORDER BY m.ad_group_name ASC
""").fetchall()
conn.close()
return rows
def _ldap_resolve_app_groups(service_conn, user_dn):
"""Prüft für jede konfigurierte AD-Gruppen-Zuordnung, ob der Benutzer
Mitglied ist (auch über verschachtelte Gruppen, siehe
_ldap_is_member_of), und gibt die Menge der zugeordneten App-Gruppen
zurück -- normale Gruppen-IDs (int) sowie ggf. das Sentinel "admin".
Additiv gedacht: mehrere passende AD-Gruppen ergeben die Vereinigung
ihrer App-Gruppen, passend zum bestehenden Rechtesystem (mehrere
Gruppen je Benutzer, Rechte addieren sich)."""
matched = set()
for mapping in _ldap_group_mappings():
if _ldap_is_member_of(service_conn, user_dn, mapping["ad_group_dn"]):
matched.add(mapping["app_group_id"])
return matched
def _ldap_authenticate(username, password):
"""Prüft Zugangsdaten per LDAP/AD (Search+Bind, siehe _ldap_find_entry
für die Details der Suche). Gibt (True, info) bei Erfolg zurück (info
wie bei _ldap_entry_info), sonst (False, None) — nie eine Exception
nach außen, ein LDAP-Fehler soll wie eine falsche Anmeldung behandelt
werden, nicht die Login-Seite mit einem 500er abbrechen."""
cfg = _ldap_settings()
if not cfg["enabled"] or not username or not password:
return False, None
if not cfg["server"] or not cfg["bind_dn"]:
return False, None
result, error = _ldap_connect_service(cfg)
if error:
return False, None
server, service_conn = result
try:
base_dn = _ldap_base_dn(server, cfg)
if not base_dn:
return False, None
entry = _ldap_find_entry(service_conn, base_dn, cfg, username)
if not entry:
return False, None
user_dn = str(entry.distinguishedName)
info = _ldap_entry_info(entry, cfg)
if info["disabled"]:
return False, None
# Welche App-Gruppen dem Benutzer laut AD-Gruppenmitgliedschaft
# zustehen (siehe ldap_group_mappings) -- ohne konfigurierte
# Zuordnung dürfen sich weiterhin alle Domänen-Benutzer anmelden
# (Standardgruppe greift dann beim Provisionieren).
info["app_groups"] = _ldap_resolve_app_groups(service_conn, user_dn)
except LDAPException:
return False, None
finally:
try:
service_conn.unbind()
except Exception:
pass
try:
user_conn = Connection(
server, user=user_dn, password=password,
authentication=SIMPLE, auto_bind=True, receive_timeout=5,
)
user_conn.unbind()
except LDAPException:
return False, None
return True, info
# ---------------------------------------------------------------------------
# Auth
# ---------------------------------------------------------------------------
def _assign_ldap_groups(conn, user_id, app_groups):
"""Weist die per AD-Gruppenzuordnung ermittelten App-Gruppen zu — rein
additiv (nimmt nie eine bereits vorhandene, z.B. manuell zugewiesene
Gruppe wieder weg), damit ein Admin-Eingriff nie durch den nächsten
Login überschrieben wird. Das Sentinel "admin" setzt is_admin direkt."""
if "admin" in app_groups:
conn.execute("UPDATE users SET is_admin=1 WHERE id=?", (user_id,))
for group_id in app_groups:
if group_id == "admin":
continue
# Zielgruppe könnte inzwischen in den Papierkorb verschoben oder
# sogar endgültig gelöscht worden sein -- dann keine (dann sinnlose
# oder gar auf nichts mehr zeigende) Mitgliedschaft eintragen.
if not conn.execute("SELECT 1 FROM groups WHERE id=? AND deleted_at IS NULL", (group_id,)).fetchone():
continue
conn.execute("INSERT OR IGNORE INTO user_groups (user_id, group_id) VALUES (?, ?)", (user_id, group_id))
def _ldap_default_group_id(conn):
default_group = get_setting("ldap_default_group", "")
if default_group:
# Konfigurierte Gruppe könnte inzwischen in den Papierkorb verschoben
# worden sein -- dann auf den Systemstandard zurückfallen, statt eine
# (dann möglicherweise sogar endgültig gelöschte) Gruppen-ID stumpf
# weiterzuverwenden.
if conn.execute(
"SELECT 1 FROM groups WHERE id=? AND deleted_at IS NULL", (default_group,)
).fetchone():
return default_group
row = conn.execute(
"SELECT id FROM groups WHERE name=? AND deleted_at IS NULL", (DEFAULT_GROUP_NAME,)
).fetchone()
return row["id"] if row else None
@app.route("/login", methods=["GET", "POST"])
def login():
if request.method == "POST":
username = request.form["username"].strip()
password = request.form["password"]
conn = get_db_connection()
# 1) Exakter Benutzername-Treffer (Groß-/Kleinschreibung wie
# eingegeben) -- deckt lokale Konten UNVERÄNDERT wie bisher ab, und
# bereits per LDAP angelegte Konten, wenn der Benutzername
# konsistent geschrieben wird.
# deleted_at IS NULL bei allen drei Lookups: ein in den Papierkorb
# verschobenes lokales Konto darf sich nicht mehr anmelden UND soll
# als "nicht gefunden" behandelt werden (nicht z.B. mit einer extra
# Fehlermeldung, die verriete, dass der Name existiert).
user = conn.execute(
"SELECT * FROM users WHERE username = ? AND deleted_at IS NULL", (username,)
).fetchone()
# 2) Kein exakter Treffer -- per E-Mail suchen (case-insensitiv).
# Deckt sowohl lokale Konten mit hinterlegter E-Mail als auch per
# LDAP angelegte Konten ab (deren E-Mail-Spalte den UPN enthält).
# Login-Logik umfasst damit genau vier Fälle: lokaler Benutzername,
# lokale E-Mail, AD sAMAccountName, AD userPrincipalName.
if not user:
user = conn.execute(
"SELECT * FROM users WHERE email = ? COLLATE NOCASE AND email IS NOT NULL AND email != '' "
"AND deleted_at IS NULL",
(username,),
).fetchone()
# 3) Immer noch nichts -- unter den PER LDAP angelegten Konten
# zusätzlich case-insensitiv nach dem Benutzernamen suchen
# (AD-Benutzernamen sind case-insensitiv; ohne das könnte "JDoe"
# und "jdoe" sonst zwei verschiedene lokale Schatten-Konten
# erzeugen). Wirkt sich NIE auf lokale Konten aus.
if not user:
user = conn.execute(
"SELECT * FROM users WHERE auth_source='ldap' AND username = ? COLLATE NOCASE AND deleted_at IS NULL",
(username,),
).fetchone()
if user and user["is_locked"]:
# Gesperrt (siehe Benutzerverwaltung) -- unabhängig von
# Passwort/AD-Status. Der einzige zuverlässige Weg, einem
# AD-Konto den Zugriff über die App wirklich zu entziehen, ohne
# es in AD selbst zu deaktivieren.
conn.close()
flash("Dieses Konto ist gesperrt.", "danger")
return render_template("login.html")
if user and user["auth_source"] == "ldap":
# Passwort wird NIE lokal geprüft (der gespeicherte Hash ist nur
# ein nie verwendeter Platzhalter) -- bei JEDEM Login live gegen
# AD verifiziert, damit eine Passwortänderung in AD sofort greift,
# ohne dass hier etwas synchronisiert werden müsste.
ok, info = _ldap_authenticate(user["username"], password)
if ok:
# Name/E-Mail bei jedem Login aus AD auffrischen (können sich
# dort ändern), und ggf. neu hinzugekommene AD-Gruppen-
# Zuordnungen zusätzlich zuweisen (nie etwas wegnehmen).
conn.execute(
"UPDATE users SET first_name=?, last_name=?, email=? WHERE id=?",
(info.get("first_name"), info.get("last_name"), info.get("email"), user["id"]),
)
_assign_ldap_groups(conn, user["id"], info.get("app_groups") or set())
conn.commit()
refreshed = conn.execute("SELECT * FROM users WHERE id=?", (user["id"],)).fetchone()
conn.close()
login_user(_build_user(refreshed))
return redirect(url_for("index"))
conn.close()
flash("Ungültiger Benutzername oder Passwort", "danger")
elif user:
conn.close()
if bcrypt.check_password_hash(user["password"], password):
login_user(_build_user(user))
return redirect(url_for("index"))
flash("Ungültiger Benutzername oder Passwort", "danger")
else:
# Kein lokales Konto mit diesem Namen/dieser E-Mail -- bei
# aktivierter LDAP-Anmeldung gegen AD prüfen (per Benutzername,
# UPN oder E-Mail, siehe _ldap_find_entry) und beim ersten
# erfolgreichen Login automatisch ein lokales Konto anlegen.
ok, info = _ldap_authenticate(username, password)
if ok:
canonical_username = info.get("username") or username
placeholder_hash = bcrypt.generate_password_hash(secrets.token_hex(32)).decode("utf-8")
app_groups = info.get("app_groups") or set()
is_admin_new = 1 if "admin" in app_groups else 0
try:
cur = conn.execute(
"INSERT INTO users (username, password, is_admin, first_name, last_name, email, auth_source) "
"VALUES (?, ?, ?, ?, ?, ?, 'ldap')",
(canonical_username, placeholder_hash, is_admin_new,
info.get("first_name"), info.get("last_name"), info.get("email")),
)
if app_groups:
_assign_ldap_groups(conn, cur.lastrowid, app_groups)
else:
default_group = _ldap_default_group_id(conn)
if default_group:
conn.execute(
"INSERT OR IGNORE INTO user_groups (user_id, group_id) VALUES (?, ?)",
(cur.lastrowid, default_group),
)
conn.commit()
new_user = conn.execute("SELECT * FROM users WHERE id=?", (cur.lastrowid,)).fetchone()
conn.close()
log_action_system("user.ldap_provision", canonical_username, "Erstes erfolgreiches AD/LDAP-Login")
login_user(_build_user(new_user))
return redirect(url_for("index"))
except sqlite3.IntegrityError:
conn.close()
flash("Ungültiger Benutzername oder Passwort", "danger")
else:
conn.close()
flash("Ungültiger Benutzername oder Passwort", "danger")
return render_template("login.html")
@app.route("/logout")
@login_required
def logout():
logout_user()
# 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"))
# ---------------------------------------------------------------------------
# Eigenes Profil / Konto — jeder eingeloggte Benutzer darf Vor-/Nachname,
# eigenes Passwort und Profilbild selbst ändern (Zahnrad-Symbol neben dem
# eigenen Namen unten in der Sidebar). Bewusst getrennt vom Admin-"Users"-
# Bereich, der andere Benutzer verwaltet. Die Navbar-Reihenfolge (siehe
# save_nav_order()) konfigurieren Admins unter Systemeinstellungen, nicht hier.
# ---------------------------------------------------------------------------
@app.route("/account")
@login_required
def account():
return render_template("account.html")
@app.route("/profile", methods=["POST"])
@login_required
def profile():
conn = get_db_connection()
if "update_profile" in request.form:
first_name = request.form.get("first_name", "").strip() or None
last_name = request.form.get("last_name", "").strip() or None
conn.execute(
"UPDATE users SET first_name=?, last_name=? WHERE id=?",
(first_name, last_name, current_user.id),
)
conn.commit()
log_action("profile.update", current_user.username)
flash("Profil aktualisiert.", "success")
elif "change_password" in request.form:
if current_user.is_ldap_user:
# Das gespeicherte Passwort ist nur ein nie geprüfter
# Platzhalter (siehe Login-Handler) -- der bcrypt-Vergleich
# unten würde ohnehin nie zutreffen, aber mit einer irreführenden
# "aktuelles Passwort falsch"-Meldung statt einer klaren Ansage.
flash("Dieses Konto meldet sich über Active Directory an — das Passwort wird dort verwaltet.", "danger")
conn.close()
return redirect(url_for("account"))
current_password = request.form.get("current_password", "")
new_password = request.form.get("new_password", "")
confirm_password = request.form.get("confirm_password", "")
row = conn.execute("SELECT password FROM users WHERE id=?", (current_user.id,)).fetchone()
if not row or not bcrypt.check_password_hash(row["password"], current_password):
flash("Aktuelles Passwort ist falsch.", "danger")
elif not new_password or new_password != confirm_password:
flash("Neues Passwort und Bestätigung stimmen nicht überein.", "danger")
else:
pw_hash = bcrypt.generate_password_hash(new_password).decode("utf-8")
conn.execute("UPDATE users SET password=? WHERE id=?", (pw_hash, current_user.id))
conn.commit()
log_action("profile.password", current_user.username)
flash("Passwort geändert.", "success")
elif "upload_avatar" in request.form:
file = request.files.get("avatar")
if not file or not file.filename:
flash("Bitte ein Bild auswählen.", "danger")
else:
ext = file.filename.rsplit(".", 1)[-1].lower() if "." in file.filename else ""
if ext not in ALLOWED_AVATAR_EXT:
flash("Nur PNG, JPG, GIF oder WEBP erlaubt.", "danger")
else:
old_row = conn.execute(
"SELECT avatar_filename FROM users WHERE id=?", (current_user.id,)
).fetchone()
filename = secure_filename(f"user_{current_user.id}_{secrets.token_hex(4)}.{ext}")
file.save(os.path.join(AVATAR_DIR, filename))
conn.execute("UPDATE users SET avatar_filename=? WHERE id=?", (filename, current_user.id))
conn.commit()
if old_row and old_row["avatar_filename"]:
old_path = os.path.join(AVATAR_DIR, old_row["avatar_filename"])
if os.path.isfile(old_path):
os.remove(old_path)
flash("Profilbild aktualisiert.", "success")
conn.close()
return redirect(request.referrer or url_for("index"))
# ---------------------------------------------------------------------------
# Log-Auswertung (Erreichbarkeits-Status je Gerät)
# ---------------------------------------------------------------------------
def _latest_log_file():
"""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 TESM_LIVE_LOG_PATH if os.path.exists(TESM_LIVE_LOG_PATH) else None
def get_last_seen(dev_name: str):
"""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(TESM_LIVE_LOG_PATH + "*")
if not log_files:
return None
latest_time = None
for logfile in sorted(log_files):
try:
with open(logfile, "r") as f:
for line in f:
line = line.strip()
if f"{dev_name} ist erreichbar!" in line:
try:
ts_str = line.split(" ")[0] + " " + line.split(" ")[1]
ts = datetime.strptime(ts_str, "%Y-%m-%d %H:%M:%S")
if latest_time is None or ts > latest_time:
latest_time = ts
except Exception:
continue
except OSError:
continue
if latest_time:
return latest_time.strftime("Zuletzt Online am %d.%m.%Y um %H:%M Uhr")
return None
_LOG_LINE_RE = re.compile(
r"^(\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}) (.+?) ist (erreichbar|nicht erreichbar)!$"
)
def _format_ts(ts_str: str) -> str:
try:
return datetime.strptime(ts_str, "%Y-%m-%d %H:%M:%S").strftime("%d.%m.%Y %H:%M")
except ValueError:
return ts_str
def get_last_run_at():
"""Zeitpunkt des letzten Prüf-Durchlaufs (jüngste Zeile im aktuellsten
Logfile) — Basis für den globalen 'Nächste Prüfung'-Countdown in der
Topbar, unabhängig von einer bestimmten Geräteliste."""
latest_log = _latest_log_file()
if not latest_log:
return None
latest_ts_str = None
with open(latest_log, "r") as f:
for line in f:
m = _LOG_LINE_RE.match(line.strip())
if m and (latest_ts_str is None or m.group(1) > latest_ts_str):
latest_ts_str = m.group(1)
if not latest_ts_str:
return None
try:
return datetime.strptime(latest_ts_str, "%Y-%m-%d %H:%M:%S")
except ValueError:
return None
@app.context_processor
def inject_check_timer():
"""Stellt den 'Nächste Prüfung'-Countdown global für die Topbar bereit
(auf jeder Seite sichtbar, siehe base.html) — unabhängig davon, ob die
jeweilige Route selbst etwas mit Geräten/Logs zu tun hat."""
interval = int(get_setting("interval", 5))
last_run_at = get_last_run_at()
return {
"global_check_interval": interval,
"global_last_run_epoch_ms": int(last_run_at.timestamp() * 1000) if last_run_at else None,
}
@app.context_processor
def inject_dhcp_topbar_status():
"""Ampel-Punkt für den Kea-Dienst neben dem Prüfintervall-Timer — nur
für Benutzer mit mindestens Leserecht auf DHCP, und nur ein einzelner,
kurzer systemctl-Aufruf (nicht die volle Status-Erkennung von
_detect_dhcp_status), damit das nicht jede Seite spürbar verlangsamt."""
if not current_user.is_authenticated or not current_user.has_permission("settings_dhcp.view"):
return {"dhcp_topbar_active": None}
try:
result = subprocess.run(["systemctl", "is-active", DHCP_SERVICE], capture_output=True, text=True, timeout=2)
active = result.stdout.strip() == "active"
except Exception:
active = None
return {"dhcp_topbar_active": active}
def get_device_status(devices):
"""
Ermittelt aus dem aktuellsten Logfile in einem Durchlauf je Gerät:
- status: online / offline / unbekannt
- last_checked: Zeitpunkt der letzten Prüfung (unabhängig vom Ergebnis)
- last_seen: Zeitpunkt des letzten "online"-Status (nur für aktuell offline Geräte,
über alle Logfiles hinweg — siehe get_last_seen)
- last_run_at: Zeitpunkt (datetime) des letzten Prüf-Durchlaufs insgesamt
(jüngste Zeile im Logfile) — Basis für den Countdown "Nächste Prüfung".
"""
status_dict, last_seen_dict, last_checked_dict = {}, {}, {}
latest_log = _latest_log_file()
if not latest_log:
return status_dict, last_seen_dict, last_checked_dict, None
name_to_mac = {d["name"]: d["mac"] for d in devices}
last_line_for_name = {}
latest_ts_str = None
with open(latest_log, "r") as f:
for line in f:
m = _LOG_LINE_RE.match(line.strip())
if not m:
continue
ts_str, name, state = m.groups()
if latest_ts_str is None or ts_str > latest_ts_str:
latest_ts_str = ts_str
if name in name_to_mac:
last_line_for_name[name] = (ts_str, state == "erreichbar")
for dev in devices:
entry = last_line_for_name.get(dev["name"])
if entry:
ts_str, is_online = entry
status_dict[dev["mac"]] = "online" if is_online else "offline"
last_checked_dict[dev["mac"]] = _format_ts(ts_str)
if not is_online:
last_seen_dict[dev["mac"]] = get_last_seen(dev["name"])
else:
status_dict[dev["mac"]] = "unbekannt"
last_run_at = None
if latest_ts_str:
try:
last_run_at = datetime.strptime(latest_ts_str, "%Y-%m-%d %H:%M:%S")
except ValueError:
last_run_at = None
return status_dict, last_seen_dict, last_checked_dict, last_run_at
# ---------------------------------------------------------------------------
# Dashboard
# ---------------------------------------------------------------------------
def _dashboard_context():
"""Gemeinsam von index() und dem AJAX-Partial /dashboard/tiles genutzt,
damit das Live-Update des Dashboards (ohne vollen Seiten-Reload) exakt
dieselben Daten liefert wie der initiale Seitenaufruf."""
conn = get_db_connection()
all_devices = conn.execute(
"SELECT mac, name, ip, switch_hostname, port, is_active FROM devices WHERE deleted_at IS NULL ORDER BY name ASC"
).fetchall()
conn.close()
interval = int(get_setting("interval", 5))
status, last_seen, last_checked, last_run_at = get_device_status(all_devices)
is_authenticated = current_user.is_authenticated
def by_name(devs):
return sorted(devs, key=lambda d: d["name"].lower())
offline_devices = by_name(
d for d in all_devices if d["is_active"] and status.get(d["mac"]) != "online"
)
online_devices = by_name(
d for d in all_devices if d["is_active"] and status.get(d["mac"]) == "online"
)
disabled_devices = by_name(d for d in all_devices if not d["is_active"]) if is_authenticated else []
visible_devices = offline_devices + online_devices + disabled_devices
devices = offline_devices + online_devices # für Kompatibilität/Zähler
stats = {"online": len(online_devices), "offline": len(offline_devices), "total": len(devices)}
if is_authenticated:
stats["total"] = len(all_devices)
stats["disabled"] = len(disabled_devices)
last_run_epoch_ms = int(last_run_at.timestamp() * 1000) if last_run_at else None
return dict(
offline_devices=offline_devices, online_devices=online_devices, disabled_devices=disabled_devices,
device_count=len(visible_devices),
status=status, last_seen=last_seen,
last_checked=last_checked, interval=interval, stats=stats,
last_run_epoch_ms=last_run_epoch_ms,
)
@app.route("/")
def index():
"""
Dashboard als Kachel-Ansicht, in drei Abschnitten: Offline, Online,
Deaktiviert (jeweils alphabetisch) — jeweils nur gerendert, wenn nicht
leer. Ohne Login: keine deaktivierten Geräte, nur Online/Offline/Gesamt-
Statistik, keine Interaktion. Eingeloggt: alle Geräte, volle
Statistik-Kacheln, Klick-Details, manueller PoE-Neustart bzw. bei
deaktivierten Geräten eine Aktivieren-Option.
"""
return render_template("index.html", **_dashboard_context())
@app.route("/dashboard/tiles")
def dashboard_tiles():
"""AJAX-Partial für das Live-Update der Dashboard-Kacheln (Status,
Statistik) ohne vollen Seiten-Reload — liefert dasselbe Fragment, das
index.html beim initialen Aufruf einbindet (siehe _dashboard_tiles.html)."""
return render_template("_dashboard_tiles.html", **_dashboard_context())
def _restart_check_service():
"""Startet tesm-check.service (poe_wrapper.py -> poe.sh) neu, damit ein
geändertes Prüfintervall sofort greift (poe.sh liest SLEEP nur einmal
beim Start aus der DB) — läuft dabei automatisch auch ein sofortiger
Prüf-Durchlauf mit frischem Logfile an. Schlägt in Umgebungen ohne
systemd (z.B. Container) lautlos fehl, statt die Anfrage abzubrechen."""
try:
subprocess.run(["systemctl", "restart", "tesm-check.service"], check=False)
except FileNotFoundError:
pass # z.B. lokale Entwicklungsumgebung ohne systemd
@app.route("/check/run-now", methods=["POST"])
@login_required
def run_check_now():
"""Manueller 'Jetzt prüfen'-Trigger (Zahnrad-Icon neben dem Prüf-Timer):
startet denselben Service neu wie eine Intervalländerung — das erzeugt
sofort ein frisches Logfile mit einem neuen Durchlauf. Liefert den neuen
Zeitpunkt direkt in der Antwort mit, damit app.js den Topbar-Countdown
auf JEDER Seite zurücksetzen kann (vorher aktualisierte nur die
Dashboard-eigene Live-Aktualisierung die Anzeige — auf allen anderen
Seiten lief der Countdown mit dem alten Zeitpunkt weiter, obwohl im
Hintergrund längst neu geprüft wurde)."""
if not current_user.is_admin:
return {"success": False, "msg": "Keine Berechtigung."}, 403
_restart_check_service()
log_action("check.run_now", "Prüfintervall")
# Kurz warten, bis der frisch gestartete Dienst den ersten Durchlauf ins
# neue Logfile geschrieben hat — dann kann die Antwort den aktuellen
# Zeitpunkt direkt mitliefern, ohne dass das Frontend dafür einen
# zweiten Request/Endpunkt braucht (Flask läuft threaded, blockiert also
# keine anderen Anfragen währenddessen).
time.sleep(1.5)
last_run_at = get_last_run_at()
return {
"success": True,
"msg": "Prüfung gestartet.",
"last_run_epoch_ms": int(last_run_at.timestamp() * 1000) if last_run_at else None,
}
# ---------------------------------------------------------------------------
# Settings
# ---------------------------------------------------------------------------
# ---------------------------------------------------------------------------
# System-Netzwerkeinstellungen (IP/DNS/DHCP DIESES Hosts — nicht der
# Kea-DHCP-*Server* für Client-Geräte weiter unten). Eine falsche Änderung
# kann die Erreichbarkeit kappen, daher: Backend-Erkennung statt Annahme
# (unbekannt -> rein lesend), Backup vor jeder Änderung, und ein
# Sicherheits-Timer (analog "netplan try") der ohne explizite Bestätigung
# innerhalb von NETWORK_REVERT_SECONDS automatisch zurückrollt.
# ---------------------------------------------------------------------------
NETWORK_REVERT_SECONDS = 45
_pending_network_revert = {} # {"timer": Timer, "backend":..., "interface":..., "backup": {...}}
# Eigene netplan-Override-Datei (siehe _apply_network_config) — bewusst nicht
# die von cloud-init/dem Distributor mitgelieferte Datei, damit ein Rollback
# nie versehentlich fremden Inhalt überschreibt.
NETPLAN_CONFIG_PATH = "/etc/netplan/90-tesm.yaml"
def _detect_network_backend():
"""Rein lesend: welcher der gängigen Netzwerk-Manager ist aktiv? Keine
Annahme, falls keiner läuft (z.B. in dieser WSL-Testumgebung, die ihr
Netz über den Hyper-V-Adapter bezieht) — dann bleibt die Seite bewusst
rein lesend."""
for service, name in (("NetworkManager", "networkmanager"), ("dhcpcd", "dhcpcd")):
try:
result = subprocess.run(["systemctl", "is-active", service], capture_output=True, text=True, timeout=5)
if result.stdout.strip() == "active":
return name
except Exception:
pass
# Standard-Ubuntu (Server- wie Cloud-Images) verwaltet sein Netz
# inzwischen über netplan + systemd-networkd statt NetworkManager oder
# dhcpcd — mit Abstand der häufigste Fall auf einem "normalen" frisch
# installierten Ubuntu-Host (live an einer echten Ubuntu-24.04-VM
# verifiziert, die ihre IP per DHCP bezieht).
try:
result = subprocess.run(["systemctl", "is-active", "systemd-networkd"], capture_output=True, text=True, timeout=5)
if result.stdout.strip() == "active" and shutil.which("netplan"):
return "netplan"
except Exception:
pass
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:
return [line.split()[1] for line in f if line.strip().startswith("nameserver")]
except OSError:
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,
Modus (dhcp/static) backend-spezifisch ermittelt. Jeder Erkennungsschritt
ist defensiv (try/except) — im Zweifel lieber "unbekannt" anzeigen als
einen falschen Zustand zu behaupten."""
net_info = _detect_interface_network(interface)
mode = "unknown"
if backend == "networkmanager":
try:
dev_out = subprocess.run(
["nmcli", "-t", "-f", "GENERAL.CONNECTION", "device", "show", interface],
capture_output=True, text=True, timeout=5,
)
conn_name = dev_out.stdout.split(":", 1)[1].strip() if ":" in dev_out.stdout else None
if conn_name:
method_out = subprocess.run(
["nmcli", "-g", "ipv4.method", "connection", "show", conn_name],
capture_output=True, text=True, timeout=5,
)
mode = "static" if method_out.stdout.strip() == "manual" else "dhcp"
except Exception:
pass
elif backend == "dhcpcd":
try:
with open("/etc/dhcpcd.conf", encoding="utf-8") as f:
content = f.read()
block_match = re.search(rf"^interface\s+{re.escape(interface)}\s*$(.*?)(?=^interface\s|\Z)", content, re.S | re.M)
block = block_match.group(1) if block_match else ""
mode = "static" if re.search(r"^\s*static\s+ip_address=", block, re.M) else "dhcp"
except OSError:
mode = "dhcp"
elif backend == "netplan":
# Netplan/systemd-networkd markiert eine per DHCP bezogene Adresse
# im Kernel mit "dynamic" — zuverlässiger als die YAML-Dateien unter
# /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_configured_dns(interface), "mode": mode, "backend": backend}
def _backup_network_config(interface, backend):
"""Kompletten Ist-Zustand vor einer Änderung sichern, damit
_revert_network_config() ihn exakt wiederherstellen kann."""
state = _read_network_state(interface, backend)
backup = {
"interface": interface, "backend": backend, "mode": state["mode"],
"ip": state.get("ip"), "prefix": state.get("prefix"), "gateway": state.get("gateway"),
"dns": state.get("dns", []),
}
if backend == "dhcpcd":
try:
with open("/etc/dhcpcd.conf", encoding="utf-8") as f:
backup["dhcpcd_conf"] = f.read()
except OSError:
backup["dhcpcd_conf"] = None
elif backend == "networkmanager":
try:
dev_out = subprocess.run(
["nmcli", "-t", "-f", "GENERAL.CONNECTION", "device", "show", interface],
capture_output=True, text=True, timeout=5,
)
backup["nm_connection"] = dev_out.stdout.split(":", 1)[1].strip() if ":" in dev_out.stdout else None
except Exception:
backup["nm_connection"] = None
elif backend == "netplan":
# Eigene Override-Datei sichern statt der von cloud-init/dem
# Distributor mitgelieferten Dateien — None heißt "existierte vorher
# nicht", dann löscht der Revert sie wieder statt sie leer zu lassen.
try:
with open(NETPLAN_CONFIG_PATH, encoding="utf-8") as f:
backup["netplan_conf"] = f.read()
except OSError:
backup["netplan_conf"] = None
return backup
def _apply_network_config(backend, interface, mode, ip, prefix, gateway, dns_list):
"""Wendet die neue Konfiguration über das erkannte Backend an. Gibt
(ok, message) zurück statt zu werfen, damit der Aufrufer immer eine
Flash-Meldung bekommt statt eines 500ers."""
dns_csv = ",".join(dns_list)
if backend == "networkmanager":
try:
dev_out = subprocess.run(
["nmcli", "-t", "-f", "GENERAL.CONNECTION", "device", "show", interface],
capture_output=True, text=True, timeout=5,
)
conn_name = dev_out.stdout.split(":", 1)[1].strip() if ":" in dev_out.stdout else None
if not conn_name:
return False, f"Keine aktive NetworkManager-Verbindung für {interface} gefunden."
args = ["nmcli", "connection", "modify", conn_name]
if mode == "static":
args += ["ipv4.method", "manual", "ipv4.addresses", f"{ip}/{prefix}", "ipv4.gateway", gateway]
else:
args += ["ipv4.method", "auto", "ipv4.addresses", "", "ipv4.gateway", ""]
if dns_csv:
args += ["ipv4.dns", dns_csv, "ipv4.ignore-auto-dns", "yes"]
else:
args += ["ipv4.dns", "", "ipv4.ignore-auto-dns", "no"]
ok, out = _dhcp_run_privileged(args, timeout=15)
if not ok:
return False, out
return _dhcp_run_privileged(["nmcli", "connection", "up", conn_name], timeout=20)
except Exception as e:
return False, str(e)
if backend == "dhcpcd":
try:
with open("/etc/dhcpcd.conf", encoding="utf-8") as f:
content = f.read()
content = re.sub(rf"^interface\s+{re.escape(interface)}\s*$(.*?)(?=^interface\s|\Z)", "", content, flags=re.S | re.M)
block_lines = [f"interface {interface}"]
if mode == "static":
block_lines.append(f"static ip_address={ip}/{prefix}")
if gateway:
block_lines.append(f"static routers={gateway}")
if dns_list:
block_lines.append(f"static domain_name_servers={' '.join(dns_list)}")
if len(block_lines) > 1:
content = content.rstrip() + "\n\n" + "\n".join(block_lines) + "\n"
with open("/etc/dhcpcd.conf", "w", encoding="utf-8") as f:
f.write(content)
return _dhcp_run_privileged(["systemctl", "restart", "dhcpcd"], timeout=20)
except Exception as e:
return False, str(e)
if backend == "netplan":
try:
eth_cfg = {}
if mode == "static":
eth_cfg["dhcp4"] = False
eth_cfg["addresses"] = [f"{ip}/{prefix}"]
if gateway:
eth_cfg["routes"] = [{"to": "default", "via": gateway}]
else:
eth_cfg["dhcp4"] = True
if dns_list:
# Ohne diesen Override würde networkd die eigenen
# DNS-Server mit den per DHCP gelieferten zusammen
# verwenden, statt sie wie bei den anderen Backends
# (ipv4.ignore-auto-dns / dhcpcd) vollständig zu ersetzen.
eth_cfg["dhcp4-overrides"] = {"use-dns": False}
if dns_list:
eth_cfg["nameservers"] = {"addresses": dns_list}
# Bestehende Datei einlesen und nur den Block DIESES Interfaces
# ersetzen/ergänzen -- ein Host kann mehrere Interfaces haben,
# jedes über einen eigenen Aufruf dieser Seite konfiguriert. Ein
# kompletter Neuaufbau des Dokuments (wie ursprünglich) hätte bei
# jedem Speichern alle ANDEREN, bereits konfigurierten Interfaces
# aus der Datei geworfen -- deren Konfiguration fiele dadurch auf
# die Distributor-Datei zurück (i.d.R. DHCP), obwohl an ihnen gar
# nichts geändert wurde (live reproduziert: zweites Interface
# eingerichtet, erstes verlor dadurch seine statische IP).
try:
with open(NETPLAN_CONFIG_PATH, encoding="utf-8") as f:
existing_doc = yaml.safe_load(f) or {}
except OSError:
existing_doc = {}
ethernets = ((existing_doc.get("network") or {}).get("ethernets") or {}).copy()
ethernets[interface] = eth_cfg
doc = {"network": {"version": 2, "ethernets": ethernets}}
# Eigene, niedrig priorisierte Datei statt der von cloud-init
# mitgelieferten (z.B. 50-cloud-init.yaml) — netplan wendet
# Dateien in alphabetischer Reihenfolge an und lässt spätere
# Werte pro Schlüssel gewinnen, das ist hier der etablierte Weg
# für eigene Overrides statt die Distributor-Datei zu editieren.
with open(NETPLAN_CONFIG_PATH, "w", encoding="utf-8") as f:
yaml.safe_dump(doc, f, default_flow_style=False)
os.chmod(NETPLAN_CONFIG_PATH, 0o600)
return _dhcp_run_privileged(["netplan", "apply"], timeout=20)
except Exception as e:
return False, str(e)
return False, "Kein unterstütztes Netzwerk-Backend erkannt (weder NetworkManager, dhcpcd noch netplan aktiv)."
def _revert_network_config(token):
"""Timer-Callback: falls binnen NETWORK_REVERT_SECONDS keine Bestätigung
einging, gesicherten Zustand wiederherstellen. Läuft in einem
Hintergrund-Thread (threading.Timer) — Fehler werden geloggt statt die
App zum Absturz zu bringen."""
entry = _pending_network_revert.get(token)
if not entry:
return
backup = entry["backup"]
try:
if backup["backend"] == "dhcpcd" and backup.get("dhcpcd_conf") is not None:
with open("/etc/dhcpcd.conf", "w", encoding="utf-8") as f:
f.write(backup["dhcpcd_conf"])
subprocess.run(["systemctl", "restart", "dhcpcd"], timeout=20)
elif backup["backend"] == "networkmanager":
_apply_network_config(
"networkmanager", backup["interface"],
backup["mode"], backup.get("ip"), backup.get("prefix"), backup.get("gateway"), backup.get("dns", []),
)
elif backup["backend"] == "netplan":
if backup.get("netplan_conf") is not None:
with open(NETPLAN_CONFIG_PATH, "w", encoding="utf-8") as f:
f.write(backup["netplan_conf"])
else:
# Datei existierte vor der Änderung nicht (erste Netplan-
# Änderung über diese Seite) — dann muss sie beim Rollback
# auch wieder verschwinden statt leer zurückzubleiben.
try:
os.remove(NETPLAN_CONFIG_PATH)
except OSError:
pass
subprocess.run(["netplan", "apply"], timeout=20)
# Das Rollback ändert die Netzwerkkonfiguration erneut — falls
# zwischenzeitlich (z.B. manuell auf der DHCP-Seite) wieder gestartet,
# aus demselben Grund wie beim ursprünglichen Anwenden vorsorglich
# stoppen statt mit einer ggf. nicht mehr passenden Range weiterlaufen
# zu lassen.
if _stop_dhcp_service_if_active():
log_action_system("settings.network_revert", backup["interface"], "DHCP-Dienst wegen Rollback gestoppt")
log_action_system("settings.network_revert", backup["interface"], "automatisch nach Timeout zurückgerollt")
except Exception as e:
app.logger.error("Network auto-revert failed: %s", e)
finally:
_pending_network_revert.pop(token, None)
@app.route("/settings", methods=["GET", "POST"])
@login_required
def settings():
if request.method == "GET" and not current_user.can_view_settings_system:
flash("Keine Berechtigung, die Systemeinstellungen anzusehen.", "danger")
return redirect(url_for("index"))
interval = int(get_setting("interval", 5))
if request.method == "POST":
if not current_user.has_permission("settings_system.edit"):
flash("Keine Berechtigung, die Systemeinstellungen zu ändern.", "danger")
return redirect(url_for("settings"))
if "interval" in request.form:
new_interval = int(request.form["interval"])
set_setting("interval", new_interval)
set_setting("check_interval", new_interval * 60)
log_action("settings.update", "Prüfintervall", f"{new_interval} Minuten")
_restart_check_service()
flash(f"Intervall auf {new_interval} Minuten gesetzt und Service neu gestartet!", "success")
elif "apply_network" in request.form:
interface = request.form.get("net_interface", "").strip()
mode = request.form.get("net_mode", "dhcp")
ip = request.form.get("net_ip", "").strip()
prefix = request.form.get("net_prefix", "").strip()
gateway = request.form.get("net_gateway", "").strip()
dns_list = [d.strip() for d in request.form.get("net_dns", "").split(",") if d.strip()]
backend = _detect_network_backend()
if interface not in _list_network_interfaces():
flash(f"Interface „{interface}“ existiert nicht auf diesem Host.", "danger")
elif backend == "unknown":
flash("Kein unterstütztes Netzwerk-Backend erkannt — Änderungen über die App sind deaktiviert.", "danger")
elif mode == "static" and not (ip and prefix and gateway):
flash("Für eine statische Konfiguration werden IP-Adresse, Prefix und Gateway benötigt.", "danger")
else:
backup = _backup_network_config(interface, backend)
ok, out = _apply_network_config(backend, interface, mode, ip, prefix, gateway, dns_list)
if ok:
token = secrets.token_hex(8)
timer = threading.Timer(NETWORK_REVERT_SECONDS, _revert_network_config, args=(token,))
timer.daemon = True
_pending_network_revert.clear() # nur eine ausstehende Änderung gleichzeitig
_pending_network_revert[token] = {"timer": timer, "backup": backup}
timer.start()
# Damit die Seite beim nächsten Laden das zuletzt bearbeitete
# statt immer nur das alphabetisch erste Interface zeigt --
# gerade bei mehreren Interfaces sonst verwirrend, siehe
# net_interface-Fallback weiter unten.
set_setting("net_interface", interface)
log_action("settings.network_apply", interface, f"Modus {mode}")
msg = (
f"Netzwerkkonfiguration angewendet. Falls diese Seite jetzt noch erreichbar ist, bitte "
f"unten bestätigen — sonst wird nach {NETWORK_REVERT_SECONDS}s automatisch zurückgerollt."
)
# Eine vorher passende DHCP-Range kann nach einer IP-/Subnetzänderung
# plötzlich zum falschen Netz gehören — der Dienst wird deshalb
# vorsorglich gestoppt statt (ggf. falsch) weiterzulaufen. Bewusst
# kein automatischer Neustart: die Range muss der Admin aktiv auf
# der DHCP-Seite prüfen/bestätigen, siehe Kommentar oben am Kea-Block.
if _stop_dhcp_service_if_active():
log_action("settings.network_apply", interface, "DHCP-Dienst wegen Netzwerkänderung gestoppt")
msg += (
" Der DHCP-Server wurde dabei gestoppt und deaktiviert, da die konfigurierte Range zum "
"neuen Netz nicht mehr passen könnte — bitte auf der DHCP-Seite prüfen und bei Bedarf "
"neu starten (bleibt sonst auch nach einem Host-Neustart aus)."
)
flash(msg, "success")
else:
flash(f"Anwenden fehlgeschlagen: {out}", "danger")
elif "hostname" in request.form:
new_hostname = request.form.get("hostname", "").strip()
if not re.match(r"^[a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?$", new_hostname):
flash("Ungültiger Hostname (nur Buchstaben, Ziffern und Bindestrich, max. 63 Zeichen).", "danger")
else:
ok, out = _dhcp_run_privileged(["hostnamectl", "set-hostname", new_hostname], timeout=10)
if ok:
log_action("settings.update", "Hostname", new_hostname)
flash(f"Hostname auf „{new_hostname}“ gesetzt.", "success")
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 tesm.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)
if entry:
entry["timer"].cancel()
log_action("settings.network_confirm", entry["backup"]["interface"])
flash("Netzwerkkonfiguration bestätigt — kein automatisches Rollback mehr.", "success")
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")
elif "save_trash_retention" in request.form:
new_days_raw = request.form.get("trash_retention_days", "").strip()
if not new_days_raw.isdigit() or int(new_days_raw) < 1:
flash("Aufbewahrungsdauer muss eine Zahl ≥ 1 sein.", "danger")
else:
set_setting("trash_retention_days", new_days_raw)
log_action("settings.update", "Papierkorb-Aufbewahrung", f"{new_days_raw} Tage")
flash(f"Papierkorb-Aufbewahrung auf {new_days_raw} Tage gesetzt.", "success")
elif "purge_trash_now" in request.form:
_purge_expired_trash()
log_action("settings.update", "Papierkorb sofort bereinigt")
flash("Abgelaufene Papierkorb-Einträge wurden endgültig gelöscht.", "success")
# LDAP/Active Directory hat seit dem Umzug auf eine eigene
# Unterseite (Übersichtlichkeit) eine eigene Route -- siehe
# settings_ldap().
return redirect(url_for("settings"))
net_backend = _detect_network_backend()
all_interfaces = _list_network_interfaces()
net_interface = get_setting("net_interface") or (all_interfaces or [None])[0]
net_state = _read_network_state(net_interface, net_backend) if net_interface else None
# Übersicht ganz oben zeigt JEDES tatsächlich aktive Interface (eigene
# IPv4-Adresse vorhanden) gleichzeitig, nicht nur das gerade im
# Formular ausgewählte -- bei mehreren Interfaces sonst leicht der
# Eindruck, das zweite existiere für die App gar nicht (live so
# gemeldet: die Übersicht zeigte nach dem Umschalten der Auswahl
# weiterhin nur die Werte des ersten Interfaces).
active_net_states = [
{"interface": iface, **_read_network_state(iface, net_backend)}
for iface in all_interfaces
]
active_net_states = [s for s in active_net_states if s.get("ok")]
pending_token = next(iter(_pending_network_revert), None)
try:
current_hostname = socket.gethostname()
except Exception:
current_hostname = None
return render_template(
"settings.html",
interval=interval,
current_hostname=current_hostname,
net_backend=net_backend,
net_interfaces=all_interfaces,
net_interface=net_interface,
net_state=net_state,
active_net_states=active_net_states,
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": TESM_LIVE_LOG_PATH,
"changes": TESM_CHANGES_LOG_PATH,
"app": TESM_APP_LOG_PATH,
"kea": TESM_KEA_LOG_PATH,
},
current_timezone=_current_timezone(),
timezones=_list_timezones(),
full_nav_items=_ordered_nav_items() if current_user.is_admin else [],
trash_retention_days=_trash_retention_days(),
)
@app.route("/settings/network_state")
@login_required
def settings_network_state():
"""JSON-Endpunkt für die Interface-Auswahl auf der Netzwerkeinstellungen-
Karte -- liefert den tatsächlichen Ist-Zustand DES ausgewählten
Interfaces, damit die Eingabemaske beim Umschalten live aktualisiert
wird statt weiter die Werte des zuvor angezeigten Interfaces zu zeigen
(bei mehreren Interfaces sonst leicht mit dessen Konfiguration zu
verwechseln -- live reproduziert)."""
if not current_user.can_view_settings_system:
return jsonify({"error": "Keine Berechtigung."}), 403
interface = request.args.get("interface", "").strip()
if interface not in _list_network_interfaces():
return jsonify({"error": "Unbekanntes Interface."}), 404
backend = _detect_network_backend()
state = _read_network_state(interface, backend)
# "network" ist ein ipaddress.IPv4Network-Objekt (siehe
# _detect_interface_network) -- nicht JSON-serialisierbar und vom
# Frontend hier auch nicht benötigt (nur ip/prefix/gateway/dns/mode).
state.pop("network", None)
return jsonify(state)
@app.route("/settings/import-export")
@login_required
def settings_import_export():
if not current_user.can_view_settings_importexport:
flash("Keine Berechtigung für Im-/Export.", "danger")
return redirect(url_for("index"))
return render_template(
"settings_import_export.html",
export_sections=EXPORT_SECTIONS,
admin_only_sections=IMPORT_EXPORT_ADMIN_ONLY_SECTIONS,
)
# ---------------------------------------------------------------------------
# DHCP-Reservierungen (Kea DHCP)
#
# Backend bewusst Kea statt isc-dhcp-server (seit 2022 EOL) — Kea bildet
# "globaler Wert, pro Client überschreibbar" nativ über Reservation-Options
# ab. Installation & Dienststeuerung sind eigene, einzeln bestätigte
# Aktionen statt an "Konfiguration speichern" gekoppelt, da ein
# unerwarteter DHCP-Server ein Netzsegment stören kann.
# ---------------------------------------------------------------------------
DHCP_PACKAGE = "kea-dhcp4-server"
DHCP_SERVICE = "kea-dhcp4-server"
# Explizit statt dem eingebauten Kea-Default gesetzt (siehe _render_kea_config)
# — Anzeige der aktiven Leases (_dhcp_read_leases) liest genau diesen Pfad,
# damit beide Seiten garantiert übereinstimmen, statt sich auf einen
# distributionsabhängigen impliziten Default zu verlassen.
KEA_LEASE_FILE = "/var/lib/kea/kea-leases4.csv"
# Interface, Range, Gateway und DNS sind seit der Mehrfach-Subnetz-
# Unterstützung KEINE globalen Einstellungen mehr, sondern je Zeile in der
# dhcp_subnets-Tabelle gepflegt (siehe _dhcp_subnets/_dhcp_matching_network)
# — ein Host kann mehrere IPs/Interfaces mit jeweils eigenem Netz haben.
# Domain, Lease-Zeiten und Ausgabepfad gelten dagegen für den gesamten
# Kea-Dienst und bleiben deshalb global.
DHCP_DEFAULTS = {
"dhcp_domain": "local",
"dhcp_lease_default": "600",
"dhcp_lease_max": "7200",
"dhcp_output_path": "/etc/kea/kea-dhcp4.conf",
}
def _dhcp_subnets(conn):
return conn.execute("SELECT * FROM dhcp_subnets ORDER BY sort_order ASC, id ASC").fetchall()
def _dhcp_subnet_matches(subnet_row):
"""Validiert eine einzelne Subnetz-Zeile gegen das tatsächliche Netz
ihres Interfaces (siehe _dhcp_matching_network) und gibt bei Erfolg
(net_info, None) zurück, sonst (None, Fehlertext). Eine Subnetz-Zeile
ohne passendes reales Netz kann Kea nicht zugeordnet werden — es muss
eine physische IP im gewünschten Bereich vorhanden sein, sonst fehlt
die CIDR/Netzmaske, die Kea für den subnet4-Block braucht."""
return _dhcp_matching_network(subnet_row["interface"], subnet_row["range_start"], subnet_row["range_end"])
# Auswahl an Kea-Datentypen für eigene Options — bewusst eine kompatible
# Teilmenge statt der vollen Liste, orientiert an den in der Praxis
# gebrauchten Fällen (Text, IP, Zahl, Wahrheitswert, Rohdaten/Hex).
DHCP_OPTION_TYPES = [
("string", "Text (string)"),
("ipv4-address", "IP-Adresse (ipv4-address)"),
("uint8", "Zahl, 1 Byte (uint8)"),
("uint16", "Zahl, 2 Byte (uint16)"),
("uint32", "Zahl, 4 Byte (uint32)"),
("boolean", "Wahrheitswert (boolean)"),
("binary", "Hexadezimal/Rohdaten (binary)"),
]
DHCP_OPTION_TYPE_KEYS = {t[0] for t in DHCP_OPTION_TYPES}
def _load_dhcp_settings():
return {key: get_setting(key, default) for key, default in DHCP_DEFAULTS.items()}
def _detect_dhcp_status():
"""Rein lesende Erkennung, ob Kea auf diesem Host installiert ist und
läuft — löst selbst nichts aus. Installation/Start laufen ausschließlich
über die separaten, bestätigten Aktionen weiter unten."""
kea_path = shutil.which("kea-dhcp4")
installed = kea_path is not None
active = None
enabled = None
version = None
if installed:
try:
version_out = subprocess.run(
["kea-dhcp4", "-v"], capture_output=True, text=True, timeout=5
)
out = (version_out.stdout or version_out.stderr or "").strip()
version = out.splitlines()[0] if out else None
except Exception:
version = None
try:
status = subprocess.run(
["systemctl", "is-active", DHCP_SERVICE], capture_output=True, text=True, timeout=5,
)
active = status.stdout.strip() == "active"
except Exception:
active = None
try:
enabled_out = subprocess.run(
["systemctl", "is-enabled", DHCP_SERVICE], capture_output=True, text=True, timeout=5,
)
enabled = enabled_out.stdout.strip() == "enabled"
except Exception:
enabled = None
return {"installed": installed, "path": kea_path, "active": active, "enabled": enabled, "version": version}
def _stop_dhcp_service_if_active():
"""Stoppt UND deaktiviert den Kea-DHCP-Dienst, falls er gerade läuft —
aufgerufen, wenn sich die Host-Netzwerkkonfiguration ändert
(Systemeinstellungen > Netzwerkeinstellungen). Eine vorher passende
Range/Interface-Bindung kann nach einer IP-/Subnetzänderung plötzlich
zum falschen Netz gehören. Bewusst nur stoppen, nicht automatisch neu
starten — der Admin muss die Range aktiv auf der DHCP-Seite
prüfen/bestätigen, statt dass die App mit einer möglicherweise falschen
Konfiguration weiterläuft oder blind rät. Zusätzlich deaktivieren (nicht
nur stoppen), damit ein Host-Neustart vor dieser Prüfung den Dienst
nicht trotzdem automatisch mit der ggf. falschen Range wieder hochfährt."""
try:
status = subprocess.run(["systemctl", "is-active", DHCP_SERVICE], capture_output=True, text=True, timeout=5)
if status.stdout.strip() != "active":
return False
ok = subprocess.run(["systemctl", "stop", DHCP_SERVICE], timeout=15).returncode == 0
subprocess.run(["systemctl", "disable", DHCP_SERVICE], timeout=15)
return ok
except Exception:
return False
def _list_network_interfaces():
"""Echte Netzwerkschnittstellen dieses Hosts (ohne loopback) — Quelle
für die Interface-Auswahl, damit nur tatsächlich vorhandene Interfaces
wählbar sind statt eines frei eintippbaren Textfelds."""
try:
return sorted(name for name in os.listdir("/sys/class/net") if name != "lo")
except OSError:
return []
def _detect_interface_networks(interface):
"""Wie _detect_interface_network() (siehe unten), aber für ALLE IPv4-
Adressen des Interfaces statt nur der ersten — ein Interface kann
mehrere IPs/Subnetze gleichzeitig tragen (z.B. Alias-IPs oder mehrere
Ranges auf derselben Karte). Gibt eine Liste von net_info-Dicts
zurück (leer, falls keine IPv4-Adresse gefunden wurde/das Interface
nicht existiert)."""
if not interface:
return []
try:
addr_out = subprocess.run(
["ip", "-4", "-o", "addr", "show", "dev", interface],
capture_output=True, text=True, timeout=5,
)
route_out = subprocess.run(
["ip", "-4", "route", "show", "default", "dev", interface],
capture_output=True, text=True, timeout=5,
)
gw_match = re.search(r"via (\d+\.\d+\.\d+\.\d+)", route_out.stdout)
if not gw_match:
# Fallback: irgendeine Default-Route, falls keine speziell an
# dieses Interface gebunden ist (typisch bei Hosts mit nur
# einer aktiven Schnittstelle).
route_out = subprocess.run(
["ip", "-4", "route", "show", "default"], capture_output=True, text=True, timeout=5,
)
gw_match = re.search(r"via (\d+\.\d+\.\d+\.\d+)", route_out.stdout)
gateway = gw_match.group(1) if gw_match else None
results = []
for line in addr_out.stdout.splitlines():
match = re.search(r"inet (\d+\.\d+\.\d+\.\d+)/(\d+)", line)
if not match:
continue
ip_str, prefix = match.group(1), int(match.group(2))
network = ipaddress.IPv4Network(f"{ip_str}/{prefix}", strict=False)
# Der Kernel markiert eine per DHCP bezogene Adresse mit "dynamic" —
# unabhängig vom Backend eine zuverlässige Quelle für den aktuellen
# DHCP/Statisch-Modus (genutzt von der netplan-Erkennung unten).
dynamic = "dynamic" in line
results.append({
"ok": True, "ip": ip_str, "prefix": prefix, "network": network,
"netmask": str(network.netmask), "gateway": gateway, "dynamic": dynamic, "error": None,
})
return results
except Exception:
return []
def _detect_interface_network(interface):
"""Liest Subnet, Netzmaske und Gateway der PRIMÄREN (ersten) IPv4-
Adresse eines Interfaces direkt aus der laufenden System-
Netzwerkkonfiguration aus (statt sie manuell pflegen zu lassen) —
dadurch koppelt sich die generierte DHCP-Konfiguration immer an das
Netz, in dem der Host tatsächlich hängt, auch wenn sich dessen
IP/Subnet mal ändert. Trägt das Interface mehrere IPv4-Adressen,
siehe _detect_interface_networks() für alle."""
if not interface:
return {"ok": False, "ip": None, "prefix": None, "netmask": None, "network": None, "gateway": None,
"dynamic": None, "error": "Kein Interface ausgewählt."}
networks = _detect_interface_networks(interface)
if not networks:
return {"ok": False, "ip": None, "prefix": None, "netmask": None, "network": None, "gateway": None,
"dynamic": None, "error": f"Keine IPv4-Adresse auf „{interface}“ gefunden."}
return networks[0]
def _dhcp_matching_network(interface, range_start, range_end):
"""Prüft eine DHCP-Range gegen ALLE auf dem Interface aktuell
konfigurierten IPv4-Netze (ein Interface kann mehrere IPs/Subnetze
tragen, z.B. Alias-IPs) und gibt bei Erfolg (net_info, None) zurück,
sonst (None, Fehlertext). Verhindert, dass eine Range gespeichert,
geschrieben oder ein Dienststart erlaubt wird, die zu keinem
tatsächlich vorhandenen Subnetz passt — z.B. wenn sich das Netz des
Hosts seit dem letzten Speichern geändert hat."""
try:
start_ip = ipaddress.IPv4Address(range_start)
end_ip = ipaddress.IPv4Address(range_end)
except ValueError:
return None, "Range Start/Ende müssen gültige IPv4-Adressen sein."
if int(start_ip) > int(end_ip):
return None, "Range Start muss kleiner oder gleich Range Ende sein."
networks = _detect_interface_networks(interface)
if not networks:
return None, f"Keine IPv4-Adresse auf „{interface}“ gefunden."
for net_info in networks:
if start_ip in net_info["network"] and end_ip in net_info["network"]:
return net_info, None
nets_str = ", ".join(str(n["network"]) for n in networks)
return None, f"Range {range_start}{range_end} liegt in keinem der auf „{interface}“ erkannten Netze ({nets_str})."
def _dhcp_safe_hostname(name, mac):
"""Wandelt einen Client-Namen in einen für Kea-Reservierungen/hostname
gültigen Bezeichner um (nur a-z0-9-, keine führenden/abschließenden/
doppelten Bindestriche). Fällt bei leerem Ergebnis auf die letzten 4
Byte der MAC zurück, damit nie ein leerer oder doppelter Bezeichner
entsteht."""
slug = re.sub(r"[^a-z0-9-]+", "-", (name or "").strip().lower()).strip("-")
slug = re.sub(r"-{2,}", "-", slug)
if slug:
return slug
return "geraet-" + re.sub(r"[^0-9a-f]", "", mac.lower())[-8:]
def _dhcp_valid_subnets(subnets):
"""Nimmt die rohen dhcp_subnets-Zeilen und validiert JEDE einzeln gegen
ihr tatsächliches Netz (siehe _dhcp_subnet_matches — es muss eine
physische IP im gewünschten Bereich vorhanden sein, sonst kann Kea
damit nichts anfangen). Deaktivierte Subnetze (enabled=0) werden dabei
komplett übersprungen — bewusst nicht als "invalid" gewertet, da das
Fehlen eines echten Netzes hier kein Fehler ist, sondern Absicht.
Gibt (valid, invalid) zurück: valid ist um "net_info" angereichert (für
Reservierungs-Zuordnung und Kea-Config), invalid enthält (Zeile,
Fehlertext) für die Anzeige/Warnungen."""
valid, invalid = [], []
for s in subnets:
if not s["enabled"]:
continue
net_info, error = _dhcp_subnet_matches(s)
if error:
invalid.append((s, error))
else:
valid.append({
"id": s["id"], "interface": s["interface"],
"range_start": s["range_start"], "range_end": s["range_end"],
"gateway": s["gateway"], "dns": s["dns"], "net_info": net_info,
})
return valid, invalid
def _dhcp_reservation_candidates(conn, valid_subnets):
"""Baut die Liste der Kea-Reservierungen aus ZWEI Quellen: automatisch
aus aktiven Geräten mit gültiger MAC + IP (Clients dieser App) sowie
zusätzlich aus manuell angelegten Reservierungen (dhcp_manual_reservations
— für Geräte, die nicht als Client gepflegt werden, z.B. ein externes
Gerät, dem bewusst eine feste IP gegeben werden soll). Bei einer MAC in
beiden Quellen gewinnt die automatische (echte Client-Daten vor einer
ggf. veralteten manuellen Zeile). Geräte ohne ip/mac werden
übersprungen statt eine kaputte Reservierung zu erzeugen. Ebenso
übersprungen: Einträge, deren IP zu KEINEM der übergebenen (bereits
validierten) Subnetze gehört — Kea lehnt eine Reservierung außerhalb
ihres subnet4-Blocks als Konfigurationsfehler ab (mit einem Live-Test
gegen echtes Kea verifiziert). Gibt (reservations_by_subnet_id,
out_of_subnet_count) zurück — jeder Eintrag landet bei genau einem
Subnetz, dem seine IP tatsächlich zugehört (ein Host kann mehrere
Subnetze gleichzeitig anbieten)."""
auto_rows = conn.execute(
"SELECT mac, ip, name FROM devices "
"WHERE is_active=1 AND deleted_at IS NULL AND mac IS NOT NULL AND ip IS NOT NULL ORDER BY name ASC"
).fetchall()
manual_rows = conn.execute("SELECT id, mac, ip, name FROM dhcp_manual_reservations ORDER BY name ASC").fetchall()
# Case-insensitiv nach MAC dedupliziert (Geräte/manuelle Zeilen können
# unterschiedlich geschriebene MACs haben) — automatisch gewinnt immer.
candidates = {} # MAC (upper) -> {mac, ip, name, source, manual_id}
for row in manual_rows:
candidates[row["mac"].upper()] = {
"mac": row["mac"], "ip": row["ip"], "name": row["name"], "source": "manual", "manual_id": row["id"],
}
for row in auto_rows:
candidates[row["mac"].upper()] = {
"mac": row["mac"], "ip": row["ip"], "name": row["name"], "source": "auto", "manual_id": None,
}
seen_hostnames_by_subnet = {s["id"]: set() for s in valid_subnets}
reservations_by_subnet = {s["id"]: [] for s in valid_subnets}
out_of_subnet = 0
for c in sorted(candidates.values(), key=lambda c: c["name"]):
try:
ip = ipaddress.IPv4Address(c["ip"])
except ValueError:
out_of_subnet += 1
continue
matched = next((s for s in valid_subnets if ip in s["net_info"]["network"]), None)
if matched is None:
out_of_subnet += 1
continue
hostname = _dhcp_safe_hostname(c["name"], c["mac"])
# Kollisionen bei generierten Namen (z.B. zwei Geräte mit gleichem
# Namen im selben Subnetz) auflösen, statt zwei Reservierungen mit
# demselben Bezeichner zu erzeugen (Kea würde das als Fehler ablehnen).
seen = seen_hostnames_by_subnet[matched["id"]]
base_hostname, suffix = hostname, 2
while hostname in seen:
hostname = f"{base_hostname}-{suffix}"
suffix += 1
seen.add(hostname)
reservations_by_subnet[matched["id"]].append({
"mac": c["mac"], "ip": c["ip"], "hostname": hostname, "name": c["name"], "source": c["source"],
"manual_id": c["manual_id"], "subnet_id": matched["id"],
"subnet_label": f"{matched['interface']} ({matched['net_info']['network']})",
})
return reservations_by_subnet, out_of_subnet
def _dhcp_option_defs(conn):
return conn.execute(
"SELECT id, code, name, type, description, is_standard FROM dhcp_option_defs ORDER BY code ASC"
).fetchall()
def _dhcp_option_values(conn):
"""{option_def_id: {"": <globaler Wert>, "<mac>": <Override>, ...}}
— device_mac='' ist der globale Wert (siehe create_db.py)."""
values = {}
for row in conn.execute("SELECT option_def_id, device_mac, value FROM dhcp_option_values"):
values.setdefault(row["option_def_id"], {})[row["device_mac"]] = row["value"]
return values
def _dhcp_read_leases():
"""Liest die aktuell aktiven Kea-Leases direkt aus der memfile-CSV
(siehe lease-file in _render_kea_config) — kein Kea-Control-Agent
nötig, nur Lesezugriff auf die Datei. Wichtig, um Clients OHNE eigene
Reservierung sichtbar zu machen, die sich einfach eine freie IP aus dem
Pool genommen haben (siehe reserved-Flag, wird vom Aufrufer ergänzt).
Gibt None zurück, falls die Datei (noch) nicht existiert — z.B. weil
der Dienst noch nie gestartet wurde oder noch keine Adresse vergeben
hat — statt fälschlich "keine Leases" zu behaupten."""
try:
with open(KEA_LEASE_FILE, newline="", encoding="utf-8") as f:
rows = list(csv.DictReader(f))
except OSError:
return None
now = int(time.time())
# Kea's memfile-Backend HÄNGT bei jeder Verlängerung eine neue Zeile an,
# statt die alte zu ersetzen -- dieselbe Adresse steht oft dutzendfach
# in der Datei, mit jeweils weiter in die Zukunft geschobenem "expire".
# Da Kea eine Lease schon bei ~50% der Laufzeit erneuert (nicht erst bei
# Ablauf), sind kurz nach einer Verlängerung sowohl die alte als auch
# die neue Zeile noch "nicht abgelaufen" -- ohne Deduplizierung nach
# Adresse würde dieselbe Lease doppelt in der Übersicht auftauchen.
by_ip = {}
for row in rows:
# state: 0 = aktiv/default, 1 = "declined" (Konflikt erkannt),
# 2 = "expired-reclaimed" (von Kea bereits aufgeräumt) — nur 0 ist
# eine tatsächlich vergebene, gültige Adresse.
if (row.get("state") or "0") != "0":
continue
try:
expire = int(row.get("expire") or 0)
except ValueError:
expire = 0
# Abgelaufen, aber von der periodischen Lease-File-Cleanup (lfc)
# noch nicht aus der CSV entfernt — nicht als aktiv anzeigen.
if expire and expire < now:
continue
ip = row.get("address") or ""
existing = by_ip.get(ip)
if existing is not None and (existing.get("expire") or 0) >= expire:
continue # bereits eine neuere (oder gleich neue) Zeile für diese IP gesehen
by_ip[ip] = {"expire": expire, "row": row}
leases = []
for ip, entry in by_ip.items():
row, expire = entry["row"], entry["expire"]
leases.append({
"ip": ip,
"mac": (row.get("hwaddr") or "").upper(),
"hostname": row.get("hostname") or "",
# Absolut UND als Epoch-ms für einen live tickenden Countdown im
# Browser (siehe initLeaseCountdowns in app.js) — eine reine
# Absolutzeit beim Seitenaufruf wirkt bei kurzen Lease-Zeiten
# (Default 600s) schnell "schon abgelaufen", sobald etwas Zeit
# vergangen ist, bis man sie tatsächlich anschaut. Explizit als
# UTC ausgewiesen, da Kea intern in UTC-Epoch arbeitet.
"expires_at": datetime.utcfromtimestamp(expire).strftime("%Y-%m-%d %H:%M:%S") if expire else None,
"expire_epoch_ms": expire * 1000 if expire else None,
})
try:
leases.sort(key=lambda l: ipaddress.IPv4Address(l["ip"]))
except ValueError:
pass
return leases
def _dhcp_delete_lease(ip):
"""Entfernt ALLE Zeilen einer Adresse direkt aus der Kea-Lease-CSV
(memfile-Backend, siehe _dhcp_read_leases für dieselbe Datei) und
startet den laufenden Dienst neu, damit Kea die Datei beim Hochfahren
frisch neu einliest -- ein bloßes Editieren bei laufendem Dienst würde
sonst von Keas eigenem, periodischem Lease-File-Cleanup irgendwann
wieder mit dem alten Stand aus dem Speicher überschrieben. Startet den
Dienst nur neu, wenn er gerade aktiv ist (kein ungefragtes Hochfahren
eines bewusst gestoppten Dienstes). Gibt (ok, message) zurück."""
try:
original_stat = os.stat(KEA_LEASE_FILE)
with open(KEA_LEASE_FILE, newline="", encoding="utf-8") as f:
reader = csv.DictReader(f)
fieldnames = reader.fieldnames
rows = list(reader)
except OSError:
return False, "Keine Lease-Datei gefunden."
remaining = [r for r in rows if (r.get("address") or "") != ip]
if len(remaining) == len(rows):
return False, f"Keine Lease für {ip} in der Datei gefunden."
try:
# lineterminator="\n" ist entscheidend: der csv-Modul-Default ist
# "\r\n" (RFC 4180), Keas eigener Writer schreibt aber reines "\n" --
# ohne diese explizite Angabe interpretiert Kea beim nächsten Start
# das "\r" als Teil des letzten Spaltennamens ("pool_id\r") und
# lehnt die ganze Datei mit "invalid column" ab (live reproduziert).
with open(KEA_LEASE_FILE, "w", newline="", encoding="utf-8") as f:
writer = csv.DictWriter(f, fieldnames=fieldnames, lineterminator="\n")
writer.writeheader()
writer.writerows(remaining)
# Diese App läuft als root, öffnet die Datei aber mit "w" -- das
# legt sie neu an und würde sie root:root statt dem Kea-Dienstnutzer
# gehören lassen. Kea (i.d.R. eigener Systemnutzer, z.B. "_kea")
# kann dann zwar noch lesen, aber keine neuen Leases mehr
# hineinschreiben. Eigentümer/Rechte der Originaldatei deshalb
# explizit wiederherstellen (live reproduziert).
os.chown(KEA_LEASE_FILE, original_stat.st_uid, original_stat.st_gid)
os.chmod(KEA_LEASE_FILE, stat.S_IMODE(original_stat.st_mode))
except OSError as e:
return False, f"Lease-Datei konnte nicht geschrieben werden: {e}"
try:
status = subprocess.run(["systemctl", "is-active", DHCP_SERVICE], capture_output=True, text=True, timeout=5)
active = status.stdout.strip() == "active"
except Exception:
active = False
if active:
ok_restart, restart_out = _dhcp_run_privileged(["systemctl", "restart", DHCP_SERVICE], timeout=30)
if not ok_restart:
return False, f"Lease für {ip} aus der Datei entfernt, Neustart des Dienstes fehlgeschlagen: {restart_out}"
return True, f"Lease für {ip} gelöscht."
def _render_kea_config(cfg, valid_subnets, reservations_by_subnet, option_defs, option_values):
"""Baut die komplette Kea-DHCPv4-Konfiguration (als dict, JSON-
serialisierbar) — ein subnet4-Block je konfiguriertem Subnetz, damit ein
Host mehrere IPs/Interfaces mit jeweils eigenem Netz gleichzeitig
bedienen kann. Subnet/Netzmaske kommen bewusst NICHT aus manueller
Eingabe, sondern aus dem net_info jedes Subnetzes (siehe
_dhcp_valid_subnets) — nur Subnetze mit einer zu einem echten,
tatsächlich vorhandenen Netz passenden Range fließen hier ein
(`valid_subnets`, bereits gefiltert). Gateway/DNS sind je Subnetz
eigenes option-data (unterschiedliche Netze haben i.d.R.
unterschiedliche Router); Domain und selbstdefinierte Options bleiben
global. Client-Overrides landen im option-data der jeweiligen
Reservierung — Kea überschreibt damit automatisch gleichnamige
globale/Subnet-Options für genau diesen Client, ohne dass wir das
selbst zusammenrechnen müssen."""
# Standard-Optionen (is_standard) bekommen KEIN eigenes option-def — Kea
# kennt sie bereits nativ, eine Neudefinition würde die eingebaute
# duplizieren (siehe DHCP_STANDARD_OPTIONS). Nur eigene Options brauchen
# ein option-def, damit Kea Name/Typ/Code überhaupt kennt.
option_def_list = [
{"name": d["name"], "code": d["code"], "type": d["type"], "space": "dhcp4"}
for d in option_defs if not d["is_standard"]
]
global_option_data = []
if cfg["dhcp_domain"]:
global_option_data.append({"name": "domain-name", "data": cfg["dhcp_domain"]})
for d in option_defs:
global_value = option_values.get(d["id"], {}).get("")
if global_value:
global_option_data.append({"name": d["name"], "data": global_value})
subnet4 = []
for idx, s in enumerate(valid_subnets, start=1):
# Gateway: expliziter Override hat Vorrang, sonst automatisch
# erkanntes Gateway dieses Hosts — für den Fall, dass Clients einen
# anderen Router bekommen sollen als den, über den dieser Host für
# dieses Netz selbst geht.
router = (s["gateway"] or "").strip() or s["net_info"].get("gateway") or ""
dns_servers = ", ".join(x.strip() for x in (s["dns"] or "").split(",") if x.strip())
sub_option_data = []
if router:
sub_option_data.append({"name": "routers", "data": router})
if dns_servers:
sub_option_data.append({"name": "domain-name-servers", "data": dns_servers})
kea_reservations = []
for r in reservations_by_subnet.get(s["id"], []):
res_option_data = []
for d in option_defs:
override = option_values.get(d["id"], {}).get(r["mac"])
if override:
res_option_data.append({"name": d["name"], "data": override})
entry = {
"hw-address": r["mac"].lower(),
"ip-address": r["ip"],
"hostname": r["hostname"],
}
if res_option_data:
entry["option-data"] = res_option_data
kea_reservations.append(entry)
subnet4.append({
"id": idx,
"subnet": f"{s['net_info']['network'].network_address}/{s['net_info']['network'].prefixlen}",
"pools": [{"pool": f"{s['range_start']} - {s['range_end']}"}],
**({"option-data": sub_option_data} if sub_option_data else {}),
"reservations": kea_reservations,
})
return {
"Dhcp4": {
"interfaces-config": {"interfaces": sorted({s["interface"] for s in valid_subnets})},
# Kea nennt den Datei-Pfad des memfile-Backends "name", nicht
# "lease-file" (mit falschem Schlüssel lehnt Kea die Config mit
# "unexpected keyword" ab — live so reproduziert und gefixt).
"lease-database": {"type": "memfile", "lfc-interval": 3600, "name": KEA_LEASE_FILE},
"valid-lifetime": int(cfg["dhcp_lease_default"] or 600),
"max-valid-lifetime": int(cfg["dhcp_lease_max"] or 7200),
"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": TESM_KEA_LOG_PATH}],
"severity": "INFO",
}
],
}
}
def _dhcp_run_privileged(args, timeout):
"""Führt einen Installations-/Dienst-Befehl aus und fasst das Ergebnis
einheitlich zusammen — wirft nie, damit der aufrufende Code immer eine
Flash-Meldung statt eines 500ers bekommt."""
try:
result = subprocess.run(args, capture_output=True, text=True, timeout=timeout)
return result.returncode == 0, (result.stdout or "") + (result.stderr or "")
except Exception as e:
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
def _dhcp_write_and_restart(conn):
"""Schreibt die Kea-Konfiguration aus dem aktuellen DB-Stand neu und
startet den Dienst neu, FALLS er gerade aktiv ist -- aufgerufen direkt
nach jeder Änderung, die sich auf die geschriebene Konfiguration
auswirkt (Reservierung, Option, Subnetz, globale Einstellung), damit
eine Änderung sofort wirksam wird und ein manuelles "Datei schreiben" +
"Neu starten" nicht mehr vergessen werden kann. Schreibt nur, wenn
mindestens ein gültiges Subnetz konfiguriert ist (sonst gäbe es nichts
Sinnvolles zu schreiben); startet nur neu, wenn der Dienst bereits
läuft -- ein bewusst gestoppter Dienst wird dadurch nicht ungefragt
wieder hochgefahren. Flasht nur bei einem tatsächlichen Fehler etwas
Zusätzliches, ein stiller Erfolg reiht sich in die ohnehin schon
gezeigte Erfolgsmeldung der jeweiligen Aktion ein."""
cfg = _load_dhcp_settings()
valid_subnets, _ = _dhcp_valid_subnets(_dhcp_subnets(conn))
if not valid_subnets:
return
reservations_by_subnet, _ = _dhcp_reservation_candidates(conn, valid_subnets)
option_defs = _dhcp_option_defs(conn)
option_values = _dhcp_option_values(conn)
content = json.dumps(
_render_kea_config(cfg, valid_subnets, reservations_by_subnet, option_defs, option_values),
indent=2, ensure_ascii=False,
)
output_path = cfg["dhcp_output_path"]
try:
os.makedirs(os.path.dirname(output_path), exist_ok=True)
with open(output_path, "w", encoding="utf-8") as f:
f.write(content + "\n")
except OSError as e:
flash(f"Konfiguration konnte nicht automatisch geschrieben werden: {e}", "danger")
return
try:
status = subprocess.run(["systemctl", "is-active", DHCP_SERVICE], capture_output=True, text=True, timeout=5)
active = status.stdout.strip() == "active"
except Exception:
active = False
if active:
ok_restart, restart_out = _dhcp_run_privileged(["systemctl", "restart", DHCP_SERVICE], timeout=30)
if not ok_restart:
flash(f"Konfiguration geschrieben, Neustart des DHCP-Dienstes fehlgeschlagen: {restart_out}", "danger")
@app.route("/settings/dhcp", methods=["GET", "POST"])
@login_required
def settings_dhcp():
if request.method == "GET" and not current_user.can_view_settings_dhcp:
flash("Keine Berechtigung, die DHCP-Einstellungen anzusehen.", "danger")
return redirect(url_for("index"))
conn = get_db_connection()
if request.method == "POST":
if not current_user.has_permission("settings_dhcp.edit"):
flash("Keine Berechtigung, die DHCP-Konfiguration zu ändern.", "danger")
conn.close()
return redirect(url_for("settings_dhcp"))
if "save_dhcp_config" in request.form:
# Nur noch die globalen, Kea-weiten Einstellungen — Interface/
# Range/Gateway/DNS werden seit der Mehrfach-Subnetz-Unterstützung
# separat je Subnetz gepflegt (siehe add/edit/delete_dhcp_subnet).
for key in DHCP_DEFAULTS:
value = request.form.get(key, "").strip()
if value:
set_setting(key, value)
log_action("settings.update", "DHCP-Konfiguration")
flash("DHCP-Konfiguration gespeichert.", "success")
_dhcp_write_and_restart(conn)
elif "add_dhcp_subnet" in request.form:
interface = request.form.get("interface", "").strip()
range_start = request.form.get("range_start", "").strip()
range_end = request.form.get("range_end", "").strip()
gateway = request.form.get("gateway", "").strip()
dns = request.form.get("dns", "").strip()
if interface not in _list_network_interfaces():
flash(f"Interface „{interface}“ existiert nicht auf diesem Host.", "danger")
else:
_, range_error = _dhcp_matching_network(interface, range_start, range_end)
if range_error:
flash(range_error, "danger")
else:
max_order = conn.execute("SELECT COALESCE(MAX(sort_order), -1) AS m FROM dhcp_subnets").fetchone()["m"]
conn.execute(
"INSERT INTO dhcp_subnets (interface, range_start, range_end, gateway, dns, sort_order) "
"VALUES (?, ?, ?, ?, ?, ?)",
(interface, range_start, range_end, gateway or None, dns or None, max_order + 1),
)
conn.commit()
log_action("settings.dhcp_subnet_add", interface, f"{range_start}{range_end}")
flash(f"Subnetz {range_start}{range_end} auf „{interface}“ hinzugefügt.", "success")
_dhcp_write_and_restart(conn)
elif "edit_dhcp_subnet" in request.form:
subnet_id = request.form.get("edit_dhcp_subnet")
interface = request.form.get("interface", "").strip()
range_start = request.form.get("range_start", "").strip()
range_end = request.form.get("range_end", "").strip()
gateway = request.form.get("gateway", "").strip()
dns = request.form.get("dns", "").strip()
if interface not in _list_network_interfaces():
flash(f"Interface „{interface}“ existiert nicht auf diesem Host.", "danger")
else:
_, range_error = _dhcp_matching_network(interface, range_start, range_end)
if range_error:
flash(range_error, "danger")
else:
conn.execute(
"UPDATE dhcp_subnets SET interface=?, range_start=?, range_end=?, gateway=?, dns=? WHERE id=?",
(interface, range_start, range_end, gateway or None, dns or None, subnet_id),
)
conn.commit()
log_action("settings.dhcp_subnet_edit", interface, f"{range_start}{range_end}")
flash("Subnetz aktualisiert.", "success")
_dhcp_write_and_restart(conn)
elif "delete_dhcp_subnet" in request.form:
subnet_id = request.form.get("delete_dhcp_subnet")
conn.execute("DELETE FROM dhcp_subnets WHERE id=?", (subnet_id,))
conn.commit()
log_action("settings.dhcp_subnet_delete", subnet_id)
flash("Subnetz gelöscht.", "success")
_dhcp_write_and_restart(conn)
elif "toggle_dhcp_subnet" in request.form:
subnet_id = request.form.get("toggle_dhcp_subnet")
row = conn.execute("SELECT enabled, interface FROM dhcp_subnets WHERE id=?", (subnet_id,)).fetchone()
if row:
new_state = 0 if row["enabled"] else 1
conn.execute("UPDATE dhcp_subnets SET enabled=? WHERE id=?", (new_state, subnet_id))
conn.commit()
log_action("settings.dhcp_subnet_toggle", row["interface"], "aktiviert" if new_state else "deaktiviert")
flash(
"Subnetz aktiviert." if new_state else
"Subnetz deaktiviert — wird beim nächsten Schreiben/Starten nicht mehr berücksichtigt.",
"success",
)
_dhcp_write_and_restart(conn)
elif "add_dhcp_option" in request.form:
code_raw = request.form.get("code", "").strip()
name = request.form.get("name", "").strip()
opt_type = request.form.get("type", "").strip()
description = request.form.get("description", "").strip()
initial_value = request.form.get("initial_value", "").strip()
if not code_raw.isdigit() or not (1 <= int(code_raw) <= 254):
flash("Code muss eine Zahl zwischen 1 und 254 sein.", "danger")
elif not re.match(r"^[a-z][a-z0-9-]*$", name):
flash("Name muss mit einem Kleinbuchstaben beginnen und darf nur a-z, 0-9 und - enthalten.", "danger")
elif opt_type not in DHCP_OPTION_TYPE_KEYS:
flash("Ungültiger Datentyp.", "danger")
else:
try:
cur = conn.execute(
"INSERT INTO dhcp_option_defs (code, name, type, description) VALUES (?, ?, ?, ?)",
(int(code_raw), name, opt_type, description or None),
)
if initial_value:
conn.execute(
"INSERT INTO dhcp_option_values (option_def_id, device_mac, value) VALUES (?, '', ?)",
(cur.lastrowid, initial_value),
)
conn.commit()
log_action("settings.dhcp_option_add", name, f"Code {code_raw}, Typ {opt_type}")
flash(f"Option „{name}“ (Code {code_raw}) angelegt.", "success")
_dhcp_write_and_restart(conn)
except sqlite3.IntegrityError:
flash("Code oder Name wird bereits verwendet.", "danger")
elif "delete_dhcp_option" in request.form:
option_def_id = request.form.get("delete_dhcp_option")
option_def = conn.execute("SELECT name, is_standard FROM dhcp_option_defs WHERE id=?", (option_def_id,)).fetchone()
if option_def and option_def["is_standard"]:
flash("Standard-Optionen können nicht gelöscht werden — leeren Wert speichern, um sie auszublenden.", "danger")
else:
conn.execute("DELETE FROM dhcp_option_values WHERE option_def_id=?", (option_def_id,))
conn.execute("DELETE FROM dhcp_option_defs WHERE id=?", (option_def_id,))
conn.commit()
log_action("settings.dhcp_option_delete", option_def["name"] if option_def else option_def_id)
flash("Option gelöscht.", "success")
_dhcp_write_and_restart(conn)
elif "save_dhcp_option_global" in request.form:
option_def_id = request.form.get("option_def_id")
value = request.form.get("value", "").strip()
if value:
conn.execute(
"INSERT INTO dhcp_option_values (option_def_id, device_mac, value) VALUES (?, '', ?) "
"ON CONFLICT(option_def_id, device_mac) DO UPDATE SET value=excluded.value",
(option_def_id, value),
)
else:
conn.execute("DELETE FROM dhcp_option_values WHERE option_def_id=? AND device_mac=''", (option_def_id,))
conn.commit()
log_action("settings.dhcp_option_value", option_def_id, "global")
flash("Globaler Wert gespeichert.", "success")
_dhcp_write_and_restart(conn)
elif "save_dhcp_device_options" in request.form:
mac = request.form.get("mac", "").strip()
for d in _dhcp_option_defs(conn):
value = request.form.get(f"opt_{d['id']}", "").strip()
if value:
conn.execute(
"INSERT INTO dhcp_option_values (option_def_id, device_mac, value) VALUES (?, ?, ?) "
"ON CONFLICT(option_def_id, device_mac) DO UPDATE SET value=excluded.value",
(d["id"], mac, value),
)
else:
conn.execute("DELETE FROM dhcp_option_values WHERE option_def_id=? AND device_mac=?", (d["id"], mac))
conn.commit()
log_action("settings.dhcp_option_value", mac, "Client-Override")
flash(f"DHCP-Optionen für {mac} gespeichert.", "success")
_dhcp_write_and_restart(conn)
elif "add_dhcp_reservation" in request.form:
mac = request.form.get("mac", "").strip().upper()
ip = request.form.get("ip", "").strip()
name = request.form.get("name", "").strip()
if not re.match(r"^([0-9A-F]{2}:){5}[0-9A-F]{2}$", mac):
flash("Ungültige MAC-Adresse (Format xx:xx:xx:xx:xx:xx).", "danger")
elif not name:
flash("Name darf nicht leer sein.", "danger")
else:
try:
ipaddress.IPv4Address(ip)
except ValueError:
flash("Ungültige IPv4-Adresse.", "danger")
else:
existing_device = conn.execute(
"SELECT 1 FROM devices WHERE is_active=1 AND deleted_at IS NULL AND UPPER(mac)=?", (mac,)
).fetchone()
if existing_device:
flash(f"MAC {mac} gehört bereits zu einem Client — dafür entsteht die Reservierung automatisch.", "danger")
else:
try:
conn.execute(
"INSERT INTO dhcp_manual_reservations (mac, ip, name) VALUES (?, ?, ?)", (mac, ip, name)
)
conn.commit()
log_action("settings.dhcp_reservation_add", mac, f"{ip} ({name})")
flash(f"Manuelle Reservierung für {mac} hinzugefügt.", "success")
_dhcp_write_and_restart(conn)
except sqlite3.IntegrityError:
flash(f"Für MAC {mac} existiert bereits eine manuelle Reservierung.", "danger")
elif "delete_dhcp_reservation" in request.form:
reservation_id = request.form.get("delete_dhcp_reservation")
row = conn.execute("SELECT mac FROM dhcp_manual_reservations WHERE id=?", (reservation_id,)).fetchone()
conn.execute("DELETE FROM dhcp_manual_reservations WHERE id=?", (reservation_id,))
conn.commit()
log_action("settings.dhcp_reservation_delete", row["mac"] if row else reservation_id)
flash("Manuelle Reservierung gelöscht.", "success")
_dhcp_write_and_restart(conn)
elif "write_dhcp_file" in request.form:
cfg = _load_dhcp_settings()
valid_subnets, invalid_subnets = _dhcp_valid_subnets(_dhcp_subnets(conn))
if not valid_subnets:
flash(
"Kein gültiges Subnetz konfiguriert — mindestens ein Subnetz mit einer zu einem echten Netz "
"passenden Range wird benötigt, bevor eine Konfiguration geschrieben wird.",
"danger",
)
conn.close()
return redirect(url_for("settings_dhcp"))
reservations_by_subnet, _ = _dhcp_reservation_candidates(conn, valid_subnets)
option_defs = _dhcp_option_defs(conn)
option_values = _dhcp_option_values(conn)
content = json.dumps(
_render_kea_config(cfg, valid_subnets, reservations_by_subnet, option_defs, option_values),
indent=2, ensure_ascii=False,
)
output_path = cfg["dhcp_output_path"]
total_reservations = sum(len(v) for v in reservations_by_subnet.values())
try:
os.makedirs(os.path.dirname(output_path), exist_ok=True)
with open(output_path, "w", encoding="utf-8") as f:
f.write(content + "\n")
log_action(
"settings.dhcp_write", output_path,
f"{len(valid_subnets)} Subnetz(e), {total_reservations} Reservierungen",
)
try:
status = subprocess.run(["systemctl", "is-active", DHCP_SERVICE], capture_output=True, text=True, timeout=5)
active = status.stdout.strip() == "active"
except Exception:
active = False
restart_ok, restart_out = (True, "")
if active:
restart_ok, restart_out = _dhcp_run_privileged(["systemctl", "restart", DHCP_SERVICE], timeout=30)
msg = (
f"{len(valid_subnets)} Subnetz(e) mit insgesamt {total_reservations} Reservierungen nach "
f"{output_path} geschrieben"
+ (" und Dienst neu gestartet." if active and restart_ok else "." if not active else "")
)
if active and not restart_ok:
msg += f" Neustart des Dienstes fehlgeschlagen: {restart_out}"
if invalid_subnets:
skipped = ", ".join(f"{s['interface']} ({s['range_start']}{s['range_end']})" for s, _ in invalid_subnets)
msg += f" Übersprungen, da zu keinem echten Netz passend: {skipped}."
flash(msg, "success" if (not active or restart_ok) else "danger")
except OSError as e:
flash(f"Konnte Datei nicht schreiben: {e}", "danger")
elif "install_dhcp_package" in request.form:
ok_update, _ = _dhcp_run_privileged(["apt-get", "update"], timeout=60)
env_prefix = {"DEBIAN_FRONTEND": "noninteractive"}
try:
result = subprocess.run(
["apt-get", "install", "-y", DHCP_PACKAGE],
capture_output=True, text=True, timeout=180,
env={**os.environ, **env_prefix},
)
ok = result.returncode == 0
output_tail = "\n".join(((result.stdout or "") + (result.stderr or "")).strip().splitlines()[-10:])
except Exception as e:
ok, output_tail = False, str(e)
log_action("settings.dhcp_install", DHCP_PACKAGE, "erfolgreich" if ok else "fehlgeschlagen")
if ok:
flash(f"{DHCP_PACKAGE} installiert.", "success")
else:
flash(f"Installation fehlgeschlagen: {output_tail or 'unbekannter Fehler'}", "danger")
elif "dhcp_service_action" in request.form:
action = request.form.get("dhcp_service_action")
# Bewusst auch beim Starten nochmal live gegen das aktuelle Netz
# geprüft (nicht nur beim Speichern/Schreiben) — sonst ließe sich
# der Dienst mit einer Range starten, die z.B. nach einem
# Netzwechsel des Hosts seit dem letzten Schreiben gar nicht mehr
# zu einem echten Subnetz passt.
valid_subnets_now, _ = _dhcp_valid_subnets(_dhcp_subnets(conn))
if action == "enable_restart" and not valid_subnets_now:
flash(
"Mindestens ein Subnetz mit einer zu einem echten Netz passenden Range muss konfiguriert und "
"die Konfiguration geschrieben sein, bevor der Dienst gestartet wird.",
"danger",
)
elif action == "enable_restart":
ok_enable, _ = _dhcp_run_privileged(["systemctl", "enable", DHCP_SERVICE], timeout=15)
ok_restart, restart_out = _dhcp_run_privileged(["systemctl", "restart", DHCP_SERVICE], timeout=30)
log_action("settings.dhcp_service", DHCP_SERVICE, "enable+restart " + ("ok" if (ok_enable and ok_restart) else "fehlgeschlagen"))
if ok_enable and ok_restart:
flash(f"{DHCP_SERVICE} aktiviert und neu gestartet.", "success")
else:
flash(f"Dienst-Aktion fehlgeschlagen: {restart_out or 'unbekannter Fehler'}", "danger")
elif action == "stop":
ok_stop, stop_out = _dhcp_run_privileged(["systemctl", "stop", DHCP_SERVICE], timeout=15)
# Auch deaktivieren, nicht nur stoppen — sonst würde systemd
# den Dienst nach einem Neustart des Hosts automatisch wieder
# starten, obwohl der Admin ihn hier bewusst abgeschaltet hat.
# Damit übersteht ein Host-Neustart genau den zuletzt bewusst
# gewählten Zustand (an oder aus), statt Kea unabhängig davon
# immer wieder hochzufahren.
_dhcp_run_privileged(["systemctl", "disable", DHCP_SERVICE], timeout=15)
log_action("settings.dhcp_service", DHCP_SERVICE, "stop " + ("ok" if ok_stop else "fehlgeschlagen"))
flash(f"{DHCP_SERVICE} gestoppt und deaktiviert (startet nach einem Neustart nicht automatisch wieder)." if ok_stop else f"Stoppen fehlgeschlagen: {stop_out}", "success" if ok_stop else "danger")
conn.close()
return redirect(url_for("settings_dhcp"))
cfg = _load_dhcp_settings()
subnets = _dhcp_subnets(conn)
valid_subnets, invalid_subnets = _dhcp_valid_subnets(subnets)
reservations_by_subnet, out_of_subnet_count = _dhcp_reservation_candidates(conn, valid_subnets)
reservations = [r for rs in reservations_by_subnet.values() for r in rs]
option_defs = _dhcp_option_defs(conn)
option_values = _dhcp_option_values(conn)
skipped_count = conn.execute(
"SELECT COUNT(*) AS n FROM devices WHERE is_active=1 AND deleted_at IS NULL AND (mac IS NULL OR ip IS NULL)"
).fetchone()["n"]
conn.close()
status = _detect_dhcp_status()
preview = json.dumps(
_render_kea_config(cfg, valid_subnets, reservations_by_subnet, option_defs, option_values),
indent=2, ensure_ascii=False,
)
# Aktive Leases direkt aus Kea auslesen (siehe _dhcp_read_leases) und um
# ein reserved-Flag ergänzen — Clients OHNE eigene Reservierung, die sich
# einfach eine freie IP aus dem Pool genommen haben, sollen auffallen.
reserved_macs = {r["mac"].upper() for r in reservations}
leases = _dhcp_read_leases()
if leases is not None:
for lease in leases:
lease["reserved"] = lease["mac"] in reserved_macs
# Für die Anzeige je Subnetz-Zeile (Erkannt/Nicht erkannt + Grund) —
# als einfaches Dict statt verschachteltem Jinja-selectattr, aus
# demselben Grund wie option_values.get(...) unten im Template.
subnet_status = {}
for vs in valid_subnets:
subnet_status[vs["id"]] = {"ok": True, "network": str(vs["net_info"]["network"])}
for s, error in invalid_subnets:
subnet_status[s["id"]] = {"ok": False, "error": error}
return render_template(
"settings_dhcp.html",
cfg=cfg,
status=status,
subnets=subnets,
subnet_status=subnet_status,
interfaces=_list_network_interfaces(),
reservations=reservations,
skipped_count=skipped_count,
out_of_subnet_count=out_of_subnet_count,
preview=preview,
option_defs=option_defs,
option_values=option_values,
option_types=DHCP_OPTION_TYPES,
dhcp_package=DHCP_PACKAGE,
dhcp_service=DHCP_SERVICE,
leases=leases,
)
@app.route("/settings/dhcp/lease/delete", methods=["POST"])
@login_required
def delete_dhcp_lease():
if not current_user.has_permission("settings_dhcp.edit"):
flash("Keine Berechtigung, Leases zu löschen.", "danger")
return redirect(url_for("settings_dhcp"))
ip = request.form.get("ip", "").strip()
ok, message = _dhcp_delete_lease(ip)
log_action("settings.dhcp_lease_delete", ip, message)
flash(message, "success" if ok else "danger")
return redirect(url_for("settings_dhcp"))
@app.route("/settings/dhcp/autostart/toggle", methods=["POST"])
@login_required
def toggle_dhcp_autostart():
"""Eigenständiger Autostart-Schalter, entkoppelt vom aktuellen
Start/Stopp-Zustand -- steuert ausschließlich, ob systemd den Dienst
bei einem Host-Neustart automatisch mitstartet (systemctl enable/
disable), OHNE ihn hier auch tatsächlich zu (de)starten."""
if not current_user.has_permission("settings_dhcp.edit"):
return {"success": False, "msg": "Keine Berechtigung."}, 403
try:
result = subprocess.run(["systemctl", "is-enabled", DHCP_SERVICE], capture_output=True, text=True, timeout=5)
currently_enabled = result.stdout.strip() == "enabled"
except Exception:
currently_enabled = False
action = "disable" if currently_enabled else "enable"
ok, out = _dhcp_run_privileged(["systemctl", action, DHCP_SERVICE], timeout=15)
if not ok:
verb = "deaktivieren" if currently_enabled else "aktivieren"
return {"success": False, "msg": f"Autostart konnte nicht {verb} werden: {out}"}
new_enabled = not currently_enabled
log_action("settings.dhcp_autostart", DHCP_SERVICE, "aktiviert" if new_enabled else "deaktiviert")
return {
"success": True,
"msg": f"Autostart {'aktiviert' if new_enabled else 'deaktiviert'}.",
"new_status": 1 if new_enabled else 0,
}
@app.route("/settings/ldap", methods=["GET", "POST"])
@login_required
def settings_ldap():
if request.method == "GET" and not current_user.can_view_settings_ldap:
flash("Keine Berechtigung, die LDAP/AD-Einstellungen anzusehen.", "danger")
return redirect(url_for("index"))
if request.method == "POST":
if not current_user.has_permission("settings_ldap.edit"):
flash("Keine Berechtigung, die LDAP/AD-Konfiguration zu ändern.", "danger")
return redirect(url_for("settings_ldap"))
if "save_ldap" in request.form:
new_port_raw = request.form.get("ldap_port", "").strip()
new_bind_dn = request.form.get("ldap_bind_dn", "").strip()
if not new_port_raw.isdigit() or not (1 <= int(new_port_raw) <= 65535):
flash("LDAP-Port muss eine Zahl zwischen 1 und 65535 sein.", "danger")
else:
set_setting("ldap_enabled", "1" if request.form.get("ldap_enabled") else "0")
set_setting("ldap_server", request.form.get("ldap_server", "").strip())
set_setting("ldap_port", new_port_raw)
set_setting("ldap_use_ssl", "1" if request.form.get("ldap_use_ssl") else "0")
set_setting("ldap_tls_skip_verify", "1" if request.form.get("ldap_tls_skip_verify") else "0")
set_setting("ldap_base_dn", request.form.get("ldap_base_dn", "").strip())
set_setting("ldap_user_filter_attr", request.form.get("ldap_user_filter_attr", "").strip() or LDAP_DEFAULT_FILTER_ATTR)
set_setting("ldap_default_group", request.form.get("ldap_default_group", "").strip())
# Bind-Konto liegt in der eigenen service_accounts-Tabelle
# (siehe _ldap_settings()), nicht in settings -- ein neu
# eingegebenes Konto ERSETZT das alte komplett (purpose ist
# UNIQUE). Passwort nur überschreiben, wenn tatsächlich ein
# neues eingegeben wurde; Username kann auch ohne neues
# Passwort aktualisiert werden (dann bleibt das alte,
# verschlüsselte Passwort bestehen).
new_bind_password = request.form.get("ldap_bind_password", "")
if new_bind_dn:
conn = get_db_connection()
if new_bind_password:
conn.execute(
"INSERT INTO service_accounts (purpose, username, password) VALUES (?, ?, ?) "
"ON CONFLICT(purpose) DO UPDATE SET username=excluded.username, password=excluded.password",
(LDAP_BIND_SERVICE_ACCOUNT_PURPOSE, new_bind_dn, encrypt_password(new_bind_password)),
)
else:
existing = conn.execute(
"SELECT password FROM service_accounts WHERE purpose=?",
(LDAP_BIND_SERVICE_ACCOUNT_PURPOSE,),
).fetchone()
conn.execute(
"INSERT INTO service_accounts (purpose, username, password) VALUES (?, ?, ?) "
"ON CONFLICT(purpose) DO UPDATE SET username=excluded.username",
(LDAP_BIND_SERVICE_ACCOUNT_PURPOSE, new_bind_dn, existing["password"] if existing else ""),
)
conn.commit()
conn.close()
log_action("settings.update", "LDAP/Active Directory")
flash("LDAP-Einstellungen gespeichert.", "success")
elif "test_ldap" in request.form:
# Prüft die AKTUELL im Formular eingetragenen Werte (auch wenn
# noch nicht gespeichert) -- gleiches Prinzip wie "Verbindung
# testen" bei Switchen/Geräten, das ebenfalls die Formularwerte
# statt gespeicherter Zugangsdaten testet.
new_bind_password = request.form.get("ldap_bind_password", "")
_, saved_bind_password_enc = _ldap_bind_account()
test_cfg = {
"server": request.form.get("ldap_server", "").strip(),
"port": request.form.get("ldap_port", "").strip() or str(LDAP_DEFAULT_PORT),
"use_ssl": bool(request.form.get("ldap_use_ssl")),
"tls_skip_verify": bool(request.form.get("ldap_tls_skip_verify")),
"bind_dn": request.form.get("ldap_bind_dn", "").strip(),
"base_dn": request.form.get("ldap_base_dn", "").strip(),
"bind_password_enc": encrypt_password(new_bind_password) if new_bind_password else saved_bind_password_enc,
}
ok, message = _ldap_test_connection(test_cfg)
flash(message, "success" if ok else "danger")
elif "clear_ldap_bind" in request.form:
conn = get_db_connection()
conn.execute("DELETE FROM service_accounts WHERE purpose=?", (LDAP_BIND_SERVICE_ACCOUNT_PURPOSE,))
conn.commit()
conn.close()
# Ohne Bind-Konto kann LDAP nicht funktionieren -- vorsorglich
# mit deaktivieren, statt eine "aktivierte", aber unbenutzbare
# Konfiguration stehen zu lassen.
set_setting("ldap_enabled", "0")
log_action("settings.update", "LDAP-Bind-Konto gelöscht")
flash("LDAP-Bind-Konto gelöscht. LDAP-Anmeldung wurde deaktiviert.", "success")
elif "add_ldap_group_mapping" in request.form:
ad_group_dn = request.form.get("ad_group_dn", "").strip()
ad_group_name = request.form.get("ad_group_name", "").strip()
app_group_id = request.form.get("app_group_id", "").strip()
if not ad_group_dn or not app_group_id:
flash("AD-Gruppe und Rechtegruppe müssen ausgewählt werden.", "danger")
else:
conn = get_db_connection()
conn.execute(
"INSERT INTO ldap_group_mappings (ad_group_dn, ad_group_name, app_group_id) VALUES (?, ?, ?) "
"ON CONFLICT(ad_group_dn) DO UPDATE SET ad_group_name=excluded.ad_group_name, app_group_id=excluded.app_group_id",
(ad_group_dn, ad_group_name or ad_group_dn, app_group_id),
)
conn.commit()
conn.close()
log_action("settings.update", "LDAP-Gruppenzuordnung", f"{ad_group_name or ad_group_dn}{app_group_id}")
flash("Gruppenzuordnung gespeichert.", "success")
elif "delete_ldap_group_mapping" in request.form:
mapping_id = request.form.get("delete_ldap_group_mapping")
conn = get_db_connection()
row = conn.execute("SELECT ad_group_name FROM ldap_group_mappings WHERE id=?", (mapping_id,)).fetchone()
conn.execute("DELETE FROM ldap_group_mappings WHERE id=?", (mapping_id,))
conn.commit()
conn.close()
log_action("settings.update", "LDAP-Gruppenzuordnung gelöscht", row["ad_group_name"] if row else mapping_id)
flash("Gruppenzuordnung gelöscht.", "success")
return redirect(url_for("settings_ldap"))
return render_template(
"settings_ldap.html",
ldap=_ldap_settings(),
ldap_groups=_ldap_groups_for_dropdown(),
mappings=_ldap_group_mappings(),
)
@app.route("/settings/ldap/ad-groups")
@login_required
def settings_ldap_ad_groups():
"""JSON-Endpunkt für den 'AD-Gruppen laden'-Button in settings_ldap.html
-- fragt live per Bind-Konto alle Gruppen aus AD ab, damit die
Zuordnungstabelle nicht von Hand mit DNs gefüttert werden muss."""
if not current_user.has_permission("settings_ldap.edit"):
return jsonify({"error": "Keine Berechtigung."}), 403
return jsonify(_ldap_list_groups())
@app.route("/settings/nav-order", methods=["POST"])
@login_required
def save_nav_order():
if not current_user.is_admin:
flash("Nur Admins dürfen die Navbar-Reihenfolge ändern!", "danger")
return redirect(url_for("index"))
order = [k for k in request.form.getlist("nav_order") if k in NAV_ITEMS_BY_KEY]
order += [k for k in DEFAULT_NAV_ORDER if k not in order]
set_setting("nav_order", json.dumps(order))
# Unterpunkt-Reihenfolge je Gruppe — ein Feld "nav_child_order_<group_key>"
# pro Gruppe mit Kindern, Werte in der gewünschten Reihenfolge.
child_order = {}
for group_key, item in NAV_ITEMS_BY_KEY.items():
if not item.get("children"):
continue
valid_child_keys = {c["key"] for c in item["children"]}
submitted = [k for k in request.form.getlist(f"nav_child_order_{group_key}") if k in valid_child_keys]
submitted += [c["key"] for c in item["children"] if c["key"] not in submitted]
child_order[group_key] = submitted
set_setting("nav_child_order", json.dumps(child_order))
log_action("settings.update", "Navbar-Reihenfolge")
flash("Navbar-Reihenfolge gespeichert.", "success")
return redirect(url_for("account"))
# ---------------------------------------------------------------------------
# Import/Export — für Umzug auf eine neue Umgebung. Wählbare Kategorien
# (Clients, Switche, Zugangsdaten, Benutzer, Custom-Gruppen, LDAP/AD,
# DHCP, Logs) werden als EIN mit einer selbstgewählten Passphrase
# verschlüsseltes JSON-Bundle exportiert — unabhängig vom fernet.key dieser
# Installation, damit die Datei auf jedem anderen Server wieder eingelesen
# werden kann, der dieselbe Passphrase kennt. Der Import läuft zweistufig:
# 1) Datei+Passphrase hochladen -> entschlüsseln, Vorschau zeigt NUR die in
# der Datei tatsächlich enthaltenen Kategorien samt Anzahl.
# 2) Aus dieser Vorschau gezielt einzelne Kategorien zum Einspielen auswählen
# (siehe _pending_imports/import_apply) — eine Vollsicherung muss also
# nicht komplett eingespielt werden.
# ---------------------------------------------------------------------------
EXPORT_SECTIONS = [
("devices", "Clients"),
("switches", "Switche"),
("credentials", "Zugangsdaten"),
("users", "Benutzer (lokal)"),
("groups", "Custom-Gruppen"),
("ldap", "LDAP/AD-Einstellungen"),
("dhcp", "DHCP-Einstellungen"),
("logs", f"Logs (letzte {500} Einträge)"),
]
EXPORT_SECTION_LABELS = dict(EXPORT_SECTIONS)
# Diese Kategorien betreffen Konten/Rechte/Zugangsdaten für den gesamten
# Betrieb (können u.a. neue Admin-Konten anlegen oder das LDAP-Bind-Konto
# überschreiben) — Auswahl bleibt echten Admins vorbehalten, unabhängig vom
# sonst delegierbaren "Im-/Export"-Recht (gleiches Eskalationsschutz-Prinzip
# wie bei group_id=="admin" an anderer Stelle im Code).
IMPORT_EXPORT_ADMIN_ONLY_SECTIONS = {"users", "groups", "ldap"}
LOG_EXPORT_LIMIT = 500
LDAP_SETTING_KEYS = [
"ldap_enabled", "ldap_server", "ldap_port", "ldap_use_ssl", "ldap_tls_skip_verify",
"ldap_base_dn", "ldap_user_filter_attr", "ldap_default_group",
]
def _export_devices(conn):
# Papierkorb-Einträge werden nie mitexportiert -- ein Restore/Purge ist
# eine lokale Aufräum-Entscheidung dieser Instanz, kein Bestand, der auf
# eine andere Umgebung übertragen werden soll.
return [dict(r) for r in conn.execute(
"SELECT mac, ip, port, name, switch_hostname, is_active FROM devices WHERE deleted_at IS NULL"
).fetchall()]
def _export_switches(conn):
return [dict(r) for r in conn.execute("""
SELECT switches.hostname, switches.ip, credentials.name AS credential_name
FROM switches LEFT JOIN credentials ON credentials.id = switches.credential_id
WHERE switches.deleted_at IS NULL
""").fetchall()]
def _export_credentials(conn):
rows = conn.execute("SELECT name, username, password FROM credentials WHERE deleted_at IS NULL").fetchall()
return [{"name": r["name"], "username": r["username"], "password": decrypt_password(r["password"])} for r in rows]
def _export_users(conn):
# Nur lokale Konten -- AD/LDAP-Konten werden nie exportiert: Konto und
# Passwort werden über Windows/Active Directory verwaltet, nicht über
# diese App, und legen sich beim nächsten Login ohnehin automatisch
# wieder an (siehe login()).
rows = conn.execute(
"SELECT id, username, password, first_name, last_name, email, is_admin, is_locked "
"FROM users WHERE auth_source='local' AND deleted_at IS NULL"
).fetchall()
result = []
for r in rows:
groups = [g["name"] for g in conn.execute(
"SELECT g.name FROM user_groups ug JOIN groups g ON g.id=ug.group_id "
"WHERE ug.user_id=? AND g.deleted_at IS NULL",
(r["id"],),
).fetchall()]
result.append({
"username": r["username"], "password_hash": r["password"], "first_name": r["first_name"],
"last_name": r["last_name"], "email": r["email"], "is_admin": r["is_admin"],
"is_locked": r["is_locked"], "groups": groups,
})
return result
def _export_groups(conn):
rows = conn.execute("SELECT id, name FROM groups WHERE is_system=0 AND deleted_at IS NULL").fetchall()
result = []
for g in rows:
perms = [p["permission"] for p in conn.execute(
"SELECT permission FROM group_permissions WHERE group_id=?", (g["id"],)
).fetchall()]
members = [m["username"] for m in conn.execute(
"SELECT u.username FROM user_groups ug JOIN users u ON u.id=ug.user_id "
"WHERE ug.group_id=? AND u.deleted_at IS NULL",
(g["id"],),
).fetchall()]
result.append({"name": g["name"], "permissions": perms, "members": members})
return result
def _export_ldap(conn):
settings_data = {k: get_setting(k) for k in LDAP_SETTING_KEYS if get_setting(k) not in (None, "")}
data = {"settings": settings_data, "group_mappings": []}
bind = conn.execute(
"SELECT username, password FROM service_accounts WHERE purpose=?", (LDAP_BIND_SERVICE_ACCOUNT_PURPOSE,)
).fetchone()
if bind:
data["bind_username"] = bind["username"]
data["bind_password"] = decrypt_password(bind["password"])
data["group_mappings"] = [dict(m) for m in conn.execute(
"SELECT ad_group_dn, ad_group_name, app_group_id FROM ldap_group_mappings"
).fetchall()]
if not (settings_data or "bind_username" in data or data["group_mappings"]):
return None
return data
def _export_dhcp(conn):
settings_data = {k: get_setting(k) for k in DHCP_DEFAULTS if get_setting(k) not in (None, "")}
subnets = [dict(r) for r in conn.execute(
"SELECT interface, range_start, range_end, gateway, dns, sort_order, enabled FROM dhcp_subnets"
).fetchall()]
reservations = [dict(r) for r in conn.execute(
"SELECT mac, ip, name FROM dhcp_manual_reservations"
).fetchall()]
option_defs = conn.execute(
"SELECT id, code, name, type, description, is_standard FROM dhcp_option_defs"
).fetchall()
options = []
for d in option_defs:
values = conn.execute(
"SELECT device_mac, value FROM dhcp_option_values WHERE option_def_id=?", (d["id"],)
).fetchall()
if not values and d["is_standard"]:
continue # unveränderte Standard-Option -- wird auf dem Ziel automatisch angelegt
options.append({
"code": d["code"], "name": d["name"], "type": d["type"], "description": d["description"],
"is_standard": d["is_standard"],
"values": [{"device_mac": v["device_mac"], "value": v["value"]} for v in values],
})
if not (settings_data or subnets or reservations or options):
return None
return {"settings": settings_data, "subnets": subnets, "reservations": reservations, "options": options}
def _export_logs(conn):
rows = conn.execute(
"SELECT ts, username, action, target, details FROM audit_log ORDER BY id DESC LIMIT ?", (LOG_EXPORT_LIMIT,)
).fetchall()
return [dict(r) for r in rows] if rows else None
EXPORT_BUILDERS = {
"devices": _export_devices, "switches": _export_switches, "credentials": _export_credentials,
"users": _export_users, "groups": _export_groups, "ldap": _export_ldap, "dhcp": _export_dhcp,
"logs": _export_logs,
}
def _import_devices(conn, items):
# WHERE deleted_at IS NULL an der DO-UPDATE-Klausel: ein Import darf ein
# im Papierkorb liegendes Gerät nicht stillschweigend wiederbeleben --
# die MAC bleibt dann einfach reserviert, bis es wiederhergestellt oder
# endgültig gelöscht wird (siehe Papierkorb-Doku).
n = 0
for d in items:
conn.execute(
"""INSERT INTO devices (mac, ip, port, name, switch_hostname, is_active) VALUES (?, ?, ?, ?, ?, ?)
ON CONFLICT(mac) DO UPDATE SET ip=excluded.ip, port=excluded.port, name=excluded.name,
switch_hostname=excluded.switch_hostname, is_active=excluded.is_active
WHERE devices.deleted_at IS NULL""",
(d["mac"], d["ip"], d.get("port"), d["name"], d.get("switch_hostname"), d.get("is_active", 1)),
)
n += 1
return n
def _import_switches(conn, items):
n = 0
for s in items:
cred_id = None
if s.get("credential_name"):
row = conn.execute(
"SELECT id FROM credentials WHERE name=? AND deleted_at IS NULL", (s["credential_name"],)
).fetchone()
cred_id = row["id"] if row else None
conn.execute(
"""INSERT INTO switches (hostname, ip, credential_id) VALUES (?, ?, ?)
ON CONFLICT(hostname) DO UPDATE SET ip=excluded.ip, credential_id=excluded.credential_id
WHERE switches.deleted_at IS NULL""",
(s["hostname"], s["ip"], cred_id),
)
n += 1
return n
def _import_credentials(conn, items):
n = 0
for c in items:
conn.execute(
"""INSERT INTO credentials (name, username, password) VALUES (?, ?, ?)
ON CONFLICT(name) DO UPDATE SET username=excluded.username, password=excluded.password
WHERE credentials.deleted_at IS NULL""",
(c["name"], c["username"], encrypt_password(c["password"])),
)
n += 1
return n
def _import_users(conn, items):
# AD/LDAP-Konten mit gleichem Benutzernamen werden nie überschrieben --
# deren Konto/Passwort wird über Active Directory/Windows verwaltet,
# nicht über einen Import hier.
n = 0
for u in items:
existing = conn.execute(
"SELECT id, auth_source, deleted_at FROM users WHERE username=?", (u["username"],)
).fetchone()
if existing and existing["auth_source"] != "local":
continue
if existing and existing["deleted_at"] is not None:
# Im Papierkorb liegender Benutzername bleibt reserviert -- ein
# Import darf ihn nicht stillschweigend wiederbeleben (siehe
# Wartung-Seite: erst wiederherstellen oder endgültig löschen).
continue
if existing:
conn.execute(
"UPDATE users SET password=?, is_admin=?, first_name=?, last_name=?, email=?, is_locked=? WHERE id=?",
(u["password_hash"], u.get("is_admin", 0), u.get("first_name"), u.get("last_name"),
u.get("email"), u.get("is_locked", 0), existing["id"]),
)
user_id = existing["id"]
else:
cur = conn.execute(
"INSERT INTO users (username, password, is_admin, first_name, last_name, email, is_locked, auth_source) "
"VALUES (?, ?, ?, ?, ?, ?, ?, 'local')",
(u["username"], u["password_hash"], u.get("is_admin", 0), u.get("first_name"), u.get("last_name"),
u.get("email"), u.get("is_locked", 0)),
)
user_id = cur.lastrowid
conn.execute("DELETE FROM user_groups WHERE user_id=?", (user_id,))
for gname in u.get("groups", []):
grow = conn.execute(
"SELECT id FROM groups WHERE name=? AND deleted_at IS NULL", (gname,)
).fetchone()
if grow:
conn.execute("INSERT OR IGNORE INTO user_groups (user_id, group_id) VALUES (?, ?)", (user_id, grow["id"]))
n += 1
return n
def _import_groups(conn, items):
# Systemgruppen (aktuell nur "Benutzer") nie über Import überschreiben,
# ebenso wenig im Papierkorb liegende Gruppen -- der Name bleibt dann
# reserviert, bis wiederhergestellt oder endgültig gelöscht wird.
n = 0
for g in items:
existing = conn.execute(
"SELECT id, is_system, deleted_at FROM groups WHERE name=?", (g["name"],)
).fetchone()
if existing and (existing["is_system"] or existing["deleted_at"] is not None):
continue
if existing:
group_id = existing["id"]
else:
cur = conn.execute("INSERT INTO groups (name, is_default, is_system) VALUES (?, 0, 0)", (g["name"],))
group_id = cur.lastrowid
conn.execute("DELETE FROM group_permissions WHERE group_id=?", (group_id,))
valid_perms = [p for p in g.get("permissions", []) if p in ALL_PERMISSION_KEYS]
conn.executemany(
"INSERT INTO group_permissions (group_id, permission) VALUES (?, ?)", [(group_id, p) for p in valid_perms]
)
for username in g.get("members", []):
urow = conn.execute(
"SELECT id FROM users WHERE username=? AND deleted_at IS NULL", (username,)
).fetchone()
if urow:
conn.execute("INSERT OR IGNORE INTO user_groups (user_id, group_id) VALUES (?, ?)", (urow["id"], group_id))
n += 1
return n
def _import_ldap(conn, data):
for key, value in data.get("settings", {}).items():
if key in LDAP_SETTING_KEYS:
set_setting(key, value)
if data.get("bind_username"):
conn.execute(
"INSERT INTO service_accounts (purpose, username, password) VALUES (?, ?, ?) "
"ON CONFLICT(purpose) DO UPDATE SET username=excluded.username, password=excluded.password",
(LDAP_BIND_SERVICE_ACCOUNT_PURPOSE, data["bind_username"], encrypt_password(data.get("bind_password", ""))),
)
n = 0
for m in data.get("group_mappings", []):
conn.execute(
"INSERT INTO ldap_group_mappings (ad_group_dn, ad_group_name, app_group_id) VALUES (?, ?, ?) "
"ON CONFLICT(ad_group_dn) DO UPDATE SET ad_group_name=excluded.ad_group_name, app_group_id=excluded.app_group_id",
(m["ad_group_dn"], m["ad_group_name"], m["app_group_id"]),
)
n += 1
return n
def _import_dhcp(conn, data):
for key, value in data.get("settings", {}).items():
if key in DHCP_DEFAULTS:
set_setting(key, value)
n = 0
# Subnetze additiv (nie löschen/ersetzen) -- ein bestehendes, unabhängig
# vom Ziel-Admin konfiguriertes Subnetz soll ein Import nie entfernen.
for s in data.get("subnets", []):
exists = conn.execute(
"SELECT 1 FROM dhcp_subnets WHERE interface=? AND range_start=? AND range_end=?",
(s["interface"], s["range_start"], s["range_end"]),
).fetchone()
if not exists:
max_order = conn.execute("SELECT COALESCE(MAX(sort_order), -1) AS m FROM dhcp_subnets").fetchone()["m"]
conn.execute(
"INSERT INTO dhcp_subnets (interface, range_start, range_end, gateway, dns, sort_order, enabled) "
"VALUES (?, ?, ?, ?, ?, ?, ?)",
(s["interface"], s["range_start"], s["range_end"], s.get("gateway"), s.get("dns"),
max_order + 1, s.get("enabled", 1)),
)
n += 1
for r in data.get("reservations", []):
conn.execute(
"INSERT INTO dhcp_manual_reservations (mac, ip, name) VALUES (?, ?, ?) "
"ON CONFLICT(mac) DO UPDATE SET ip=excluded.ip, name=excluded.name",
(r["mac"], r["ip"], r["name"]),
)
n += 1
for opt in data.get("options", []):
conn.execute(
"INSERT INTO dhcp_option_defs (code, name, type, description, is_standard) VALUES (?, ?, ?, ?, ?) "
"ON CONFLICT(name) DO UPDATE SET code=excluded.code, type=excluded.type, description=excluded.description",
(opt["code"], opt["name"], opt["type"], opt.get("description"), opt.get("is_standard", 0)),
)
def_id = conn.execute("SELECT id FROM dhcp_option_defs WHERE name=?", (opt["name"],)).fetchone()["id"]
for v in opt.get("values", []):
conn.execute(
"INSERT INTO dhcp_option_values (option_def_id, device_mac, value) VALUES (?, ?, ?) "
"ON CONFLICT(option_def_id, device_mac) DO UPDATE SET value=excluded.value",
(def_id, v.get("device_mac", ""), v["value"]),
)
n += 1
return n
def _import_logs(conn, items):
conn.executemany(
"INSERT INTO audit_log (ts, username, action, target, details) VALUES (?, ?, ?, ?, ?)",
[(l["ts"], l["username"], l["action"], l.get("target"), l.get("details")) for l in items],
)
return len(items)
IMPORT_APPLIERS = {
"devices": _import_devices, "switches": _import_switches, "credentials": _import_credentials,
"users": _import_users, "groups": _import_groups, "ldap": _import_ldap, "dhcp": _import_dhcp,
"logs": _import_logs,
}
# Entschlüsselte Import-Vorschau zwischen Upload (import_data) und
# tatsächlichem Einspielen (import_apply) -- analog _pending_network_revert:
# In-Memory, token-basiert, TTL-begrenzt, damit die Passphrase nicht ein
# zweites Mal im Browser landen muss, um gezielt einzelne Kategorien
# auszuwählen.
_pending_imports = {}
PENDING_IMPORT_TTL_SECONDS = 900
def _purge_expired_imports():
now = datetime.now()
expired = [t for t, e in _pending_imports.items() if (now - e["created_at"]).total_seconds() > PENDING_IMPORT_TTL_SECONDS]
for t in expired:
_pending_imports.pop(t, None)
@app.route("/settings/export", methods=["POST"])
@login_required
def export_data():
# Eigenes, von Import getrenntes Recht (settings_importexport.export,
# eigene X-Spalte in der Gruppentabelle) -- Export enthält entschlüsselte
# Passwörter im Klartext, daher bewusst nicht an das generische
# "Ändern"-Recht gekoppelt. Admins haben es über has_permission()
# automatisch, für andere Gruppen entscheidet ein Admin gezielt.
if not current_user.has_permission("settings_importexport.export"):
flash("Keine Berechtigung, Daten zu exportieren.", "danger")
return redirect(url_for("index"))
passphrase = request.form.get("export_passphrase", "")
sections = [s for s in request.form.getlist("export_sections") if s in EXPORT_BUILDERS]
if not passphrase:
flash("Bitte eine Passphrase für den Export angeben.", "danger")
return redirect(url_for("settings_import_export"))
if not sections:
flash("Bitte mindestens eine Kategorie zum Exportieren auswählen.", "danger")
return redirect(url_for("settings_import_export"))
if not current_user.is_admin and (set(sections) & IMPORT_EXPORT_ADMIN_ONLY_SECTIONS):
flash("Benutzer, Custom-Gruppen und LDAP-Einstellungen dürfen nur Admins exportieren.", "danger")
return redirect(url_for("settings_import_export"))
conn = get_db_connection()
payload, counts = {}, {}
for key in sections:
value = EXPORT_BUILDERS[key](conn)
if value:
payload[key] = value
counts[key] = len(value) if isinstance(value, list) else 1
conn.close()
if not payload:
flash("Keine Daten in den ausgewählten Kategorien vorhanden — nichts exportiert.", "danger")
return redirect(url_for("settings_import_export"))
salt = secrets.token_bytes(16)
export_fernet = _derive_export_fernet(passphrase, salt)
encrypted_payload = export_fernet.encrypt(json.dumps(payload).encode("utf-8"))
envelope = {
"app": "tesm",
"version": 2,
"exported_at": datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
"salt": base64.urlsafe_b64encode(salt).decode("utf-8"),
"payload": encrypted_payload.decode("utf-8"),
}
summary = ", ".join(f"{EXPORT_SECTION_LABELS[k]}: {counts[k]}" for k in payload)
log_action("data.export", ", ".join(EXPORT_SECTION_LABELS[k] for k in payload), summary)
filename = f"tesm_export_{datetime.now().strftime('%Y%m%d_%H%M%S')}.json"
response = jsonify(envelope)
response.headers["Content-Disposition"] = f"attachment; filename={filename}"
return response
@app.route("/settings/import", methods=["POST"])
@login_required
def import_data():
if not current_user.has_permission("settings_importexport.edit"):
flash("Keine Berechtigung, Daten zu importieren.", "danger")
return redirect(url_for("index"))
passphrase = request.form.get("import_passphrase", "")
upload = request.files.get("import_file")
if not passphrase or not upload or not upload.filename:
flash("Bitte Passphrase und Export-Datei angeben.", "danger")
return redirect(url_for("settings_import_export"))
try:
envelope = json.loads(upload.read().decode("utf-8"))
salt = base64.urlsafe_b64decode(envelope["salt"])
export_fernet = _derive_export_fernet(passphrase, salt)
payload = json.loads(export_fernet.decrypt(envelope["payload"].encode("utf-8")).decode("utf-8"))
except Exception:
flash("Import fehlgeschlagen: Datei ungültig oder Passphrase falsch.", "danger")
return redirect(url_for("settings_import_export"))
# Legacy-Exporte (version 1, vor der Sektionsauswahl) hatten Devices/
# Switches/Zugangsdaten direkt unter denselben Schlüsseln wie heute --
# Struktur ist identisch, daher ohne Migration weiterverwendbar.
present_sections = [key for key in EXPORT_BUILDERS if payload.get(key)]
if not present_sections:
flash("Die Datei enthält keine bekannten/unterstützten Daten.", "danger")
return redirect(url_for("settings_import_export"))
_purge_expired_imports()
token = secrets.token_urlsafe(24)
_pending_imports[token] = {"payload": payload, "created_at": datetime.now(), "by": current_user.username}
return redirect(url_for("settings_import_preview", token=token))
@app.route("/settings/import/preview/<token>")
@login_required
def settings_import_preview(token):
if not current_user.has_permission("settings_importexport.edit"):
flash("Keine Berechtigung, Daten zu importieren.", "danger")
return redirect(url_for("index"))
entry = _pending_imports.get(token)
if not entry:
flash("Die Import-Vorschau ist abgelaufen oder ungültig — bitte die Datei erneut hochladen.", "danger")
return redirect(url_for("settings_import_export"))
payload = entry["payload"]
present_sections = [key for key in EXPORT_BUILDERS if payload.get(key)]
preview_sections = [
{
"key": key, "label": EXPORT_SECTION_LABELS.get(key, key),
"count": len(payload[key]) if isinstance(payload[key], list) else 1,
"admin_only": key in IMPORT_EXPORT_ADMIN_ONLY_SECTIONS,
}
for key in present_sections
]
return render_template(
"settings_import_export.html",
import_preview={"token": token, "sections": preview_sections},
export_sections=EXPORT_SECTIONS,
admin_only_sections=IMPORT_EXPORT_ADMIN_ONLY_SECTIONS,
)
@app.route("/settings/import/apply", methods=["POST"])
@login_required
def import_apply():
if not current_user.has_permission("settings_importexport.edit"):
flash("Keine Berechtigung, Daten zu importieren.", "danger")
return redirect(url_for("index"))
token = request.form.get("import_token", "")
entry = _pending_imports.pop(token, None)
if not entry:
flash("Die Import-Vorschau ist abgelaufen oder ungültig — bitte die Datei erneut hochladen.", "danger")
return redirect(url_for("settings_import_export"))
payload = entry["payload"]
selected = [s for s in request.form.getlist("import_sections") if s in IMPORT_APPLIERS and payload.get(s)]
if not selected:
flash("Bitte mindestens eine Kategorie zum Importieren auswählen.", "danger")
return redirect(url_for("settings_import_export"))
if not current_user.is_admin and (set(selected) & IMPORT_EXPORT_ADMIN_ONLY_SECTIONS):
flash("Benutzer, Custom-Gruppen und LDAP-Einstellungen dürfen nur Admins importieren.", "danger")
return redirect(url_for("settings_import_export"))
conn = get_db_connection()
results = {key: IMPORT_APPLIERS[key](conn, payload[key]) for key in selected}
conn.commit()
conn.close()
summary = ", ".join(f"{EXPORT_SECTION_LABELS.get(k, k)}: {results[k]}" for k in selected)
log_action("data.import", ", ".join(EXPORT_SECTION_LABELS.get(k, k) for k in selected), summary)
flash(f"Import abgeschlossen: {summary}.", "success")
return redirect(url_for("settings_import_export"))
# ---------------------------------------------------------------------------
# Devices
# ---------------------------------------------------------------------------
@app.route("/devices", methods=["GET", "POST"])
@login_required
def devices():
if request.method == "GET" and not current_user.can_view_devices:
flash("Keine Berechtigung, die Devices-Seite anzusehen.", "danger")
return redirect(url_for("index"))
conn = get_db_connection()
switches = conn.execute(
"SELECT hostname FROM switches WHERE deleted_at IS NULL ORDER BY hostname ASC"
).fetchall()
if request.method == "POST":
if "add_device" in request.form:
if not current_user.has_permission("devices.create"):
flash("Keine Berechtigung, Geräte anzulegen.", "danger")
return redirect(url_for("devices"))
mac = request.form.get("mac")
ip = request.form.get("ip")
port = request.form.get("port")
name = request.form.get("name")
switch_hostname = request.form.get("switch_hostname") or None
is_active = 1 if "is_active" in request.form else 0
if not all([mac, ip, name]):
flash("Alle Felder müssen ausgefüllt sein!", "danger")
return redirect(url_for("devices"))
if conn.execute("SELECT name FROM devices WHERE ip=? AND deleted_at IS NULL", (ip,)).fetchone():
flash("IP-Adresse existiert bereits für ein anderes Gerät!", "danger")
return redirect(url_for("devices"))
if conn.execute("SELECT name FROM devices WHERE mac=? AND deleted_at IS NULL", (mac,)).fetchone():
flash("MAC-Adresse existiert bereits für ein anderes Gerät!", "danger")
return redirect(url_for("devices"))
# SSH-Anbindung optional — die meisten Geräte sind keine per SSH
# erreichbaren Hosts, nur wer bewusst Zugangsdaten auswählt/anlegt
# bekommt sie zugeordnet (siehe Wartungsseite).
ssh_port, port_error = _parse_ssh_port(request.form)
credential_id, cred_error = (None, None)
if request.form.get("credential_choice"):
credential_id, cred_error = _resolve_credential_choice(conn, default_category="linux")
if port_error:
flash(port_error, "danger")
return redirect(url_for("devices"))
if cred_error:
flash(cred_error, "danger")
return redirect(url_for("devices"))
try:
conn.execute(
"INSERT INTO devices (mac, ip, port, name, switch_hostname, is_active, credential_id, ssh_port) "
"VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
(mac, ip, port, name, switch_hostname, is_active, credential_id, ssh_port),
)
touch_record(conn, "devices", "mac", mac)
conn.commit()
log_action("device.create", name, f"MAC {mac}, IP {ip}")
flash(f"Gerät {name} hinzugefügt.", "success")
except sqlite3.IntegrityError:
flash("Fehler beim Hinzufügen des Geräts — MAC oder IP bereits vergeben (ggf. im Papierkorb unter Wartung, dort zuerst wiederherstellen oder endgültig löschen).", "danger")
elif "edit_device" in request.form:
if not current_user.has_permission("devices.edit"):
flash("Keine Berechtigung, Geräte zu bearbeiten.", "danger")
return redirect(url_for("devices"))
old_mac = request.form.get("old_mac")
mac = request.form.get("mac")
ip = request.form.get("ip")
port = request.form.get("port") or None
name = request.form.get("name")
switch_hostname_field = "switch_hostname" in request.form
# --- Nur Switch-Zuordnung ändern (Modal "Switch ändern") ---
if switch_hostname_field and not any([mac, ip, name]):
device = conn.execute(
"SELECT name, switch_hostname FROM devices WHERE mac=? AND deleted_at IS NULL", (old_mac,)
).fetchone()
if not device:
flash("Gerät nicht gefunden!", "danger")
return redirect(url_for("devices"))
old_switch = device["switch_hostname"] or "kein Switch"
new_switch = request.form.get("switch_hostname") or None
conn.execute(
"UPDATE devices SET switch_hostname=? WHERE mac=?", (new_switch, old_mac)
)
touch_record(conn, "devices", "mac", old_mac)
conn.commit()
log_action("device.edit", device["name"], f"Switch: {old_switch}{new_switch or 'Kein Switch'}")
flash(
f"Switch von {device['name']} geändert: {old_switch}{new_switch or 'Kein Switch'}",
"success",
)
return redirect(url_for("devices"))
# --- Normales Gerät bearbeiten ---
if not all([old_mac, mac, ip, name]):
flash("Felder 'MAC', 'IP' und 'Name' müssen ausgefüllt sein!", "danger")
return redirect(url_for("devices"))
if conn.execute(
"SELECT name FROM devices WHERE ip=? AND mac<>? AND deleted_at IS NULL", (ip, old_mac)
).fetchone():
flash("IP-Adresse existiert bereits für ein anderes Gerät!", "danger")
return redirect(url_for("devices"))
if conn.execute(
"SELECT name FROM devices WHERE mac=? AND mac<>? AND deleted_at IS NULL", (mac, old_mac)
).fetchone():
flash("MAC-Adresse existiert bereits für ein anderes Gerät!", "danger")
return redirect(url_for("devices"))
ssh_port, port_error = _parse_ssh_port(request.form)
credential_id, cred_error = (None, None)
if request.form.get("credential_choice"):
credential_id, cred_error = _resolve_credential_choice(conn, default_category="linux")
if port_error:
flash(port_error, "danger")
return redirect(url_for("devices"))
if cred_error:
flash(cred_error, "danger")
return redirect(url_for("devices"))
try:
conn.execute(
"UPDATE devices SET mac=?, ip=?, port=?, name=?, credential_id=?, ssh_port=? "
"WHERE mac=? AND deleted_at IS NULL",
(mac, ip, port, name, credential_id, ssh_port, old_mac),
)
touch_record(conn, "devices", "mac", mac)
conn.commit()
log_action("device.edit", name, f"MAC {old_mac}{mac}, IP {ip}")
flash(f"Gerät {name} aktualisiert.", "success")
except sqlite3.IntegrityError:
flash("Fehler beim Aktualisieren des Geräts — MAC oder IP bereits vergeben (ggf. im Papierkorb unter Wartung).", "danger")
elif "delete_device" in request.form:
# "Ändern" deckt Löschen mit ab (kein eigenes devices.delete-Recht).
if not current_user.has_permission("devices.edit"):
flash("Keine Berechtigung, Geräte zu löschen.", "danger")
return redirect(url_for("devices"))
del_mac = request.form.get("delete_device")
if del_mac:
device = conn.execute(
"SELECT name FROM devices WHERE mac=? AND deleted_at IS NULL", (del_mac,)
).fetchone()
name = device["name"] if device else del_mac
# Papierkorb statt echtem Löschen -- siehe Wartung-Seite für
# Wiederherstellen/endgültiges Löschen.
conn.execute(
"UPDATE devices SET deleted_at=? WHERE mac=? AND deleted_at IS NULL",
(datetime.now().strftime("%Y-%m-%d %H:%M:%S"), del_mac),
)
conn.commit()
log_action("device.delete", name, f"MAC {del_mac} — in den Papierkorb verschoben")
flash(f"Gerät {name} in den Papierkorb verschoben.", "success")
else:
flash("Keine MAC-Adresse übermittelt!", "danger")
conn.close()
return redirect(url_for("devices"))
device_rows = conn.execute("""
SELECT devices.mac, devices.ip, devices.port, devices.name, devices.is_active,
devices.credential_id, devices.ssh_port,
switches.hostname AS switch_hostname,
credentials.name AS credential_name, credentials.username AS credential_username
FROM devices
LEFT JOIN switches ON devices.switch_hostname = switches.hostname
LEFT JOIN credentials ON credentials.id = devices.credential_id
WHERE devices.deleted_at IS NULL
ORDER BY switches.hostname ASC, devices.name ASC
""").fetchall()
all_credentials = conn.execute(
"SELECT id, name, username, category FROM credentials WHERE deleted_at IS NULL ORDER BY name ASC"
).fetchall()
conn.close()
return render_template("devices.html", devices=device_rows, switches=switches, all_credentials=all_credentials)
@app.route("/devices/toggle/<mac>", methods=["POST"])
@login_required
def toggle_device(mac):
# Aktivieren/Deaktivieren zählt jetzt zu "Ändern" (devices.edit) statt
# eines eigenen devices.toggle-Rechts.
if not current_user.has_permission("devices.edit"):
return {"success": False, "msg": "Keine Berechtigung."}, 403
conn = get_db_connection()
device = conn.execute(
"SELECT is_active, name FROM devices WHERE mac=? AND deleted_at IS NULL", (mac,)
).fetchone()
if not device:
conn.close()
return {"success": False, "msg": "Gerät nicht gefunden!"}, 404
new_status = 0 if device["is_active"] else 1
conn.execute("UPDATE devices SET is_active=? WHERE mac=? AND deleted_at IS NULL", (new_status, mac))
touch_record(conn, "devices", "mac", mac)
conn.commit()
conn.close()
status_text = "deaktiviert" if new_status == 0 else "aktiviert"
log_action("device.deactivate" if new_status == 0 else "device.activate", device["name"])
return {"success": True, "msg": f"Gerät {device['name']} wurde {status_text}.", "new_status": new_status}
# ---------------------------------------------------------------------------
# Switches
# ---------------------------------------------------------------------------
def _resolve_credential_choice(conn, default_category="switch"):
"""
Liest die Zugangsdaten-Auswahl aus einem Switch-/Client-Formular:
entweder eine bestehende credential_id, oder (choice == "new") legt
direkt aus dem Formular heraus neue Zugangsdaten an — damit man beim
Anlegen nicht zuerst zu "Zugangsdaten" wechseln muss. default_category
wird nur für neu angelegte Zugangsdaten verwendet (z.B. "linux", wenn
aus dem Client-Formular heraus angelegt), bestehende behalten ihre
eigene Kategorie. Gibt (credential_id, error_message) zurück;
error_message ist None bei Erfolg.
"""
choice = request.form.get("credential_choice", "")
if choice == "new":
if not current_user.has_permission("credentials.create"):
return None, "Keine Berechtigung, neue Zugangsdaten anzulegen."
name = request.form.get("new_credential_name", "").strip()
username = request.form.get("new_credential_username", "").strip()
password = request.form.get("new_credential_password", "")
if not (name and username and password):
return None, "Für neue Zugangsdaten müssen Name, Username und Passwort ausgefüllt sein!"
try:
cur = conn.execute(
"INSERT INTO credentials (name, username, password, category) VALUES (?, ?, ?, ?)",
(name, username, encrypt_password(password), default_category),
)
return cur.lastrowid, None
except sqlite3.IntegrityError:
return None, "Es existieren bereits Zugangsdaten mit diesem Namen (ggf. im Papierkorb unter Wartung)!"
elif choice.isdigit():
cred_id = int(choice)
if not conn.execute(
"SELECT 1 FROM credentials WHERE id=? AND deleted_at IS NULL", (cred_id,)
).fetchone():
return None, "Ausgewählte Zugangsdaten wurden zwischenzeitlich gelöscht."
return cred_id, None
return None, "Bitte Zugangsdaten auswählen oder neue anlegen."
def _parse_ssh_port(form):
"""Liest das optionale SSH-Port-Feld aus dem Formular. Leer -> None (=
Fallback auf SWITCH_DEFAULT_SSH_PORT überall, wo der Port gebraucht
wird). Gibt (port_or_none, error_message) zurück."""
raw = (form.get("ssh_port") or "").strip()
if not raw:
return None, None
if not raw.isdigit() or not (1 <= int(raw) <= 65535):
return None, "SSH-Port muss eine Zahl zwischen 1 und 65535 sein (oder leer für Standard 22)!"
return int(raw), None
@app.route("/switches", methods=["GET", "POST"])
@login_required
def switches():
if not current_user.can_manage_switches:
flash("Keine Berechtigung für die Switch-Verwaltung.", "danger")
return redirect(url_for("index"))
conn = get_db_connection()
if request.method == "POST" and "add_switch" in request.form:
if not current_user.has_permission("switches.create"):
flash("Keine Berechtigung, Switche anzulegen.", "danger")
return redirect(url_for("switches"))
hostname = request.form["hostname"]
ip = request.form["ip"]
ssh_port, port_error = _parse_ssh_port(request.form)
credential_id, cred_error = _resolve_credential_choice(conn)
if port_error:
flash(port_error, "danger")
elif cred_error:
flash(cred_error, "danger")
else:
try:
conn.execute(
"INSERT INTO switches (hostname, ip, ssh_port, credential_id) VALUES (?, ?, ?, ?)",
(hostname, ip, ssh_port, credential_id),
)
touch_record(conn, "switches", "hostname", hostname)
conn.commit()
log_action("switch.create", hostname, f"IP {ip}")
flash(f"Switch {hostname} hinzugefügt.", "success")
except sqlite3.IntegrityError:
flash("Hostname existiert bereits (ggf. im Papierkorb unter Wartung) oder Eingabefehler!", "danger")
if request.method == "POST" and "edit_switch" in request.form:
if not current_user.has_permission("switches.edit"):
flash("Keine Berechtigung, Switche zu bearbeiten.", "danger")
return redirect(url_for("switches"))
old_hostname = request.form["old_hostname"]
hostname = request.form["hostname"]
ip = request.form["ip"]
ssh_port, port_error = _parse_ssh_port(request.form)
credential_id, cred_error = _resolve_credential_choice(conn)
if port_error:
flash(port_error, "danger")
elif cred_error:
flash(cred_error, "danger")
else:
try:
conn.execute(
"UPDATE switches SET hostname=?, ip=?, ssh_port=?, credential_id=? "
"WHERE hostname=? AND deleted_at IS NULL",
(hostname, ip, ssh_port, credential_id, old_hostname),
)
if hostname != old_hostname:
conn.execute(
"UPDATE devices SET switch_hostname=? WHERE switch_hostname=?",
(hostname, old_hostname),
)
touch_record(conn, "switches", "hostname", hostname)
conn.commit()
log_action("switch.edit", hostname, f"IP {ip}")
flash(f"Switch {hostname} aktualisiert.", "success")
except sqlite3.IntegrityError:
flash("Hostname existiert bereits (ggf. im Papierkorb unter Wartung) oder Eingabefehler!", "danger")
switch_rows = conn.execute("""
SELECT switches.hostname, switches.ip, switches.ssh_port, switches.credential_id,
credentials.name AS credential_name, credentials.username AS credential_username
FROM switches
LEFT JOIN credentials ON credentials.id = switches.credential_id
WHERE switches.deleted_at IS NULL
ORDER BY switches.hostname ASC
""").fetchall()
all_credentials = conn.execute(
"SELECT id, name, username FROM credentials WHERE deleted_at IS NULL ORDER BY name ASC"
).fetchall()
conn.close()
return render_template("switches.html", switches=switch_rows, all_credentials=all_credentials)
@app.route("/switches/delete/<hostname>", methods=["POST"])
@login_required
def delete_switch(hostname):
# "Ändern" deckt Löschen mit ab (kein eigenes switches.delete-Recht).
if not current_user.has_permission("switches.edit"):
flash("Keine Berechtigung, Switche zu löschen.", "danger")
return redirect(url_for("switches"))
conn = get_db_connection()
assigned = conn.execute(
"SELECT name FROM devices WHERE switch_hostname=? AND deleted_at IS NULL", (hostname,)
).fetchall()
if assigned:
names = ", ".join(d["name"] for d in assigned)
flash(f"Folgende Geräte sind noch dem Switch {hostname} zugewiesen: {names}", "danger")
conn.close()
return redirect(url_for("switches"))
# Papierkorb statt echtem Löschen -- siehe Wartung-Seite für
# Wiederherstellen/endgültiges Löschen.
conn.execute(
"UPDATE switches SET deleted_at=? WHERE hostname=? AND deleted_at IS NULL",
(datetime.now().strftime("%Y-%m-%d %H:%M:%S"), hostname),
)
conn.commit()
conn.close()
log_action("switch.delete", hostname, "in den Papierkorb verschoben")
flash(f"Switch '{hostname}' in den Papierkorb verschoben.", "success")
return redirect(url_for("switches"))
# ---------------------------------------------------------------------------
# Zugangsdaten (Credentials) — wiederverwendbare SSH-Logins für Switche,
# damit nicht jeder Switch sein eigenes Login braucht. Eigene credentials.*-
# Rechte, unabhängig von switches.* (bewusst entkoppelt).
# ---------------------------------------------------------------------------
@app.route("/credentials", methods=["GET", "POST"])
@login_required
def credentials():
if not current_user.can_manage_credentials:
flash("Keine Berechtigung für die Zugangsdatenverwaltung.", "danger")
return redirect(url_for("index"))
conn = get_db_connection()
if request.method == "POST" and "add_credential" in request.form:
if not current_user.has_permission("credentials.create"):
flash("Keine Berechtigung, Zugangsdaten anzulegen.", "danger")
return redirect(url_for("credentials"))
name = request.form.get("name", "").strip()
username = request.form.get("username", "").strip()
password = request.form.get("password", "")
category = request.form.get("category", "").strip()
if category not in CREDENTIAL_CATEGORY_KEYS:
category = "switch"
if name and username and password:
try:
conn.execute(
"INSERT INTO credentials (name, username, password, category) VALUES (?, ?, ?, ?)",
(name, username, encrypt_password(password), category),
)
conn.commit()
log_action("credential.create", name)
flash(f"Zugangsdaten '{name}' angelegt.", "success")
except sqlite3.IntegrityError:
flash("Es existieren bereits Zugangsdaten mit diesem Namen (ggf. im Papierkorb unter Wartung)!", "danger")
else:
flash("Name, Username und Passwort müssen ausgefüllt sein!", "danger")
elif request.method == "POST" and "edit_credential" in request.form:
if not current_user.has_permission("credentials.edit"):
flash("Keine Berechtigung, Zugangsdaten zu bearbeiten.", "danger")
return redirect(url_for("credentials"))
cred_id = request.form.get("credential_id")
name = request.form.get("name", "").strip()
username = request.form.get("username", "").strip()
new_password = request.form.get("password", "")
category = request.form.get("category", "").strip()
if category not in CREDENTIAL_CATEGORY_KEYS:
category = "switch"
if name and username:
try:
if new_password:
conn.execute(
"UPDATE credentials SET name=?, username=?, password=?, category=? "
"WHERE id=? AND deleted_at IS NULL",
(name, username, encrypt_password(new_password), category, cred_id),
)
else:
conn.execute(
"UPDATE credentials SET name=?, username=?, category=? WHERE id=? AND deleted_at IS NULL",
(name, username, category, cred_id),
)
conn.commit()
log_action("credential.edit", name)
flash(f"Zugangsdaten '{name}' aktualisiert.", "success")
except sqlite3.IntegrityError:
flash("Es existieren bereits Zugangsdaten mit diesem Namen (ggf. im Papierkorb unter Wartung)!", "danger")
else:
flash("Name und Username müssen ausgefüllt sein!", "danger")
elif request.method == "POST" and "delete_credential" in request.form:
# "Ändern" deckt Löschen mit ab (kein eigenes credentials.delete-Recht).
if not current_user.has_permission("credentials.edit"):
flash("Keine Berechtigung, Zugangsdaten zu löschen.", "danger")
return redirect(url_for("credentials"))
cred_id = request.form.get("delete_credential")
used_by_switches = conn.execute(
"SELECT hostname FROM switches WHERE credential_id=? AND deleted_at IS NULL", (cred_id,)
).fetchall()
used_by_devices = conn.execute(
"SELECT name FROM devices WHERE credential_id=? AND deleted_at IS NULL", (cred_id,)
).fetchall()
if used_by_switches or used_by_devices:
names = ", ".join([s["hostname"] for s in used_by_switches] + [d["name"] for d in used_by_devices])
flash(f"Diese Zugangsdaten werden noch verwendet von: {names}", "danger")
else:
cred_row = conn.execute(
"SELECT name FROM credentials WHERE id=? AND deleted_at IS NULL", (cred_id,)
).fetchone()
# Papierkorb statt echtem Löschen -- siehe Wartung-Seite für
# Wiederherstellen/endgültiges Löschen.
conn.execute(
"UPDATE credentials SET deleted_at=? WHERE id=? AND deleted_at IS NULL",
(datetime.now().strftime("%Y-%m-%d %H:%M:%S"), cred_id),
)
conn.commit()
log_action("credential.delete", cred_row["name"] if cred_row else cred_id, "in den Papierkorb verschoben")
flash("Zugangsdaten in den Papierkorb verschoben.", "success")
credential_rows = conn.execute("""
SELECT credentials.id, credentials.name, credentials.username, credentials.category,
COUNT(DISTINCT switches.hostname) AS switch_count,
COUNT(DISTINCT devices.mac) AS device_count
FROM credentials
LEFT JOIN switches ON switches.credential_id = credentials.id AND switches.deleted_at IS NULL
LEFT JOIN devices ON devices.credential_id = credentials.id AND devices.deleted_at IS NULL
WHERE credentials.deleted_at IS NULL
GROUP BY credentials.id
ORDER BY credentials.name ASC
""").fetchall()
conn.close()
return render_template("credentials.html", credentials=credential_rows, categories=CREDENTIAL_CATEGORIES)
# ---------------------------------------------------------------------------
# Wartung (Clients) — SSH-Bulk-Update + Neustart für Linux-Clients.
#
# Bewusst komplett getrennt vom PoE-Neustart auf dem Dashboard: der
# Dashboard-Button (devices.restart) löst IMMER einen PoE-Reset über den
# zugeordneten Switch aus. Die Aktionen hier laufen IMMER per SSH direkt
# auf dem Gerät selbst -- unabhängig davon, ob/wie es an einen Switch
# angeschlossen ist. Job-Status lebt bewusst nur im Prozessspeicher (wie
# das SSH-Terminal auch keine Sitzung persistiert) -- ein Neustart des
# tesm.service verwirft laufende Job-Anzeigen, nicht aber die
# zugrundeliegende SSH-Aktion selbst (die läuft serverseitig auf dem
# Zielgerät unabhängig vom Anzeigezustand hier weiter).
# ---------------------------------------------------------------------------
_maintenance_jobs_lock = threading.Lock()
# mac -> {"action": "update"/"reboot", "status": "running"/"success"/"error",
# "message": str, "output": str, "started": str, "finished": str|None}
_maintenance_jobs = {}
def _maintenance_job_set(mac, **fields):
with _maintenance_jobs_lock:
job = _maintenance_jobs.setdefault(mac, {})
job.update(fields)
def _maintenance_job_immediate_error(mac, action, message):
now = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
_maintenance_job_set(mac, action=action, status="error", message=message, output="", started=now, finished=now)
def _maintenance_devices(conn):
"""Alle Geräte mit hinterlegten SSH-Zugangsdaten der Kategorie
DEVICE_MAINTENANCE_CATEGORY (aktuell nur 'linux') -- nur für diese
bietet 'Wartung' überhaupt eine Aktion an (Windows/PowerShell folgt
als eigener, separater Schritt, siehe CREDENTIAL_CATEGORIES-Kommentar)."""
return conn.execute(
"""
SELECT devices.mac, devices.name, devices.ip, devices.ssh_port,
devices.credential_id, devices.is_active,
credentials.username AS cred_username
FROM devices
JOIN credentials ON credentials.id = devices.credential_id
WHERE credentials.category = ? AND devices.deleted_at IS NULL AND credentials.deleted_at IS NULL
ORDER BY devices.name ASC
""",
(DEVICE_MAINTENANCE_CATEGORY,),
).fetchall()
# ---------------------------------------------------------------------------
# Papierkorb — Clients/Switche/Zugangsdaten/Benutzer (nur lokale Konten)/
# Custom-Gruppen landen beim "Löschen" nur noch hier (siehe delete_device/
# delete_switch/delete_credential/delete_user/delete_group weiter oben),
# bis sie wiederhergestellt oder endgültig gelöscht werden -- manuell über
# die Buttons auf der Wartung-Seite, oder automatisch nach Ablauf von
# trash_retention_days (siehe Systemeinstellungen). AD/LDAP-Benutzer
# durchlaufen den Papierkorb NICHT (siehe delete_user) -- sie legen sich bei
# jedem gültigen AD-Login ohnehin automatisch neu an.
# ---------------------------------------------------------------------------
TRASH_RETENTION_DEFAULT_DAYS = 30
def _trash_retention_days():
try:
return max(1, int(get_setting("trash_retention_days", str(TRASH_RETENTION_DEFAULT_DAYS))))
except (TypeError, ValueError):
return TRASH_RETENTION_DEFAULT_DAYS
def _trashed_devices(conn):
return conn.execute(
"SELECT mac, name, ip, deleted_at FROM devices WHERE deleted_at IS NOT NULL ORDER BY deleted_at DESC"
).fetchall()
def _trashed_switches(conn):
return conn.execute(
"SELECT hostname, ip, deleted_at FROM switches WHERE deleted_at IS NOT NULL ORDER BY deleted_at DESC"
).fetchall()
def _trashed_credentials(conn):
return conn.execute(
"SELECT id, name, username, category, deleted_at FROM credentials "
"WHERE deleted_at IS NOT NULL ORDER BY deleted_at DESC"
).fetchall()
def _trashed_users(conn):
return conn.execute(
"SELECT id, username, first_name, last_name, deleted_at FROM users "
"WHERE deleted_at IS NOT NULL ORDER BY deleted_at DESC"
).fetchall()
def _trashed_groups(conn):
return conn.execute(
"SELECT id, name, deleted_at FROM groups WHERE deleted_at IS NOT NULL ORDER BY deleted_at DESC"
).fetchall()
def _purge_device(conn, mac):
# Per-Client-DHCP-Options sind an die MAC gekoppelt (kein FK, siehe
# dhcp_option_values.device_mac) -- werden hier mit aufgeräumt, statt
# nach einem endgültigen Löschen verwaist stehen zu bleiben.
conn.execute("DELETE FROM dhcp_option_values WHERE device_mac=?", (mac,))
conn.execute("DELETE FROM devices WHERE mac=? AND deleted_at IS NOT NULL", (mac,))
def _purge_switch(conn, hostname):
conn.execute("DELETE FROM switches WHERE hostname=? AND deleted_at IS NOT NULL", (hostname,))
def _purge_credential(conn, cred_id):
conn.execute("DELETE FROM credentials WHERE id=? AND deleted_at IS NOT NULL", (cred_id,))
def _purge_user(conn, user_id):
conn.execute("DELETE FROM user_groups WHERE user_id=?", (user_id,))
conn.execute("DELETE FROM users WHERE id=? AND deleted_at IS NOT NULL", (user_id,))
def _purge_group(conn, group_id):
conn.execute("DELETE FROM group_permissions WHERE group_id=?", (group_id,))
conn.execute("DELETE FROM user_groups WHERE group_id=?", (group_id,))
# AD-Gruppenzuordnungen, die auf genau diese Gruppe zeigten, würden sonst
# dauerhaft ins Leere laufen (siehe _ldap_group_mappings()-Anzeige
# "(gelöscht)" für den Fall, dass das trotzdem irgendwo übersehen wird).
conn.execute("DELETE FROM ldap_group_mappings WHERE app_group_id=?", (group_id,))
conn.execute("DELETE FROM groups WHERE id=? AND deleted_at IS NOT NULL", (group_id,))
def _purge_expired_trash():
"""Wird bei jedem Aufruf der Wartung-Seite ausgeführt (kein eigener
Scheduler nötig, analog _purge_expired_imports()) -- löscht alle
Papierkorb-Einträge endgültig, deren Aufbewahrungsfrist abgelaufen ist."""
cutoff = (datetime.now() - timedelta(days=_trash_retention_days())).strftime("%Y-%m-%d %H:%M:%S")
conn = get_db_connection()
for row in conn.execute(
"SELECT mac FROM devices WHERE deleted_at IS NOT NULL AND deleted_at < ?", (cutoff,)
).fetchall():
_purge_device(conn, row["mac"])
for row in conn.execute(
"SELECT hostname FROM switches WHERE deleted_at IS NOT NULL AND deleted_at < ?", (cutoff,)
).fetchall():
_purge_switch(conn, row["hostname"])
for row in conn.execute(
"SELECT id FROM credentials WHERE deleted_at IS NOT NULL AND deleted_at < ?", (cutoff,)
).fetchall():
_purge_credential(conn, row["id"])
for row in conn.execute(
"SELECT id FROM users WHERE deleted_at IS NOT NULL AND deleted_at < ?", (cutoff,)
).fetchall():
_purge_user(conn, row["id"])
for row in conn.execute(
"SELECT id FROM groups WHERE deleted_at IS NOT NULL AND deleted_at < ?", (cutoff,)
).fetchall():
_purge_group(conn, row["id"])
conn.commit()
conn.close()
@app.route("/devices/restore/<mac>", methods=["POST"])
@login_required
def restore_device(mac):
if not current_user.has_permission("devices.edit"):
flash("Keine Berechtigung, Geräte wiederherzustellen.", "danger")
return redirect(url_for("papierkorb"))
conn = get_db_connection()
device = conn.execute("SELECT name FROM devices WHERE mac=? AND deleted_at IS NOT NULL", (mac,)).fetchone()
conn.execute("UPDATE devices SET deleted_at=NULL WHERE mac=? AND deleted_at IS NOT NULL", (mac,))
conn.commit()
conn.close()
log_action("device.restore", device["name"] if device else mac)
flash(f"Gerät {device['name'] if device else mac} wiederhergestellt.", "success")
return redirect(url_for("papierkorb"))
@app.route("/devices/purge/<mac>", methods=["POST"])
@login_required
def purge_device(mac):
if not current_user.has_permission("devices.edit"):
flash("Keine Berechtigung, Geräte endgültig zu löschen.", "danger")
return redirect(url_for("papierkorb"))
conn = get_db_connection()
device = conn.execute("SELECT name FROM devices WHERE mac=? AND deleted_at IS NOT NULL", (mac,)).fetchone()
_purge_device(conn, mac)
conn.commit()
conn.close()
log_action("device.purge", device["name"] if device else mac)
flash(f"Gerät {device['name'] if device else mac} endgültig gelöscht.", "success")
return redirect(url_for("papierkorb"))
@app.route("/switches/restore/<hostname>", methods=["POST"])
@login_required
def restore_switch(hostname):
if not current_user.has_permission("switches.edit"):
flash("Keine Berechtigung, Switche wiederherzustellen.", "danger")
return redirect(url_for("papierkorb"))
conn = get_db_connection()
conn.execute("UPDATE switches SET deleted_at=NULL WHERE hostname=? AND deleted_at IS NOT NULL", (hostname,))
conn.commit()
conn.close()
log_action("switch.restore", hostname)
flash(f"Switch {hostname} wiederhergestellt.", "success")
return redirect(url_for("papierkorb"))
@app.route("/switches/purge/<hostname>", methods=["POST"])
@login_required
def purge_switch(hostname):
if not current_user.has_permission("switches.edit"):
flash("Keine Berechtigung, Switche endgültig zu löschen.", "danger")
return redirect(url_for("papierkorb"))
conn = get_db_connection()
_purge_switch(conn, hostname)
conn.commit()
conn.close()
log_action("switch.purge", hostname)
flash(f"Switch {hostname} endgültig gelöscht.", "success")
return redirect(url_for("papierkorb"))
@app.route("/credentials/restore/<int:cred_id>", methods=["POST"])
@login_required
def restore_credential(cred_id):
if not current_user.has_permission("credentials.edit"):
flash("Keine Berechtigung, Zugangsdaten wiederherzustellen.", "danger")
return redirect(url_for("papierkorb"))
conn = get_db_connection()
cred = conn.execute("SELECT name FROM credentials WHERE id=? AND deleted_at IS NOT NULL", (cred_id,)).fetchone()
conn.execute("UPDATE credentials SET deleted_at=NULL WHERE id=? AND deleted_at IS NOT NULL", (cred_id,))
conn.commit()
conn.close()
log_action("credential.restore", cred["name"] if cred else cred_id)
flash(f"Zugangsdaten '{cred['name'] if cred else cred_id}' wiederhergestellt.", "success")
return redirect(url_for("papierkorb"))
@app.route("/credentials/purge/<int:cred_id>", methods=["POST"])
@login_required
def purge_credential(cred_id):
if not current_user.has_permission("credentials.edit"):
flash("Keine Berechtigung, Zugangsdaten endgültig zu löschen.", "danger")
return redirect(url_for("papierkorb"))
conn = get_db_connection()
cred = conn.execute("SELECT name FROM credentials WHERE id=? AND deleted_at IS NOT NULL", (cred_id,)).fetchone()
_purge_credential(conn, cred_id)
conn.commit()
conn.close()
log_action("credential.purge", cred["name"] if cred else cred_id)
flash(f"Zugangsdaten '{cred['name'] if cred else cred_id}' endgültig gelöscht.", "success")
return redirect(url_for("papierkorb"))
@app.route("/users/restore/<int:user_id>", methods=["POST"])
@login_required
def restore_user(user_id):
if not current_user.has_permission("users.edit"):
flash("Keine Berechtigung, Benutzer wiederherzustellen.", "danger")
return redirect(url_for("papierkorb"))
conn = get_db_connection()
target = conn.execute(
"SELECT username, is_admin FROM users WHERE id=? AND deleted_at IS NOT NULL", (user_id,)
).fetchone()
if target and target["is_admin"] and not current_user.is_admin:
flash("Nur Admins dürfen Admin-Konten wiederherstellen.", "danger")
conn.close()
return redirect(url_for("papierkorb"))
conn.execute("UPDATE users SET deleted_at=NULL WHERE id=? AND deleted_at IS NOT NULL", (user_id,))
conn.commit()
conn.close()
log_action("user.restore", target["username"] if target else user_id)
flash(f"Benutzer „{target['username'] if target else user_id}“ wiederhergestellt.", "success")
return redirect(url_for("papierkorb"))
@app.route("/users/purge/<int:user_id>", methods=["POST"])
@login_required
def purge_user(user_id):
if not current_user.has_permission("users.edit"):
flash("Keine Berechtigung, Benutzer endgültig zu löschen.", "danger")
return redirect(url_for("papierkorb"))
conn = get_db_connection()
target = conn.execute(
"SELECT username, is_admin FROM users WHERE id=? AND deleted_at IS NOT NULL", (user_id,)
).fetchone()
if target and target["is_admin"] and not current_user.is_admin:
flash("Nur Admins dürfen Admin-Konten endgültig löschen.", "danger")
conn.close()
return redirect(url_for("papierkorb"))
_purge_user(conn, user_id)
conn.commit()
conn.close()
log_action("user.purge", target["username"] if target else user_id)
flash(f"Benutzer „{target['username'] if target else user_id}“ endgültig gelöscht.", "success")
return redirect(url_for("papierkorb"))
@app.route("/groups/restore/<int:group_id>", methods=["POST"])
@login_required
def restore_group(group_id):
if not current_user.has_permission("groups.edit"):
flash("Keine Berechtigung, Gruppen wiederherzustellen.", "danger")
return redirect(url_for("papierkorb"))
conn = get_db_connection()
group = conn.execute("SELECT name FROM groups WHERE id=? AND deleted_at IS NOT NULL", (group_id,)).fetchone()
conn.execute("UPDATE groups SET deleted_at=NULL WHERE id=? AND deleted_at IS NOT NULL", (group_id,))
conn.commit()
conn.close()
log_action("group.restore", group["name"] if group else group_id)
flash(f"Gruppe '{group['name'] if group else group_id}' wiederhergestellt.", "success")
return redirect(url_for("papierkorb"))
@app.route("/groups/purge/<int:group_id>", methods=["POST"])
@login_required
def purge_group(group_id):
if not current_user.has_permission("groups.edit"):
flash("Keine Berechtigung, Gruppen endgültig zu löschen.", "danger")
return redirect(url_for("papierkorb"))
conn = get_db_connection()
group = conn.execute("SELECT name FROM groups WHERE id=? AND deleted_at IS NOT NULL", (group_id,)).fetchone()
_purge_group(conn, group_id)
conn.commit()
conn.close()
log_action("group.purge", group["name"] if group else group_id)
flash(f"Gruppe '{group['name'] if group else group_id}' endgültig gelöscht.", "success")
return redirect(url_for("papierkorb"))
@app.route("/maintenance")
@login_required
def maintenance():
if not current_user.can_view_maintenance:
flash("Keine Berechtigung für die Wartungsseite.", "danger")
return redirect(url_for("index"))
conn = get_db_connection()
devices_rows = _maintenance_devices(conn)
conn.close()
with _maintenance_jobs_lock:
# Abgeschlossene ERFOLGREICHE Aktionen werden bei jedem (erneuten)
# Laden der Seite zurückgesetzt (Status wieder "Noch keine Aktion")
# -- ein Erfolg ist nur so lange relevant, wie man ihn noch live
# über das Polling mitverfolgt; ein späterer Aufruf der Seite soll
# nicht dauerhaft eine ggf. Stunden alte Erfolgsmeldung zeigen.
# Fehlgeschlagene Aktionen bleiben dagegen bewusst sichtbar, bis
# eine neue Aktion sie überschreibt -- ein Fehler soll nicht durch
# einen einfachen Seiten-Reload versehentlich übersehen werden.
for mac in [m for m, job in _maintenance_jobs.items() if job.get("status") == "success"]:
del _maintenance_jobs[mac]
jobs_snapshot = {mac: dict(job) for mac, job in _maintenance_jobs.items()}
return render_template(
"maintenance.html",
devices=devices_rows,
jobs=jobs_snapshot,
can_run=current_user.can_run_maintenance,
)
@app.route("/papierkorb")
@login_required
def papierkorb():
if not current_user.can_view_papierkorb:
flash("Keine Berechtigung für den Papierkorb.", "danger")
return redirect(url_for("index"))
# Vor dem Laden der Papierkorb-Listen abgelaufene Einträge endgültig
# aufräumen -- kein eigener Scheduler nötig (analog dem Import-Vorschau-
# Token-Aufräumen), die Seite wird ohnehin regelmäßig aufgerufen.
_purge_expired_trash()
conn = get_db_connection()
trash = {
"devices": _trashed_devices(conn) if current_user.has_permission("devices.view") else [],
"switches": _trashed_switches(conn) if current_user.has_permission("switches.view") else [],
"credentials": _trashed_credentials(conn) if current_user.has_permission("credentials.view") else [],
"users": _trashed_users(conn) if current_user.has_permission("users.view") else [],
"groups": _trashed_groups(conn) if current_user.has_permission("groups.view") else [],
}
conn.close()
return render_template(
"papierkorb.html",
trash=trash,
trash_retention_days=_trash_retention_days(),
)
@app.route("/maintenance/status")
@login_required
def maintenance_status():
if not current_user.can_view_maintenance:
return jsonify({"error": "Keine Berechtigung."}), 403
with _maintenance_jobs_lock:
return jsonify({mac: dict(job) for mac, job in _maintenance_jobs.items()})
_MAINTENANCE_OUTPUT_MAX_LINES = 400
def _maintenance_run_update(mac, name, host, port, username, password):
now = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
_maintenance_job_set(
mac, action="update", status="running", message="Update läuft …",
output="", started=now, finished=None,
)
# Zeilen kommen live rein statt erst am Ende komplett -- Grundlage für
# die automatisch mitscrollende Live-Ausgabe im Frontend. Jede Zeile
# bekommt ihren eigenen Zeitstempel (wann sie tatsächlich ankam, nicht
# nur Start/Ende des gesamten Updates). Puffer bewusst auf die letzten
# _MAINTENANCE_OUTPUT_MAX_LINES begrenzt (ganze Zeilen, kein hartes
# Zeichen-Limit mehr), damit ein sehr langes Update den
# Prozessspeicher nicht unbegrenzt wachsen lässt.
lines = []
def on_line(line, is_err):
lines.append(f"{datetime.now().strftime('%Y-%m-%d %H:%M:%S')} {line}")
if len(lines) > _MAINTENANCE_OUTPUT_MAX_LINES:
del lines[: len(lines) - _MAINTENANCE_OUTPUT_MAX_LINES]
_maintenance_job_set(mac, output="\n".join(lines))
# overall_timeout=600: ein echtes "apt upgrade" kann bei vielen
# ausstehenden Paketen mehrere Minuten dauern -- der kurze
# Standard-Timeout der interaktiven SSH-Aktionen (25s) wäre hier viel
# zu knapp; der bleibt für den reinen Verbindungsaufbau bestehen.
result = _run_ssh_command_streaming(
host, port, username, password, _apt_upgrade_command(), on_line,
sudo_password=password, overall_timeout=600,
)
finished = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
# Eindeutiger Abschluss-Marker als letzte Zeile -- vorher endete die
# Ausgabe einfach mitten im rohen apt-Output, ohne erkennbar zu machen,
# ob das Update tatsächlich fertig oder nur abgeschnitten war.
if result["error"]:
lines.append(f"{finished} ✖ Update abgebrochen: {result['error']}")
_maintenance_job_set(mac, status="error", message=result["error"], output="\n".join(lines), finished=finished)
log_action_system("maintenance.update", name, f"fehlgeschlagen: {result['error']}")
elif result["success"]:
lines.append(f"{finished} ✔ All Updates Done")
_maintenance_job_set(mac, status="success", message="Update erfolgreich abgeschlossen.", output="\n".join(lines), finished=finished)
log_action_system("maintenance.update", name, "erfolgreich")
else:
lines.append(f"{finished} ✖ Update fehlgeschlagen (Exit-Code {result['exit_code']})")
_maintenance_job_set(
mac, status="error",
message=f"Update fehlgeschlagen (Exit-Code {result['exit_code']}).",
output="\n".join(lines), finished=finished,
)
log_action_system("maintenance.update", name, f"fehlgeschlagen: Exit-Code {result['exit_code']}")
# Nach einem ausgelösten Neustart blieb der Job-Status bisher für immer auf
# "Neustart ausgelöst" stehen -- der eigentlich interessante Zeitpunkt (ist
# das Gerät wieder erreichbar?) wurde nie erfasst. Gleiche Ping-Invocation
# wie das bestehende Online/Offline-Monitoring in poe.sh
# ("ping -c 1 -W 2 <ip>"), für konsistentes Verhalten mit dem Dashboard.
_MAINTENANCE_REBOOT_GRACE_SECONDS = 15
_MAINTENANCE_REBOOT_TIMEOUT_SECONDS = 300
_MAINTENANCE_REBOOT_POLL_INTERVAL = 5
def _ping_once(host, timeout=2):
try:
result = subprocess.run(
["ping", "-c", "1", "-W", str(timeout), host],
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, timeout=timeout + 2,
)
return result.returncode == 0
except (OSError, subprocess.SubprocessError):
return False
def _maintenance_wait_online_after_reboot(mac, name, host):
"""Wartet nach einem erfolgreich ausgelösten Neustart eine kurze
Schonfrist (die Maschine braucht einen Moment zum Herunterfahren, ein
zu früher Ping könnte noch die absterbende alte Instanz erreichen),
pingt danach regelmäßig, bis sie wieder antwortet oder ein Timeout
erreicht ist. Läuft im selben Hintergrund-Thread wie
_maintenance_run_reboot weiter, blockiert also niemanden."""
_maintenance_job_set(mac, status="running", message="Neustart ausgelöst, warte auf Online …")
time.sleep(_MAINTENANCE_REBOOT_GRACE_SECONDS)
deadline = time.time() + _MAINTENANCE_REBOOT_TIMEOUT_SECONDS
while time.time() < deadline:
if _ping_once(host):
_maintenance_job_set(
mac, status="success", message="Neustart erfolgreich — Gerät wieder online.",
finished=datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
)
log_action_system("maintenance.reboot", name, "wieder online")
return
time.sleep(_MAINTENANCE_REBOOT_POLL_INTERVAL)
_maintenance_job_set(
mac, status="error",
message=f"Nach dem Neustart nicht innerhalb von {_MAINTENANCE_REBOOT_TIMEOUT_SECONDS // 60} Minuten wieder online (Ping ohne Antwort).",
finished=datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
)
log_action_system("maintenance.reboot", name, "nach Neustart nicht wieder online")
def _maintenance_run_reboot(mac, name, host, port, username, password):
now = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
_maintenance_job_set(mac, action="reboot", status="running", message="Neustart wird ausgelöst …", output="", started=now, finished=None)
result = _run_ssh_reboot(host, port, username, password)
if result["error"]:
_maintenance_job_set(
mac, status="error", message=result["error"],
finished=datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
)
log_action_system("maintenance.reboot", name, f"fehlgeschlagen: {result['error']}")
return
log_action_system("maintenance.reboot", name, "ausgelöst")
_maintenance_wait_online_after_reboot(mac, name, host)
def _maintenance_dispatch(action_name, thread_target):
"""Gemeinsame Logik für /maintenance/update und /maintenance/reboot:
ausgewählte, berechtigte MACs auflösen, bereits laufende überspringen,
je Gerät einen Hintergrund-Thread starten."""
macs = set(request.form.getlist("macs"))
if not macs:
flash("Bitte mindestens ein Gerät auswählen.", "danger")
return redirect(url_for("maintenance"))
conn = get_db_connection()
eligible = {d["mac"]: d for d in _maintenance_devices(conn) if d["mac"] in macs}
started = []
for mac, device in eligible.items():
with _maintenance_jobs_lock:
existing = _maintenance_jobs.get(mac)
if existing and existing.get("status") == "running":
continue
host, port, username, password, error = _resolve_device_ssh_target(conn, device)
if error:
_maintenance_job_immediate_error(mac, action_name, error)
continue
thread = threading.Thread(
target=thread_target,
args=(mac, device["name"], host, port, username, password),
daemon=True,
)
thread.start()
started.append(device["name"])
conn.close()
if started:
flash(f"{action_name.capitalize()} gestartet für: {', '.join(started)}", "success")
else:
flash("Keine Aktion gestartet (bereits laufend oder Zugangsdaten fehlerhaft).", "danger")
return redirect(url_for("maintenance"))
@app.route("/maintenance/update", methods=["POST"])
@login_required
def maintenance_update():
if not current_user.can_run_maintenance:
flash("Keine Berechtigung, Wartungsaktionen auszuführen.", "danger")
return redirect(url_for("maintenance"))
return _maintenance_dispatch("update", _maintenance_run_update)
@app.route("/maintenance/reboot", methods=["POST"])
@login_required
def maintenance_reboot():
if not current_user.can_run_maintenance:
flash("Keine Berechtigung, Wartungsaktionen auszuführen.", "danger")
return redirect(url_for("maintenance"))
return _maintenance_dispatch("reboot", _maintenance_run_reboot)
# ---------------------------------------------------------------------------
# Browser-SSH-Terminal — Verbindungstest beim Anlegen/Bearbeiten von Switchen.
#
# Öffnet eine echte interaktive SSH-Sitzung im Browser, damit Host-Keys beim
# Erstkontakt bestätigt und Zugangsdaten geprüft werden können, bevor der
# Switch überhaupt gespeichert ist. Implementiert mit paramiko (reines
# Python) statt eines lokalen pty + System-`ssh`-Client — das läuft auf
# jedem Betriebssystem (Windows, Linux, macOS), auf dem die Flask-App läuft,
# und die SSH-Verbindung wird dabei immer vom PoE-Server aus aufgebaut,
# niemals vom Browser/Client aus. Nur für eingeloggte Admins.
# ---------------------------------------------------------------------------
_SSH_HOST_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9\.\-:]{0,253}$")
_SSH_USER_RE = re.compile(r"^[A-Za-z0-9_.\-]{1,64}$")
def _send_and_close(ws, message):
"""
Nachricht senden und Verbindung schließen. Die kurze Pause dazwischen
zwingt beide WebSocket-Frames auf getrennte TCP-Schreibvorgänge — ohne
sie kommt es bei manchen Browsern vor, dass eine Nachricht direkt gefolgt
von der Schließung der Verbindung als Frame-Fehler gewertet und die
Nachricht verworfen wird, bevor sie angezeigt werden kann.
"""
try:
ws.send(message)
time.sleep(0.15)
except ConnectionClosed:
pass # Client ist schon weg — nichts mehr zuzustellen, kein Fehlerfall.
try:
ws.close()
except Exception:
pass
def _terminal_read_line(ws, echo=True):
"""Liest zeichenweise vom Terminal (wie normale Tastatureingaben), bis
Enter kommt — für Prompts, bevor eine Remote-Shell existiert, die das
selbst übernehmen könnte (Host-Key-Bestätigung, Passwortabfrage).
echo=False unterdrückt das lokale Echo (Passwort-Eingabe), genau wie
ein normales ssh-CLI beim Tippen des Passworts nichts anzeigt."""
line = ""
while True:
try:
raw = ws.receive(timeout=120)
except ConnectionClosed:
return None
if raw is None:
return None
try:
payload = json.loads(raw)
except (TypeError, ValueError):
continue
if payload.get("type") != "input":
continue
for ch in str(payload.get("data", "")):
if ch in ("\r", "\n"):
ws.send("\r\n")
return line
elif ch in ("\x7f", "\b"):
if line:
line = line[:-1]
if echo:
ws.send("\b \b")
elif ch == "\x03": # Strg+C
ws.send("^C\r\n")
return None
else:
line += ch
if echo:
ws.send(ch)
def _verify_host_key_interactive(ws, host, port, server_key):
"""Emuliert das bekannte SSH-CLI-Verhalten bei Host-Keys — unbekannt:
interaktiv über das Browser-Terminal nachfragen (wie "Are you sure you
want to continue connecting?"); GEÄNDERT (Eintrag vorhanden, aber
anderer Key): hart ablehnen ohne Rückfrage, wie ein normales ssh-CLI
bei einer möglichen MITM-Situation. Gibt True zurück, wenn die
Verbindung fortgesetzt werden darf."""
lookup_host = host if port == 22 else f"[{host}]:{port}"
hostkeys = paramiko.HostKeys()
try:
hostkeys.load(SSH_KNOWN_HOSTS_PATH)
except (OSError, paramiko.SSHException):
pass
existing = hostkeys.lookup(lookup_host)
if existing and existing.get(server_key.get_name()) and existing[server_key.get_name()] != server_key:
ws.send(
f"\r\n\x1b[31mWARNUNG: Host-Key für '{lookup_host}' hat sich geändert "
f"— Verbindung abgelehnt (möglicher Man-in-the-Middle).\x1b[0m\r\n"
)
return False
if hostkeys.check(lookup_host, server_key):
return True
fingerprint = ":".join(f"{b:02x}" for b in server_key.get_fingerprint())
ws.send(
f"\r\nDie Authentizität von Host '{lookup_host}' kann nicht festgestellt werden.\r\n"
f"{server_key.get_name()}-Key-Fingerabdruck: {fingerprint}\r\n"
f"Trotzdem verbinden (yes/no)? "
)
answer = _terminal_read_line(ws, echo=True)
if not answer or answer.strip().lower() not in ("yes", "y"):
return False
hostkeys.add(lookup_host, server_key.get_name(), server_key)
try:
if not os.path.exists(SSH_KNOWN_HOSTS_PATH):
open(SSH_KNOWN_HOSTS_PATH, "a").close()
hostkeys.save(SSH_KNOWN_HOSTS_PATH)
except OSError:
app.logger.error("save_host_keys failed:\n%s", traceback.format_exc())
return True
def _authenticate_interactive(ws, transport, username):
"""Fragt Zugangsdaten genauso ab, wie es ein echtes ssh-CLI täte —
direkt über die WebSocket-Verbindung, da an dieser Stelle noch keine
Remote-Shell existiert, in die man tippen könnte. Vorher wurde hier nie
tatsächlich ein Passwort abgefragt (das Init-Payload vom Browser enthält
gar keins) — SSHClient.connect() ohne password/pkey/agent scheitert
sofort mit "No authentication methods available", bevor überhaupt eine
Passwortabfrage möglich wäre. Fragt daher aktiv nach, welche
Auth-Methoden der Server anbietet, und fordert bei Bedarf interaktiv
zur Eingabe auf (Passwort oder keyboard-interactive)."""
allowed = []
try:
transport.auth_none(username)
except paramiko.BadAuthenticationType as e:
allowed = e.allowed_types
except paramiko.SSHException:
allowed = []
if transport.is_authenticated():
return True
if "keyboard-interactive" in allowed:
def handler(title, instructions, prompt_list):
responses = []
for prompt_text, echo in prompt_list:
ws.send(f"\r\n{prompt_text}")
responses.append(_terminal_read_line(ws, echo=bool(echo)) or "")
return responses
try:
transport.auth_interactive(username, handler)
except paramiko.AuthenticationException:
pass
elif "password" in allowed:
ws.send("\r\nPasswort: ")
pw = _terminal_read_line(ws, echo=False)
try:
transport.auth_password(username, pw or "")
except paramiko.AuthenticationException:
pass
else:
ws.send(f"\r\n\x1b[31mServer bietet keine unterstützte Auth-Methode an ({', '.join(allowed) or 'keine'}).\x1b[0m\r\n")
return transport.is_authenticated()
@sock.route("/ws/ssh_terminal")
def ssh_terminal(ws):
# Fängt unerwartete Fehler ab und loggt sie, statt sie in flask-sock's
# Verbindungs-Handling verschwinden zu lassen.
try:
return _ssh_terminal_inner(ws)
except Exception:
app.logger.error("ssh_terminal crashed:\n%s", traceback.format_exc())
raise
def _ssh_terminal_inner(ws):
if not current_user.is_authenticated or not current_user.is_admin:
_send_and_close(ws, "\r\n\x1b[31mZugriff verweigert.\x1b[0m\r\n")
return
try:
init = json.loads(ws.receive(timeout=10) or "{}")
except Exception:
ws.close()
return
host = str(init.get("host") or "").strip()
username = str(init.get("username") or "").strip()
port_str = str(init.get("port") or "22").strip()
if not (_SSH_HOST_RE.match(host) and _SSH_USER_RE.match(username) and port_str.isdigit()):
_send_and_close(ws, "\r\n\x1b[31mUngültiger Host oder Benutzername.\x1b[0m\r\n")
return
port = int(port_str)
ws.send(f"\x1b[90m→ Verbinde zu {username}@{host}:{port}\x1b[0m\r\n")
# Bewusst die Low-Level-Transport-API statt SSHClient.connect(): eine
# Passwortabfrage MITTEN im Verbindungsaufbau (nach Host-Key-Bestätigung,
# vor der eigentlichen Shell) ist mit der High-Level-API nicht möglich,
# da connect() Host-Key-Prüfung UND Authentifizierung in einem
# blockierenden Aufruf erledigt. Vorher führte das dazu, dass nie ein
# Passwort abgefragt wurde und jeder Login mit "No authentication
# methods available" scheiterte, sobald kein Passwort im initialen
# Payload mitgeschickt wurde (was nie der Fall war/ist).
try:
sock_conn = socket.create_connection((host, port), timeout=10)
except OSError as e:
_send_and_close(ws, f"\r\n\x1b[31m✗ Verbindung fehlgeschlagen: {e}\x1b[0m\r\n")
return
transport = paramiko.Transport(sock_conn)
try:
transport.start_client(timeout=10)
except (paramiko.SSHException, EOFError, OSError) as e:
_send_and_close(ws, f"\r\n\x1b[31m✗ Verbindung fehlgeschlagen: {e}\x1b[0m\r\n")
return
if not _verify_host_key_interactive(ws, host, port, transport.get_remote_server_key()):
transport.close()
_send_and_close(ws, "\r\n\x1b[33mHost-Key abgelehnt oder Verbindung abgebrochen.\x1b[0m\r\n")
return
try:
authenticated = _authenticate_interactive(ws, transport, username)
except (paramiko.SSHException, OSError, EOFError) as e:
transport.close()
_send_and_close(ws, f"\r\n\x1b[31m✗ Verbindung fehlgeschlagen: {e}\x1b[0m\r\n")
return
if not authenticated:
transport.close()
_send_and_close(ws, "\r\n\x1b[31m✗ Zugangsdaten falsch (Authentifizierung fehlgeschlagen).\x1b[0m\r\n")
return
ws.send("\r\n\x1b[32m✓ Login erfolgreich — Sitzung wird geöffnet …\x1b[0m\r\n")
try:
channel = transport.open_session(timeout=10)
channel.get_pty(term="xterm-256color", width=80, height=24)
channel.invoke_shell()
except paramiko.SSHException as e:
_send_and_close(ws, f"\r\n\x1b[31m✗ Konnte keine Shell öffnen: {e}\x1b[0m\r\n")
transport.close()
return
channel.settimeout(0.0)
closed = threading.Event()
def pump_output():
while not closed.is_set():
try:
if channel.recv_ready():
data = channel.recv(4096)
if not data:
break
ws.send(data.decode("utf-8", errors="replace"))
else:
time.sleep(0.03)
if channel.closed or channel.exit_status_ready():
if not channel.recv_ready():
break
except (OSError, EOFError):
break
except Exception:
break
closed.set()
reader = threading.Thread(target=pump_output, daemon=True)
reader.start()
try:
while not closed.is_set():
message = ws.receive()
if message is None:
break
try:
payload = json.loads(message)
except (TypeError, ValueError):
continue
if payload.get("type") == "input":
try:
channel.send(str(payload.get("data", "")))
except OSError:
break
elif payload.get("type") == "resize":
try:
channel.resize_pty(width=int(payload.get("cols", 80)), height=int(payload.get("rows", 24)))
except paramiko.SSHException:
pass
except Exception:
pass
finally:
closed.set()
try:
channel.close()
except Exception:
pass
try:
transport.close()
except Exception:
pass
# ---------------------------------------------------------------------------
# Wartung: nicht-interaktive SSH-Kommandos (Bulk-Update, Neustart)
#
# Bewusst getrennt von _ssh_terminal_inner() oben: dort kann bei einem
# unbekannten Host-Key interaktiv "yes/no" nachgefragt werden (wie ein
# echtes ssh-CLI). Hier läuft nichts vor einem Menschen ab (Bulk-Aktion über
# mehrere Geräte per Thread) -- ein unbekannter/nicht vorher bestätigter
# Host-Key wird deshalb hart abgelehnt (RejectPolicy) statt automatisch
# akzeptiert (AutoAddPolicy wäre hier ein MITM-Risiko ohne jede Prüfung).
# Ein Host muss also immer zuerst einmal über "Verbindung testen" (das
# interaktive Terminal) bestätigt worden sein, bevor Wartungsaktionen für
# ihn möglich sind -- beide Wege teilen sich dieselbe SSH_KNOWN_HOSTS_PATH.
# ---------------------------------------------------------------------------
def _apt_upgrade_command():
"""Sicheres, nicht-interaktives 'apt update && apt upgrade -y':
DEBIAN_FRONTEND=noninteractive plus --force-confdef/--force-confold
verhindern, dass ein Paket-Postinst-Skript auf eine Konfig-Datei-
Rückfrage wartet, die nie kommt (das Kommando würde sonst ohne PTY
unbeaufsichtigt hängen bleiben, bis der Timeout zuschlägt)."""
inner = (
"env DEBIAN_FRONTEND=noninteractive apt-get update && "
'env DEBIAN_FRONTEND=noninteractive apt-get -y '
'-o Dpkg::Options::="--force-confdef" -o Dpkg::Options::="--force-confold" upgrade'
)
return f"sudo -S -p '' bash -c {shlex.quote(inner)}"
def _resolve_device_ssh_target(conn, device):
"""Löst Host/Port/Zugangsdaten für eine SSH-Wartungsaktion an einem
Gerät auf. Gibt (host, port, username, password, error) zurück -- bei
einem Fehler ist error gesetzt und die übrigen Werte None."""
if not device["credential_id"]:
return None, None, None, None, f"{device['name']}: keine SSH-Zugangsdaten hinterlegt."
cred = conn.execute(
"SELECT username, password FROM credentials WHERE id=? AND deleted_at IS NULL", (device["credential_id"],)
).fetchone()
if not cred:
return None, None, None, None, f"{device['name']}: hinterlegte Zugangsdaten existieren nicht mehr."
port = device["ssh_port"] or SWITCH_DEFAULT_SSH_PORT
try:
password = decrypt_password(cred["password"])
except Exception:
return None, None, None, None, f"{device['name']}: Zugangsdaten konnten nicht entschlüsselt werden."
return device["ip"], port, cred["username"], password, None
def _run_ssh_command_streaming(host, port, username, password, command, on_line,
sudo_password=None, connect_timeout=25, overall_timeout=600):
"""Wie _run_ssh_command, liest die Ausgabe aber zeilenweise WÄHREND das
Kommando noch läuft, statt sie erst nach Abschluss komplett auf einmal
zu liefern -- Grundlage für die live mitwachsende, automatisch
mitscrollende Ausgabe beim Wartungs-Update (siehe
pollMaintenanceStatus() in maintenance.html). on_line(line, is_stderr)
wird für jede vollständige Zeile aufgerufen, sobald sie eintrifft."""
result = {"success": False, "exit_code": None, "stdout": "", "stderr": "", "error": None}
if not os.path.exists(SSH_KNOWN_HOSTS_PATH):
result["error"] = (
"Host-Key nicht bekannt — bitte zuerst per 'Verbindung testen' "
"einmal interaktiv verbinden und den Host-Key bestätigen."
)
return result
client = paramiko.SSHClient()
client.load_host_keys(SSH_KNOWN_HOSTS_PATH)
client.set_missing_host_key_policy(paramiko.RejectPolicy())
try:
client.connect(
host, port=port, username=username, password=password,
timeout=connect_timeout, banner_timeout=connect_timeout, auth_timeout=connect_timeout,
look_for_keys=False, allow_agent=False,
)
except paramiko.BadHostKeyException:
result["error"] = "Host-Key hat sich geändert — Verbindung abgelehnt (möglicher Man-in-the-Middle)."
return result
except paramiko.AuthenticationException:
result["error"] = "Authentifizierung fehlgeschlagen (falsche Zugangsdaten)."
return result
except paramiko.SSHException as e:
result["error"] = f"Host-Key unbekannt — bitte zuerst per 'Verbindung testen' bestätigen ({e})."
return result
except (OSError, socket.error) as e:
result["error"] = f"Verbindung fehlgeschlagen: {e}"
return result
out_parts, err_parts = [], []
out_buf, err_buf = bytearray(), bytearray()
def drain(ready, recv, buf, parts, is_err):
got_data = False
while ready():
chunk = recv(4096)
if not chunk:
break
buf.extend(chunk)
got_data = True
while b"\n" in buf:
idx = buf.index(b"\n")
line = bytes(buf[:idx]).decode("utf-8", errors="replace").rstrip("\r")
del buf[:idx + 1]
parts.append(line)
on_line(line, is_err)
return got_data
try:
stdin, stdout, stderr = client.exec_command(command, timeout=connect_timeout)
channel = stdout.channel
if sudo_password is not None:
try:
stdin.write(sudo_password + "\n")
stdin.flush()
except OSError:
pass
deadline = time.time() + overall_timeout
while True:
got = drain(channel.recv_ready, channel.recv, out_buf, out_parts, False)
got = drain(channel.recv_stderr_ready, channel.recv_stderr, err_buf, err_parts, True) or got
if channel.exit_status_ready() and not channel.recv_ready() and not channel.recv_stderr_ready():
break
if time.time() > deadline:
result["error"] = f"Kommando nach {overall_timeout}s abgebrochen (Timeout)."
return result
if not got:
time.sleep(0.2)
# Ein letzter, nicht mit "\n" abgeschlossener Rest zählt noch als Zeile
# (apt beendet die letzte Ausgabezeile nicht immer mit Zeilenumbruch).
if out_buf:
line = bytes(out_buf).decode("utf-8", errors="replace")
out_parts.append(line)
on_line(line, False)
if err_buf:
line = bytes(err_buf).decode("utf-8", errors="replace")
err_parts.append(line)
on_line(line, True)
result["exit_code"] = channel.recv_exit_status()
result["stdout"] = "\n".join(out_parts)
result["stderr"] = "\n".join(err_parts)
result["success"] = result["exit_code"] == 0
except (paramiko.SSHException, OSError) as e:
result["error"] = f"Kommando fehlgeschlagen: {e}"
finally:
try:
client.close()
except Exception:
pass
return result
def _run_ssh_command(host, port, username, password, command, sudo_password=None, timeout=25):
"""Führt EIN nicht-interaktives Kommando per SSH aus (kein Terminal,
keine Rückfragemöglichkeit). Gibt ein dict zurück:
{"success": bool, "exit_code": int|None, "stdout": str, "stderr": str,
"error": str|None} -- "error" ist nur bei Verbindungs-/Auth-Fehlern
gesetzt, ein regulär durchgelaufenes aber fehlgeschlagenes Kommando
(exit_code != 0) zählt als success=False mit leerem error."""
result = {"success": False, "exit_code": None, "stdout": "", "stderr": "", "error": None}
if not os.path.exists(SSH_KNOWN_HOSTS_PATH):
result["error"] = (
"Host-Key nicht bekannt — bitte zuerst per 'Verbindung testen' "
"einmal interaktiv verbinden und den Host-Key bestätigen."
)
return result
client = paramiko.SSHClient()
client.load_host_keys(SSH_KNOWN_HOSTS_PATH)
client.set_missing_host_key_policy(paramiko.RejectPolicy())
try:
client.connect(
host, port=port, username=username, password=password,
timeout=timeout, banner_timeout=timeout, auth_timeout=timeout,
look_for_keys=False, allow_agent=False,
)
except paramiko.BadHostKeyException:
result["error"] = "Host-Key hat sich geändert — Verbindung abgelehnt (möglicher Man-in-the-Middle)."
return result
except paramiko.AuthenticationException:
result["error"] = "Authentifizierung fehlgeschlagen (falsche Zugangsdaten)."
return result
except paramiko.SSHException as e:
result["error"] = f"Host-Key unbekannt — bitte zuerst per 'Verbindung testen' bestätigen ({e})."
return result
except (OSError, socket.error) as e:
result["error"] = f"Verbindung fehlgeschlagen: {e}"
return result
try:
stdin, stdout, stderr = client.exec_command(command, timeout=timeout)
if sudo_password is not None:
try:
stdin.write(sudo_password + "\n")
stdin.flush()
except OSError:
pass
result["exit_code"] = stdout.channel.recv_exit_status()
result["stdout"] = stdout.read().decode("utf-8", errors="replace")
result["stderr"] = stderr.read().decode("utf-8", errors="replace")
result["success"] = result["exit_code"] == 0
except (paramiko.SSHException, OSError) as e:
result["error"] = f"Kommando fehlgeschlagen: {e}"
finally:
try:
client.close()
except Exception:
pass
return result
def _run_ssh_reboot(host, port, username, password, timeout=15):
"""Startet 'sudo reboot' per SSH. Verbindungs-/Auth-Fehler VOR dem
eigentlichen Neustart-Kommando gelten als echter Fehler; ein abrupter
Verbindungsabbruch WÄHREND/NACH dem Kommando ist dagegen erwartet (die
Maschine fährt herunter, bevor sie überhaupt noch antworten kann) und
zählt als Erfolg."""
result = {"success": False, "error": None}
if not os.path.exists(SSH_KNOWN_HOSTS_PATH):
result["error"] = (
"Host-Key nicht bekannt — bitte zuerst per 'Verbindung testen' "
"einmal interaktiv verbinden und den Host-Key bestätigen."
)
return result
client = paramiko.SSHClient()
client.load_host_keys(SSH_KNOWN_HOSTS_PATH)
client.set_missing_host_key_policy(paramiko.RejectPolicy())
try:
client.connect(
host, port=port, username=username, password=password,
timeout=timeout, banner_timeout=timeout, auth_timeout=timeout,
look_for_keys=False, allow_agent=False,
)
except paramiko.BadHostKeyException:
result["error"] = "Host-Key hat sich geändert — Verbindung abgelehnt (möglicher Man-in-the-Middle)."
return result
except paramiko.AuthenticationException:
result["error"] = "Authentifizierung fehlgeschlagen (falsche Zugangsdaten)."
return result
except paramiko.SSHException as e:
result["error"] = f"Host-Key unbekannt — bitte zuerst per 'Verbindung testen' bestätigen ({e})."
return result
except (OSError, socket.error) as e:
result["error"] = f"Verbindung fehlgeschlagen: {e}"
return result
try:
stdin, stdout, stderr = client.exec_command("sudo -S -p '' reboot", timeout=timeout)
stdin.write(password + "\n")
stdin.flush()
stdout.channel.recv_exit_status()
except (paramiko.SSHException, OSError, EOFError):
pass # Verbindungsabbruch durch den Neustart selbst ist erwartet
finally:
try:
client.close()
except Exception:
pass
result["success"] = True
return result
# ---------------------------------------------------------------------------
# Logs
# ---------------------------------------------------------------------------
@app.route("/get_log")
@login_required
def get_log():
if not current_user.can_view_live_log:
return "Keine Berechtigung.", 403
latest_log = _latest_log_file()
if not latest_log:
return "Keine Logfiles gefunden."
try:
with open(latest_log, "r") as f:
content = f.read()
except Exception as e:
content = f"Fehler beim Lesen des Logs: {e}"
# Dateiname als Header mitschicken, damit die Anzeige auch nach einem
# Service-Neustart (neues Logfile, z.B. durch "Jetzt prüfen") ohne
# Seiten-Reload aktuell bleibt.
response = app.response_class(content, mimetype="text/plain")
response.headers["X-Log-Name"] = os.path.basename(latest_log)
return response
@app.route("/logs")
@login_required
def logs():
if not current_user.can_view_live_log:
flash("Keine Berechtigung, das Live-Log einzusehen.", "danger")
return redirect(url_for("index"))
interval = int(get_setting("interval", 5))
latest_log = _latest_log_file()
if not latest_log:
return render_template("logs.html", log_content="Keine Logfiles gefunden.", interval=interval)
try:
with open(latest_log, "r") as f:
log_content = f.read()
except Exception as e:
log_content = f"Fehler beim Lesen des Logs: {e}"
return render_template(
"logs.html", log_content=log_content, log_name=os.path.basename(latest_log), interval=interval
)
@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(TESM_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 TESM_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(TESM_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():
"""Änderungslog: wer hat was geändert (Aktivieren/Deaktivieren, Anlegen,
Bearbeiten, Löschen). Bewusst OHNE PoE-Neustarts — die stehen im Live-Log."""
if not current_user.can_view_activity_log:
flash("Keine Berechtigung, das Änderungslog einzusehen.", "danger")
return redirect(url_for("index"))
conn = get_db_connection()
entries = conn.execute(
"SELECT id, ts, username, action, target, details FROM audit_log ORDER BY id DESC LIMIT 500"
).fetchall()
avatars = {
row["username"]: row["avatar_filename"]
for row in conn.execute("SELECT username, avatar_filename FROM users").fetchall()
}
conn.close()
return render_template("activity_log.html", entries=entries, avatars=avatars)
# ---------------------------------------------------------------------------
# Manueller PoE-Neustart
# ---------------------------------------------------------------------------
@app.route("/restart/<mac>", methods=["POST"])
@login_required
def restart_device(mac):
if not current_user.has_permission("devices.restart"):
return jsonify({"success": False, "message": "Keine Berechtigung für den PoE-Neustart."}), 403
conn = get_db_connection()
device = conn.execute(
"SELECT mac, name, is_active, switch_hostname, port FROM devices WHERE mac=? AND deleted_at IS NULL", (mac,)
).fetchone()
conn.close()
if not device:
return jsonify({"success": False, "message": "Gerät wurde nicht gefunden."}), 404
if device["is_active"] == 0:
return jsonify({"success": False, "message": f"{device['name']} ist deaktiviert."}), 400
# Ohne zugewiesenen Switch + Port kann poe.sh keinen PoE-Reset auslösen
# (siehe poe.sh: der SSH-Restart-Block wird dort übersprungen) — dann
# lieber gar nicht erst einen "Neustart gestartet"-Erfolg vorgaukeln.
if not device["switch_hostname"] or not device["port"]:
return jsonify({"success": False, "message": f"{device['name']} hat keinen Switch/Port zugewiesen — PoE-Neustart nicht möglich."}), 400
try:
subprocess.Popen(
["/bin/bash", POE_SCRIPT, "restart", mac],
env={"PATH": os.path.join(BASE_DIR, "venv", "bin") + ":" + os.environ.get("PATH", "")},
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
)
return jsonify({"success": True, "device": device["name"]})
except Exception as e:
return jsonify({"success": False, "message": str(e)}), 500
# ---------------------------------------------------------------------------
# Users
# ---------------------------------------------------------------------------
@app.route("/users", methods=["GET", "POST"])
@login_required
def users():
if request.method == "GET" and not current_user.can_manage_users:
flash("Keine Berechtigung, die Benutzerverwaltung anzusehen.", "danger")
return redirect(url_for("index"))
conn = get_db_connection()
if request.method == "POST":
if "add_user" in request.form:
if not current_user.has_permission("users.create"):
flash("Keine Berechtigung, Benutzer anzulegen.", "danger")
return redirect(url_for("users"))
username = request.form["username"].strip()
password = request.form["password"].strip()
first_name = request.form.get("first_name", "").strip() or None
last_name = request.form.get("last_name", "").strip() or None
email = request.form.get("email", "").strip() or None
# Die Gruppen-Auswahl entscheidet auch über die Rolle: Auswahl
# "admin" macht den Benutzer zum Admin, jede andere Auswahl ist
# eine normale Gruppe (oder keine). Nur echte Admins dürfen
# jemanden direkt als Admin anlegen (Eskalationsschutz) —
# delegierte "Benutzer anlegen"-Rechte reichen dafür nicht.
group_choice = request.form.get("group_id") or ""
if group_choice == "admin" and not current_user.is_admin:
flash("Nur Admins dürfen andere Benutzer zu Admins machen.", "danger")
return redirect(url_for("users"))
is_admin = 1 if group_choice == "admin" else 0
if username and password:
pw_hash = bcrypt.generate_password_hash(password).decode("utf-8")
try:
cur = conn.execute(
"INSERT INTO users (username, password, is_admin, first_name, last_name, email) "
"VALUES (?, ?, ?, ?, ?, ?)",
(username, pw_hash, is_admin, first_name, last_name, email),
)
if not is_admin and group_choice:
conn.execute(
"INSERT OR IGNORE INTO user_groups (user_id, group_id) VALUES (?, ?)",
(cur.lastrowid, group_choice),
)
conn.commit()
log_action("user.create", username)
flash(f"Benutzer '{username}' erfolgreich angelegt!", "success")
except sqlite3.IntegrityError:
flash("Benutzername existiert bereits (ggf. im Papierkorb unter Wartung)!", "danger")
else:
flash("Username und Passwort dürfen nicht leer sein!", "danger")
elif "edit_user" in request.form:
if not current_user.has_permission("users.edit"):
flash("Keine Berechtigung, Benutzer zu bearbeiten.", "danger")
return redirect(url_for("users"))
# Nur Stammdaten + optional Passwort — Gruppe/Rolle wird
# ausschließlich über "Gruppe zuweisen" geändert (s.u.).
user_id = request.form["user_id"]
# Admin-Konten dürfen nur von echten Admins bearbeitet werden
# (u.a. Passwort-Reset) — verhindert, dass ein delegiertes
# "Benutzer bearbeiten"-Recht zur Übernahme eines Admin-Kontos
# missbraucht wird.
target_user = conn.execute(
"SELECT is_admin, auth_source FROM users WHERE id=? AND deleted_at IS NULL", (user_id,)
).fetchone()
if target_user and target_user["is_admin"] and not current_user.is_admin:
flash("Nur Admins dürfen Admin-Konten bearbeiten.", "danger")
return redirect(url_for("users"))
# AD/LDAP-Konten werden vollständig über AD verwaltet (Name,
# E-Mail/UPN, Passwort) — hier bearbeiten würde nur mit dem
# nächsten Login wieder überschrieben werden. Einzig zulässige
# Aktion für solche Konten ist Sperren/Entsperren und die
# Gruppenzuweisung (siehe toggle_lock/assign_group).
if target_user and target_user["auth_source"] == "ldap":
flash("AD/LDAP-Konten werden über Active Directory verwaltet und können hier nicht bearbeitet werden — nur sperren oder eine Gruppe zuweisen.", "danger")
return redirect(url_for("users"))
username = request.form.get("username", "").strip()
first_name = request.form.get("first_name", "").strip() or None
last_name = request.form.get("last_name", "").strip() or None
email = request.form.get("email", "").strip() or None
new_password = request.form.get("new_password", "").strip()
if username:
if new_password:
pw_hash = bcrypt.generate_password_hash(new_password).decode("utf-8")
conn.execute(
"UPDATE users SET username=?, first_name=?, last_name=?, email=?, password=? "
"WHERE id=? AND deleted_at IS NULL",
(username, first_name, last_name, email, pw_hash, user_id),
)
else:
conn.execute(
"UPDATE users SET username=?, first_name=?, last_name=?, email=? "
"WHERE id=? AND deleted_at IS NULL",
(username, first_name, last_name, email, user_id),
)
conn.commit()
log_action("user.edit", username)
flash("Benutzer aktualisiert!", "success")
else:
flash("Username darf nicht leer sein!", "danger")
elif "assign_group" in request.form:
if not current_user.has_permission("users.edit"):
flash("Keine Berechtigung, Gruppen zuzuweisen.", "danger")
return redirect(url_for("users"))
user_id = request.form["user_id"]
choice = request.form.get("group_id") or ""
# Sowohl das Zuweisen ZUR Admin-Gruppe als auch das Ändern der
# Gruppe eines BESTEHENDEN Admins bleibt echten Admins
# vorbehalten (Eskalationsschutz) — ein delegiertes
# "Gruppe zuweisen"-Recht darf niemanden zum Admin machen oder
# einem Admin die Rechte entziehen.
target = conn.execute(
"SELECT username, is_admin FROM users WHERE id=? AND deleted_at IS NULL", (user_id,)
).fetchone()
if (choice == "admin" or (target and target["is_admin"])) and not current_user.is_admin:
flash("Nur Admins dürfen Admin-Zuweisungen ändern.", "danger")
return redirect(url_for("users"))
# Mindestens ein Admin muss bestehen bleiben.
if target and target["is_admin"] and choice != "admin":
admin_count = conn.execute(
"SELECT COUNT(*) AS n FROM users WHERE is_admin=1 AND deleted_at IS NULL"
).fetchone()["n"]
if admin_count <= 1:
flash("Es muss mindestens ein Admin bestehen bleiben.", "danger")
conn.close()
return redirect(url_for("users"))
conn.execute("DELETE FROM user_groups WHERE user_id=?", (user_id,))
if choice == "admin":
conn.execute("UPDATE users SET is_admin=1 WHERE id=? AND deleted_at IS NULL", (user_id,))
else:
conn.execute("UPDATE users SET is_admin=0 WHERE id=? AND deleted_at IS NULL", (user_id,))
if choice:
conn.execute(
"INSERT OR IGNORE INTO user_groups (user_id, group_id) VALUES (?, ?)",
(user_id, choice),
)
conn.commit()
log_action("user.assign_group", target["username"] if target else user_id, choice or "keine Gruppe")
flash("Gruppe zugewiesen!", "success")
elif "delete_user" in request.form:
# "Ändern" deckt Löschen mit ab (kein eigenes users.delete-Recht).
if not current_user.has_permission("users.edit"):
flash("Keine Berechtigung, Benutzer zu löschen.", "danger")
return redirect(url_for("users"))
user_id = request.form["delete_user"]
target = conn.execute(
"SELECT username, is_admin, auth_source FROM users WHERE id=? AND deleted_at IS NULL", (user_id,)
).fetchone()
# Admin-Konten dürfen nur von echten Admins gelöscht werden.
if target and target["is_admin"] and not current_user.is_admin:
flash("Nur Admins dürfen Admin-Konten löschen.", "danger")
return redirect(url_for("users"))
if target and target["auth_source"] == "ldap":
# AD/LDAP-Konten durchlaufen den Papierkorb nicht -- sie sind
# nur ein Schatten-Eintrag, der sich bei jedem gültigen
# AD-Login ohnehin automatisch neu anlegt. Ein "wiederherzu-
# stellender" Papierkorb-Eintrag wäre hier ohne Nutzen.
conn.execute("DELETE FROM user_groups WHERE user_id=?", (user_id,))
conn.execute("DELETE FROM users WHERE id=?", (user_id,))
conn.commit()
log_action("user.delete", target["username"], "AD/LDAP-Konto, kein Papierkorb")
flash("AD/LDAP-Konto gelöscht (legt sich bei erneutem gültigen Login automatisch neu an).", "success")
else:
# Papierkorb statt echtem Löschen -- Mitgliedschaften bleiben
# dabei bewusst erhalten (siehe Wartung-Seite: Wiederherstellen
# soll denselben Stand wiederherstellen wie vor dem Löschen).
conn.execute(
"UPDATE users SET deleted_at=? WHERE id=? AND deleted_at IS NULL",
(datetime.now().strftime("%Y-%m-%d %H:%M:%S"), user_id),
)
conn.commit()
log_action("user.delete", target["username"] if target else user_id, "in den Papierkorb verschoben")
flash("Benutzer in den Papierkorb verschoben.", "success")
elif "toggle_lock" in request.form:
# Einzige Möglichkeit, einem AD/LDAP-Konto den Zugriff zu
# entziehen, ohne es in AD selbst zu sperren -- funktioniert
# aber genauso für lokale Konten.
if not current_user.has_permission("users.edit"):
flash("Keine Berechtigung, Benutzer zu sperren.", "danger")
return redirect(url_for("users"))
user_id = request.form["toggle_lock"]
target = conn.execute(
"SELECT username, is_admin, is_locked FROM users WHERE id=? AND deleted_at IS NULL", (user_id,)
).fetchone()
if not target:
flash("Benutzer nicht gefunden.", "danger")
return redirect(url_for("users"))
if target["is_admin"] and not current_user.is_admin:
flash("Nur Admins dürfen Admin-Konten sperren.", "danger")
return redirect(url_for("users"))
if str(user_id) == str(current_user.id):
flash("Das eigene Konto kann nicht gesperrt werden.", "danger")
return redirect(url_for("users"))
new_state = 0 if target["is_locked"] else 1
if new_state and target["is_admin"]:
unlocked_admins = conn.execute(
"SELECT COUNT(*) AS n FROM users WHERE is_admin=1 AND is_locked=0 AND deleted_at IS NULL"
).fetchone()["n"]
if unlocked_admins <= 1:
flash("Es muss mindestens ein entsperrtes Admin-Konto bestehen bleiben.", "danger")
conn.close()
return redirect(url_for("users"))
conn.execute("UPDATE users SET is_locked=? WHERE id=? AND deleted_at IS NULL", (new_state, user_id))
conn.commit()
log_action("user.lock" if new_state else "user.unlock", target["username"])
flash(f"Benutzer „{target['username']}{'gesperrt' if new_state else 'entsperrt'}.", "success")
elif "ldap_add_user" in request.form:
# Vorab-Zuweisung: ein AD-Benutzer wird gesucht (siehe
# /users/ldap_search) und hier -- noch VOR seinem ersten Login --
# mit einer Gruppe angelegt. Ruft _ldap_lookup erneut auf statt
# den übermittelten Anzeigenamen zu vertrauen, damit Name/E-Mail
# garantiert frisch aus AD stammen.
if not current_user.has_permission("users.create"):
flash("Keine Berechtigung, Benutzer anzulegen.", "danger")
return redirect(url_for("users"))
ldap_username = request.form.get("ldap_username", "").strip()
group_choice = request.form.get("group_id") or ""
if group_choice == "admin" and not current_user.is_admin:
flash("Nur Admins dürfen andere Benutzer zu Admins machen.", "danger")
return redirect(url_for("users"))
found, info = _ldap_lookup(ldap_username) if ldap_username else (False, None)
if not found:
flash("Benutzer wurde im Verzeichnis nicht (mehr) gefunden.", "danger")
else:
canonical_username = info["username"]
placeholder_hash = bcrypt.generate_password_hash(secrets.token_hex(32)).decode("utf-8")
is_admin_new = 1 if group_choice == "admin" else 0
try:
cur = conn.execute(
"INSERT INTO users (username, password, is_admin, first_name, last_name, email, auth_source) "
"VALUES (?, ?, ?, ?, ?, ?, 'ldap')",
(canonical_username, placeholder_hash, is_admin_new,
info.get("first_name"), info.get("last_name"), info.get("email")),
)
if not is_admin_new and group_choice:
conn.execute(
"INSERT OR IGNORE INTO user_groups (user_id, group_id) VALUES (?, ?)",
(cur.lastrowid, group_choice),
)
conn.commit()
log_action("user.ldap_add", canonical_username, "Aus Active Directory hinzugefügt")
flash(f"„{canonical_username}“ aus Active Directory hinzugefügt.", "success")
except sqlite3.IntegrityError:
flash("Dieser Benutzer existiert bereits (ggf. im Papierkorb unter Wartung).", "danger")
users_list = conn.execute("""
SELECT users.id, users.username, users.is_admin, users.first_name, users.last_name,
users.auth_source, users.email, users.is_locked,
GROUP_CONCAT(groups.name, ', ') AS group_names,
(SELECT group_id FROM user_groups WHERE user_groups.user_id = users.id LIMIT 1) AS group_id
FROM users
LEFT JOIN user_groups ON user_groups.user_id = users.id
LEFT JOIN groups ON groups.id = user_groups.group_id AND groups.deleted_at IS NULL
WHERE users.deleted_at IS NULL
GROUP BY users.id
ORDER BY users.username ASC
""").fetchall()
all_groups = conn.execute(
"SELECT id, name, is_default FROM groups WHERE deleted_at IS NULL ORDER BY is_default DESC, name ASC"
).fetchall()
conn.close()
return render_template(
"users.html", users=users_list, all_groups=all_groups,
ldap_enabled=get_setting("ldap_enabled", "0") == "1",
)
@app.route("/users/ldap_search")
@login_required
def users_ldap_search():
"""JSON-Endpunkt für 'Aus Active Directory hinzufügen' -- Substring-Suche
im Verzeichnis, gefiltert um bereits lokal bekannte Benutzernamen
(case-insensitiv, unabhängig von auth_source)."""
if not current_user.has_permission("users.create"):
return jsonify({"error": "Keine Berechtigung."}), 403
query = request.args.get("q", "").strip()
if len(query) < 2:
return jsonify([])
results = _ldap_search_users(query)
conn = get_db_connection()
# Nur LEBENDE Konten ausblenden -- ein im Papierkorb liegender Name soll
# weiterhin gefunden werden (führt beim Hinzufügen zu einer klaren
# Fehlermeldung statt stillschweigend zu fehlen, siehe ldap_add_user).
existing = {
row["username"].lower()
for row in conn.execute("SELECT username FROM users WHERE deleted_at IS NULL").fetchall()
}
conn.close()
return jsonify([r for r in results if r["username"] and r["username"].lower() not in existing])
# ---------------------------------------------------------------------------
# Gruppen (Rechtesystem) — nur Admins dürfen Gruppen und deren
# Berechtigungen/Mitgliedschaften verwalten.
# ---------------------------------------------------------------------------
@app.route("/groups", methods=["GET", "POST"])
@login_required
def groups():
if request.method == "GET" and not current_user.can_manage_groups:
flash("Keine Berechtigung, die Gruppenverwaltung anzusehen.", "danger")
return redirect(url_for("index"))
conn = get_db_connection()
if request.method == "POST":
if "add_group" in request.form:
if not current_user.has_permission("groups.create"):
flash("Keine Berechtigung, Gruppen anzulegen.", "danger")
return redirect(url_for("groups"))
name = request.form.get("name", "").strip()
if name:
try:
cur = conn.execute("INSERT INTO groups (name) VALUES (?)", (name,))
# Rechte direkt beim Anlegen setzbar (dieselbe Tabelle wie
# beim Bearbeiten, hier ohne eigenes "_submitted"-Flag, da
# eine neue Gruppe ohnehin noch keine Rechte hat).
selected_permissions = set(request.form.getlist("permissions")) & set(ALL_PERMISSION_KEYS)
if selected_permissions:
conn.executemany(
"INSERT INTO group_permissions (group_id, permission) VALUES (?, ?)",
[(cur.lastrowid, p) for p in selected_permissions],
)
conn.commit()
log_action("group.create", name, f"{len(selected_permissions)} Rechte" if selected_permissions else None)
flash(f"Gruppe '{name}' angelegt.", "success")
except sqlite3.IntegrityError:
flash("Eine Gruppe mit diesem Namen existiert bereits (ggf. im Papierkorb unter Wartung)!", "danger")
else:
flash("Gruppenname darf nicht leer sein!", "danger")
elif "save_group" in request.form:
if not current_user.has_permission("groups.edit"):
flash("Keine Berechtigung, Gruppen zu bearbeiten.", "danger")
return redirect(url_for("groups"))
group_id = request.form.get("group_id")
name = request.form.get("name", "").strip()
if not name:
flash("Gruppenname darf nicht leer sein!", "danger")
conn.close()
return redirect(url_for("groups"))
# Systemgruppen (aktuell nur die Standardgruppe 'Benutzer') sind
# standardmäßig in Name und Rechten fix. Ein Admin kann die Rechte
# über den expliziten "Freischalten"-Button in der UI trotzdem
# ändern (unlock_system_group=1) -- der Name bleibt IMMER fix,
# da DEFAULT_GROUP_NAME an anderer Stelle im Code über den Namen
# nach dieser Gruppe sucht (Umbenennen würde diese Lookups
# brechen). Mitgliedschaft (members_submitted) war schon immer
# frei verwaltbar.
target_group = conn.execute(
"SELECT name, is_system FROM groups WHERE id=? AND deleted_at IS NULL", (group_id,)
).fetchone()
is_system = bool(target_group["is_system"]) if target_group else False
system_unlocked = is_system and current_user.is_admin and request.form.get("unlock_system_group") == "1"
if is_system and "permissions_submitted" in request.form and not system_unlocked:
flash("Die Rechte der Systemgruppe können nicht geändert werden — über „Freischalten“ kann ein Admin dies gezielt erlauben.", "danger")
conn.close()
return redirect(url_for("groups"))
if is_system and target_group and name != target_group["name"]:
flash("Systemgruppen können nicht umbenannt werden.", "danger")
conn.close()
return redirect(url_for("groups"))
# Die Gruppenkarte (Name + Rechte) und das Mitglieder-Modal sind
# zwei getrennte Formulare, damit das Speichern des einen nicht
# versehentlich den Stand des anderen zurücksetzt. Die verstecken
# *_submitted-Felder markieren, welcher Teil tatsächlich abgeschickt
# wurde (leere Checkbox-Listen wären sonst nicht von "nichts
# ausgewählt" zu unterscheiden).
try:
conn.execute("UPDATE groups SET name=? WHERE id=? AND deleted_at IS NULL", (name, group_id))
if "permissions_submitted" in request.form:
selected_permissions = set(request.form.getlist("permissions")) & set(ALL_PERMISSION_KEYS)
conn.execute("DELETE FROM group_permissions WHERE group_id=?", (group_id,))
conn.executemany(
"INSERT INTO group_permissions (group_id, permission) VALUES (?, ?)",
[(group_id, p) for p in selected_permissions],
)
if "members_submitted" in request.form:
selected_members = {int(x) for x in request.form.getlist("members") if x.isdigit()}
conn.execute("DELETE FROM user_groups WHERE group_id=?", (group_id,))
conn.executemany(
"INSERT INTO user_groups (user_id, group_id) VALUES (?, ?)",
[(uid, group_id) for uid in selected_members],
)
conn.commit()
detail_bits = []
if "permissions_submitted" in request.form:
detail_bits.append("Rechte geändert")
if "members_submitted" in request.form:
detail_bits.append("Mitglieder geändert")
log_action("group.edit", name, ", ".join(detail_bits) or None)
flash(f"Gruppe '{name}' aktualisiert.", "success")
except sqlite3.IntegrityError:
flash("Eine Gruppe mit diesem Namen existiert bereits (ggf. im Papierkorb unter Wartung)!", "danger")
elif "assign_admins" in request.form:
# Bleibt bewusst admin-exklusiv, unabhängig von groups.edit —
# wer Admin ist/wird, darf nicht über ein delegierbares Recht
# gesteuert werden können.
if not current_user.is_admin:
flash("Nur Admins dürfen Admin-Mitgliedschaften ändern.", "danger")
return redirect(url_for("groups"))
selected_admins = {int(x) for x in request.form.getlist("members") if x.isdigit()}
if not selected_admins:
flash("Es muss mindestens ein Admin bestehen bleiben.", "danger")
else:
conn.execute("UPDATE users SET is_admin=0 WHERE deleted_at IS NULL")
conn.executemany(
"UPDATE users SET is_admin=1 WHERE id=?",
[(uid,) for uid in selected_admins],
)
# Admins brauchen keine Gruppenrechte mehr (sie dürfen ohnehin
# alles) — Mitgliedschaften aufräumen, damit sie nicht doppelt
# in Admin- und z.B. Standardgruppe auftauchen.
conn.executemany(
"DELETE FROM user_groups WHERE user_id=?",
[(uid,) for uid in selected_admins],
)
conn.commit()
log_action("group.assign_admins", "Admin", f"{len(selected_admins)} Mitglieder")
flash("Admin-Zuweisung aktualisiert.", "success")
elif "delete_group" in request.form:
# "Ändern" deckt Löschen mit ab (kein eigenes groups.delete-Recht).
if not current_user.has_permission("groups.edit"):
flash("Keine Berechtigung, Gruppen zu löschen.", "danger")
return redirect(url_for("groups"))
group_id = request.form.get("delete_group")
target = conn.execute(
"SELECT name, is_default, is_system FROM groups WHERE id=? AND deleted_at IS NULL", (group_id,)
).fetchone()
if target and (target["is_default"] or target["is_system"]):
flash(f"Die Systemgruppe '{target['name']}' kann nicht gelöscht werden.", "danger")
else:
# Papierkorb statt echtem Löschen -- Rechte/Mitgliedschaften
# bleiben dabei bewusst erhalten (siehe Wartung-Seite:
# Wiederherstellen soll denselben Stand wie vorher liefern).
conn.execute(
"UPDATE groups SET deleted_at=? WHERE id=? AND deleted_at IS NULL",
(datetime.now().strftime("%Y-%m-%d %H:%M:%S"), group_id),
)
conn.commit()
log_action("group.delete", target["name"] if target else group_id, "in den Papierkorb verschoben")
flash("Gruppe in den Papierkorb verschoben.", "success")
conn.close()
return redirect(url_for("groups"))
group_rows = conn.execute(
"SELECT id, name, is_default, is_system FROM groups WHERE deleted_at IS NULL "
"ORDER BY is_default DESC, name ASC"
).fetchall()
all_users = conn.execute(
"SELECT id, username FROM users WHERE is_admin=0 AND deleted_at IS NULL ORDER BY username ASC"
).fetchall()
groups_data = []
for g in group_rows:
perm_rows = conn.execute(
"SELECT permission FROM group_permissions WHERE group_id=?", (g["id"],)
).fetchall()
member_rows = conn.execute("""
SELECT users.id, users.username FROM users
JOIN user_groups ON user_groups.user_id = users.id
WHERE user_groups.group_id = ? AND users.deleted_at IS NULL
ORDER BY users.username ASC
""", (g["id"],)).fetchall()
groups_data.append({
"id": g["id"],
"name": g["name"],
"is_default": bool(g["is_default"]),
"is_system": bool(g["is_system"]),
"permissions": {p["permission"] for p in perm_rows},
"members": {m["id"] for m in member_rows},
"member_names": [m["username"] for m in member_rows],
})
# Virtuelle "Admin"-Gruppe: rein informativ, damit auf einen Blick
# sichtbar ist, wer alles darf — Mitgliedschaft/Rechte werden weiterhin
# ausschließlich über den is_admin-Schalter auf der Users-Seite gesteuert,
# hier gibt es daher bewusst kein Formular.
admin_rows = conn.execute(
"SELECT username FROM users WHERE is_admin=1 AND deleted_at IS NULL ORDER BY username ASC"
).fetchall()
admin_virtual_group = {
"name": "Admin",
"permissions": set(ALL_PERMISSION_KEYS),
"member_names": [u["username"] for u in admin_rows],
}
all_users_all = conn.execute(
"SELECT id, username, is_admin FROM users WHERE deleted_at IS NULL ORDER BY username ASC"
).fetchall()
conn.close()
return render_template(
"groups.html",
groups=groups_data,
admin_virtual_group=admin_virtual_group,
all_users=all_users,
all_users_all=all_users_all,
permission_catalog=PERMISSIONS,
permission_row_types=PERMISSION_ROW_TYPES,
group_row_types=GROUP_ROW_TYPES,
permission_labels=PERMISSION_LABELS,
)
if __name__ == "__main__":
debug = os.environ.get("FLASK_DEBUG", "0") == "1"
# threaded=True ist für das SSH-Web-Terminal (WebSocket) notwendig: die
# Verbindung hält den Thread offen, ohne Threading würde der einzelne
# Worker der Dev-Umgebung blockieren und WebSocket-Frames verstümmeln.
app.run(host="0.0.0.0", port=int(os.environ.get("PORT", 5000)), debug=debug, threaded=True)