Files
tesm/srv/poe_manager/app.py
T
alientim 1a0b55ed44 DHCP: manuelle Reservierungen, Options-Schutz+Dropdown-UI, Lease-Countdown-Fix
1. Manuelle Reservierungen (dhcp_manual_reservations) fuer Geraete, die
   nicht als Client in dieser App gepflegt werden -- bewusst eine eigene,
   explizite Aktion (MAC/IP/Name), damit kein externes Geraet unueberwacht
   automatisch eine feste IP bekommt. _dhcp_reservation_candidates fuehrt
   automatische (aus devices) und manuelle Reservierungen jetzt zusammen,
   case-insensitiv nach MAC dedupliziert (automatisch gewinnt bei Konflikt).
   Automatische Reservierungen sind in der Tabelle nicht loeschbar
   (Schloss-Symbol statt Button, sie entstehen/verschwinden mit den
   Client-Stammdaten), manuelle schon. Live verifiziert: Hinzufuegen,
   doppelte MAC ablehnen, ungueltige MAC ablehnen, generierte Kea-Config
   mit gemischten Auto-/manuellen Reservierungen besteht kea-dhcp4 -t.

2. Standard-DHCP-Optionen um 10 weitere verifizierte Optionen erweitert
   (u.a. host-name/12 -- Kea-Name mit Bindestrich, NICHT "hostname", live
   gegen echtes Kea geprueft) -- jetzt 57 insgesamt. Standard-Optionen sind
   nicht mehr loeschbar (Backend lehnt es ab, UI zeigt ein Schloss-Symbol
   statt Loeschen-Button) -- vorher liess sich eine vorbefuellte Options-
   Definition versehentlich entfernen.

3. Options-Tabellen (global UND pro Reservierung) zeigen jetzt nur noch
   tatsaechlich genutzte Options -- eigene immer, Standard-Optionen nur mit
   gesetztem Wert. Weitere Standard-Optionen kommen ueber ein Dropdown
   "+ Option hinzufuegen" dazu statt permanent alle 57 als leere Felder zu
   zeigen. Das neu eingeblendete Feld wird automatisch in den sichtbaren
   Bereich gescrollt und fokussiert (bei bis zu 57 moeglichen Optionen kann
   das Feld sonst weit unterhalb des sichtbaren Modal-Ausschnitts liegen
   und es wirkt so, als waere nichts passiert).

4. Lease-Countdown-Fix: die Ablaufzeit wird jetzt explizit als UTC
   ausgewiesen (Kea arbeitet intern in UTC-Epoch) und zusaetzlich als live
   tickender Countdown angezeigt (gleiches Prinzip wie der bestehende
   "Naechste Pruefung"-Timer). Vorher wirkte eine reine Absolutzeit auf der
   nicht selbst aktualisierenden Seite bei den kurzen Standard-Lease-Zeiten
   (600s) schnell "schon abgelaufen", sobald etwas Zeit vergangen war, bis
   man tatsaechlich hinschaut -- live im echten Betrieb genau als dieses
   Missverstaendnis beobachtet und anhand der rohen Kea-CSV verifiziert,
   dass die richtige Spalte (expire) korrekt gelesen wird.

5. UI-Feinschliff: "In Datei schreiben" bei den Reservierungen heisst jetzt
   "Speichern" und ist der primaere (orange) Button, "Reservierung
   hinzufuegen" sekundaer -- das Hinzufuegen legt nur einen Datensatz an,
   erst "Speichern" schreibt die tatsaechlich wirksame Kea-Config.

Alles live auf der Test-VM deployt und verifiziert (Playwright fuer die
Dropdown-Sichtbarkeit/Fokus-Interaktion, curl fuer die Backend-Validierung,
kea-dhcp4 -t fuer die generierte Konfiguration), keine Fehler im
journalctl-Log.
2026-08-11 15:52:52 +02:00

3995 lines
185 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
"""
PoE Manager — 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, send_from_directory
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
import base64, csv, ipaddress, shutil, socket, sqlite3, glob, json, os, re, secrets, subprocess, threading, time, traceback
import paramiko
import yaml
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/poe_manager)
# ---------------------------------------------------------------------------
DB_PATH = os.environ.get("POE_DB_PATH", os.path.join(BASE_DIR, "sqlite.db"))
POE_SCRIPT = os.environ.get("POE_SCRIPT", "/usr/local/bin/custom/poe.sh")
LOG_GLOB = os.environ.get("POE_LOG_GLOB", "/var/log/rpi-*.log")
FERNET_KEY_PATH = os.environ.get("POE_FERNET_KEY", os.path.join(BASE_DIR, "fernet.key"))
SSH_KNOWN_HOSTS_PATH = os.environ.get("POE_KNOWN_HOSTS", os.path.join(BASE_DIR, "known_hosts"))
SECRET_KEY_PATH = os.environ.get("POE_SECRET_KEY_FILE", os.path.join(BASE_DIR, "secret.key"))
AVATAR_DIR = os.path.join(BASE_DIR, "static", "uploads", "avatars")
# 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"}
os.makedirs(AVATAR_DIR, exist_ok=True)
app = Flask(__name__)
def _load_or_create_secret() -> str:
env_secret = os.environ.get("POE_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)"),
]
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",
},
},
},
},
"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"}},
},
},
"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",
# L = Export (nur lesend), Ä = Import (verändert Bestand).
"rows": {"view": "settings_importexport.view", "edit": "settings_importexport.edit"},
},
"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"},
},
},
},
}
# 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)",
"logs_live.view": "Live-Log lesen", "logs_activity.view": "Änderungslog 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": "Daten exportieren", "settings_importexport.edit": "Daten importieren",
"settings_dhcp.view": "DHCP-Status und -Konfiguration lesen",
"settings_dhcp.edit": "DHCP-Konfiguration speichern und Reservierungsdatei schreiben",
}
# 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": "settings_group", "label": "Einstellungen", "icon": "sliders", "children": [
{"key": "users", "label": "Benutzer", "icon": "users", "endpoint": "users"},
{"key": "groups", "label": "Gruppen", "icon": "groups", "endpoint": "groups"},
{"key": "settings_system", "label": "Systemeinstellungen", "icon": "sliders", "endpoint": "settings"},
{"key": "settings_importexport", "label": "Im-/Export", "icon": "transfer", "endpoint": "settings_import_export"},
{"key": "settings_dhcp", "label": "DHCP", "icon": "network", "endpoint": "settings_dhcp"},
]},
{"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"},
]},
]
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 == "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 == "logs_live":
return user.can_view_live_log
if key == "logs_activity":
return user.can_view_activity_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_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):
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
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_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_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):
return self.has_permission("settings_importexport.view")
@property
def can_view_settings_dhcp(self):
return self.has_permission("settings_dhcp.view")
@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"). Alle 57 Namen live
# gegen eine echte Kea-2.4.1-Instanz verifiziert (kea-dhcp4 -t je Option
# einzeln) — u.a. "host-name" (Kea-Name mit Bindestrich, NICHT "hostname").
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"),
(12, "host-name", "string", "Hostname (für Reservierung erzwingbar)"),
(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")
# Ä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
)
""")
# "Wer hat zuletzt geändert" direkt am Objekt, zusätzlich zum Audit-Log.
device_cols = {row["name"] for row in conn.execute("PRAGMA table_info(devices)").fetchall()}
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
)
""")
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")
# 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 (u.a.
# host-name/12) — 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,))
# 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,)
)
# 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
""").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()
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 = ?
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,
)
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"
conn = get_db_connection()
conn.execute(
"INSERT INTO audit_log (ts, username, action, target, details) VALUES (?, ?, ?, ?, ?)",
(datetime.now().strftime("%Y-%m-%d %H:%M:%S"), who, action, target, details),
)
conn.commit()
conn.close()
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)."""
conn = get_db_connection()
conn.execute(
"INSERT INTO audit_log (ts, username, action, target, details) VALUES (?, ?, ?, ?, ?)",
(datetime.now().strftime("%Y-%m-%d %H:%M:%S"), "system", action, target, details),
)
conn.commit()
conn.close()
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()
@login_manager.user_loader
def load_user(user_id):
conn = get_db_connection()
user = conn.execute("SELECT * FROM users WHERE id = ?", (user_id,)).fetchone()
conn.close()
if user:
return _build_user(user)
return None
# ---------------------------------------------------------------------------
# Auth
# ---------------------------------------------------------------------------
@app.route("/login", methods=["GET", "POST"])
def login():
if request.method == "POST":
username = request.form["username"]
password = request.form["password"]
conn = get_db_connection()
user = conn.execute("SELECT * FROM users WHERE username = ?", (username,)).fetchone()
conn.close()
if user and bcrypt.check_password_hash(user["password"], password):
login_user(_build_user(user))
return redirect(url_for("index"))
flash("Ungültiger Benutzername oder Passwort", "danger")
return render_template("login.html")
@app.route("/logout")
@login_required
def logout():
logout_user()
return redirect(url_for("login"))
# ---------------------------------------------------------------------------
# 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. Admins konfigurieren hier
# zusätzlich die Navbar-Reihenfolge (siehe save_nav_order()).
# ---------------------------------------------------------------------------
@app.route("/account")
@login_required
def account():
# Volle, ungefilterte Navbar-Struktur für den Reihenfolge-Editor (nur
# für Admins sichtbar/relevant) — anders als das automatisch injizierte
# nav_items_ordered (das nach Berechtigung des jeweils eingeloggten
# Benutzers filtert), damit auch Unterpunkte editierbar sind, die der
# bearbeitende Admin selbst nicht als Nicht-Admin sehen würde.
full_nav_items = _ordered_nav_items() if current_user.is_admin else []
return render_template("account.html", full_nav_items=full_nav_items)
@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:
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():
"""Neuestes Logfile ermitteln. Bewusst über den Dateinamen sortiert
(rpi-YYYYMMDDHHMMSS.log ist lexikografisch = chronologisch sortierbar),
NICHT über Dateisystem-Metadaten wie ctime/mtime — die sind je nach
Dateisystem (z.B. unter WSL2 auf einem gemounteten Windows-Laufwerk)
unzuverlässig und haben in der Praxis nicht immer das tatsächlich
zuletzt geschriebene Logfile geliefert."""
log_files = glob.glob(LOG_GLOB)
if not log_files:
return None
return max(log_files)
def get_last_seen(dev_name: str):
"""Letzter Zeitpunkt, zu dem ein Gerät laut Logs erreichbar war."""
log_files = glob.glob(LOG_GLOB)
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, rpi_ip, switch_hostname, port, is_active FROM devices 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 rpi-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", "rpi-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-poe-manager.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"
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_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_resolv_conf_dns(), "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}
doc = {"network": {"version": 2, "ethernets": {interface: eth_cfg}}}
# 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()
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 "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")
return redirect(url_for("settings"))
net_backend = _detect_network_backend()
net_interface = get_setting("net_interface") or (_list_network_interfaces() or [None])[0]
net_state = _read_network_state(net_interface, net_backend) if net_interface else None
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=_list_network_interfaces(),
net_interface=net_interface,
net_state=net_state,
net_revert_seconds=NETWORK_REVERT_SECONDS,
pending_network_token=pending_token,
)
@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")
# ---------------------------------------------------------------------------
# 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 rpi_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, rpi_ip AS ip, name FROM devices WHERE is_active=1 AND mac IS NOT NULL AND rpi_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())
leases = []
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
leases.append({
"ip": row.get("address") or "",
"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 _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,
}
}
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)
@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")
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")
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")
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")
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",
)
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")
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")
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")
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")
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 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")
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")
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",
)
msg = (
f"{len(valid_subnets)} Subnetz(e) mit insgesamt {total_reservations} Reservierungen nach "
f"{output_path} geschrieben. Der Dienst übernimmt die Änderung erst nach einem Neustart "
f"(Button unten) — das führt die App bewusst nicht automatisch mit aus."
)
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")
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 (mac IS NULL OR rpi_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/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. Der komplette Datensatz
# (Devices, Switche, Zugangsdaten inkl. Passwörter) wird als ein einziges,
# 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.
# ---------------------------------------------------------------------------
@app.route("/settings/export", methods=["POST"])
@login_required
def export_data():
if not current_user.has_permission("settings_importexport.view"):
flash("Keine Berechtigung, Daten zu exportieren.", "danger")
return redirect(url_for("index"))
passphrase = request.form.get("export_passphrase", "")
if not passphrase:
flash("Bitte eine Passphrase für den Export angeben.", "danger")
return redirect(url_for("settings_import_export"))
conn = get_db_connection()
credentials_rows = conn.execute("SELECT name, username, password FROM credentials").fetchall()
switches_rows = conn.execute("""
SELECT switches.hostname, switches.ip, credentials.name AS credential_name
FROM switches LEFT JOIN credentials ON credentials.id = switches.credential_id
""").fetchall()
devices_rows = conn.execute(
"SELECT mac, rpi_ip, port, name, switch_hostname, is_active FROM devices"
).fetchall()
conn.close()
payload = {
"credentials": [
{"name": r["name"], "username": r["username"], "password": decrypt_password(r["password"])}
for r in credentials_rows
],
"switches": [
{"hostname": r["hostname"], "ip": r["ip"], "credential_name": r["credential_name"]}
for r in switches_rows
],
"devices": [dict(r) for r in devices_rows],
}
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": "poe-manager",
"version": 1,
"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"),
}
log_action(
"data.export", "Devices/Switches/Zugangsdaten",
f"{len(payload['devices'])} Geräte, {len(payload['switches'])} Switche, {len(payload['credentials'])} Zugangsdaten",
)
filename = f"poe_manager_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"))
conn = get_db_connection()
n_cred = n_switch = n_dev = 0
for c in payload.get("credentials", []):
conn.execute(
"""INSERT INTO credentials (name, username, password) VALUES (?, ?, ?)
ON CONFLICT(name) DO UPDATE SET username=excluded.username, password=excluded.password""",
(c["name"], c["username"], encrypt_password(c["password"])),
)
n_cred += 1
for s in payload.get("switches", []):
cred_id = None
if s.get("credential_name"):
row = conn.execute("SELECT id FROM credentials WHERE name=?", (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""",
(s["hostname"], s["ip"], cred_id),
)
n_switch += 1
for d in payload.get("devices", []):
conn.execute(
"""INSERT INTO devices (mac, rpi_ip, port, name, switch_hostname, is_active) VALUES (?, ?, ?, ?, ?, ?)
ON CONFLICT(mac) DO UPDATE SET rpi_ip=excluded.rpi_ip, port=excluded.port, name=excluded.name,
switch_hostname=excluded.switch_hostname, is_active=excluded.is_active""",
(d["mac"], d["rpi_ip"], d.get("port"), d["name"], d.get("switch_hostname"), d.get("is_active", 1)),
)
n_dev += 1
conn.commit()
conn.close()
log_action("data.import", "Devices/Switches/Zugangsdaten", f"{n_dev} Geräte, {n_switch} Switche, {n_cred} Zugangsdaten")
flash(f"Import abgeschlossen: {n_cred} Zugangsdaten, {n_switch} Switche, {n_dev} Geräte.", "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 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")
rpi_ip = request.form.get("rpi_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, rpi_ip, name]):
flash("Alle Felder müssen ausgefüllt sein!", "danger")
return redirect(url_for("devices"))
if conn.execute("SELECT name FROM devices WHERE rpi_ip=?", (rpi_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=?", (mac,)).fetchone():
flash("MAC-Adresse existiert bereits für ein anderes Gerät!", "danger")
return redirect(url_for("devices"))
try:
conn.execute(
"INSERT INTO devices (mac, rpi_ip, port, name, switch_hostname, is_active) "
"VALUES (?, ?, ?, ?, ?, ?)",
(mac, rpi_ip, port, name, switch_hostname, is_active),
)
touch_record(conn, "devices", "mac", mac)
conn.commit()
log_action("device.create", name, f"MAC {mac}, IP {rpi_ip}")
flash(f"Gerät {name} hinzugefügt.", "success")
except sqlite3.IntegrityError:
flash("Fehler beim Hinzufügen des Geräts!", "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")
rpi_ip = request.form.get("rpi_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, rpi_ip, name]):
device = conn.execute(
"SELECT name, switch_hostname FROM devices WHERE mac=?", (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, rpi_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 rpi_ip=? AND mac<>?", (rpi_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<>?", (mac, old_mac)
).fetchone():
flash("MAC-Adresse existiert bereits für ein anderes Gerät!", "danger")
return redirect(url_for("devices"))
try:
conn.execute(
"UPDATE devices SET mac=?, rpi_ip=?, port=?, name=? WHERE mac=?",
(mac, rpi_ip, port, name, old_mac),
)
touch_record(conn, "devices", "mac", mac)
conn.commit()
log_action("device.edit", name, f"MAC {old_mac}{mac}, IP {rpi_ip}")
flash(f"Gerät {name} aktualisiert.", "success")
except sqlite3.IntegrityError:
flash("Fehler beim Aktualisieren des Geräts!", "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=?", (del_mac,)).fetchone()
name = device["name"] if device else del_mac
conn.execute("DELETE FROM devices WHERE mac=?", (del_mac,))
conn.commit()
log_action("device.delete", name, f"MAC {del_mac}")
flash(f"Gerät {name} gelöscht.", "success")
else:
flash("Keine MAC-Adresse übermittelt!", "danger")
conn.close()
return redirect(url_for("devices"))
device_rows = conn.execute("""
SELECT devices.mac, devices.rpi_ip, devices.port, devices.name, devices.is_active,
switches.hostname AS switch_hostname
FROM devices
LEFT JOIN switches ON devices.switch_hostname = switches.hostname
ORDER BY switches.hostname ASC, devices.name ASC
""").fetchall()
conn.close()
return render_template("devices.html", devices=device_rows, switches=switches)
@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=?", (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=?", (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):
"""
Liest die Zugangsdaten-Auswahl aus einem Switch-Formular: entweder eine
bestehende credential_id, oder (choice == "new") legt direkt aus dem
Switch-Formular heraus neue Zugangsdaten an — damit man beim Anlegen
eines Switches nicht zuerst zu "Zugangsdaten" wechseln muss.
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) VALUES (?, ?, ?)",
(name, username, encrypt_password(password)),
)
return cur.lastrowid, None
except sqlite3.IntegrityError:
return None, "Es existieren bereits Zugangsdaten mit diesem Namen!"
elif choice.isdigit():
return int(choice), 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 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=?",
(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 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
ORDER BY switches.hostname ASC
""").fetchall()
all_credentials = conn.execute("SELECT id, name, username FROM credentials 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=?", (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"))
conn.execute("DELETE FROM switches WHERE hostname=?", (hostname,))
conn.commit()
conn.close()
log_action("switch.delete", hostname)
flash(f"Switch '{hostname}' gelöscht.", "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", "")
if name and username and password:
try:
conn.execute(
"INSERT INTO credentials (name, username, password) VALUES (?, ?, ?)",
(name, username, encrypt_password(password)),
)
conn.commit()
log_action("credential.create", name)
flash(f"Zugangsdaten '{name}' angelegt.", "success")
except sqlite3.IntegrityError:
flash("Es existieren bereits Zugangsdaten mit diesem Namen!", "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", "")
if name and username:
try:
if new_password:
conn.execute(
"UPDATE credentials SET name=?, username=?, password=? WHERE id=?",
(name, username, encrypt_password(new_password), cred_id),
)
else:
conn.execute(
"UPDATE credentials SET name=?, username=? WHERE id=?",
(name, username, 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!", "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 = conn.execute("SELECT hostname FROM switches WHERE credential_id=?", (cred_id,)).fetchall()
if used_by:
names = ", ".join(s["hostname"] for s in used_by)
flash(f"Diese Zugangsdaten werden noch von folgenden Switchen verwendet: {names}", "danger")
else:
cred_row = conn.execute("SELECT name FROM credentials WHERE id=?", (cred_id,)).fetchone()
conn.execute("DELETE FROM credentials WHERE id=?", (cred_id,))
conn.commit()
log_action("credential.delete", cred_row["name"] if cred_row else cred_id)
flash("Zugangsdaten gelöscht.", "success")
credential_rows = conn.execute("""
SELECT credentials.id, credentials.name, credentials.username,
COUNT(switches.hostname) AS switch_count
FROM credentials
LEFT JOIN switches ON switches.credential_id = credentials.id
GROUP BY credentials.id
ORDER BY credentials.name ASC
""").fetchall()
conn.close()
return render_template("credentials.html", credentials=credential_rows)
# ---------------------------------------------------------------------------
# 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
# ---------------------------------------------------------------------------
# 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("/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 FROM devices WHERE mac=?", (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
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
# 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) "
"VALUES (?, ?, ?, ?, ?)",
(username, pw_hash, is_admin, first_name, last_name),
)
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!", "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_admin = conn.execute("SELECT is_admin FROM users WHERE id=?", (user_id,)).fetchone()
if target_admin and target_admin["is_admin"] and not current_user.is_admin:
flash("Nur Admins dürfen Admin-Konten bearbeiten.", "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
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=?, password=? WHERE id=?",
(username, first_name, last_name, pw_hash, user_id),
)
else:
conn.execute(
"UPDATE users SET username=?, first_name=?, last_name=? WHERE id=?",
(username, first_name, last_name, 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=?", (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").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=?", (user_id,))
else:
conn.execute("UPDATE users SET is_admin=0 WHERE id=?", (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 FROM users WHERE id=?", (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"))
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"] if target else user_id)
flash("Benutzer gelöscht!", "success")
users_list = conn.execute("""
SELECT users.id, users.username, users.is_admin, users.first_name, users.last_name,
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
GROUP BY users.id
ORDER BY users.username ASC
""").fetchall()
all_groups = conn.execute("SELECT id, name, is_default FROM groups ORDER BY is_default DESC, name ASC").fetchall()
conn.close()
return render_template("users.html", users=users_list, all_groups=all_groups)
# ---------------------------------------------------------------------------
# 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!", "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
# in Name und Rechten fix — nicht mal für Admins änderbar über
# die UI. Mitgliedschaft (members_submitted) bleibt frei
# verwaltbar, nur permissions_submitted bzw. eine Umbenennung
# werden blockiert.
target_group = conn.execute("SELECT name, is_system FROM groups WHERE id=?", (group_id,)).fetchone()
is_system = bool(target_group["is_system"]) if target_group else False
if is_system and "permissions_submitted" in request.form:
flash("Die Rechte der Systemgruppe können nicht geändert werden.", "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=?", (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!", "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")
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=?", (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:
conn.execute("DELETE FROM group_permissions WHERE group_id=?", (group_id,))
conn.execute("DELETE FROM user_groups WHERE group_id=?", (group_id,))
conn.execute("DELETE FROM groups WHERE id=?", (group_id,))
conn.commit()
log_action("group.delete", target["name"] if target else group_id)
flash("Gruppe gelöscht.", "success")
conn.close()
return redirect(url_for("groups"))
group_rows = conn.execute(
"SELECT id, name, is_default, is_system FROM groups ORDER BY is_default DESC, name ASC"
).fetchall()
all_users = conn.execute("SELECT id, username FROM users WHERE is_admin=0 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 = ?
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 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 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)