Bisher enthielt "Systemeinstellungen" nur das Pruefintervall. Jetzt zusaetzlich Verwaltung der Netzwerkkonfiguration DIESES Hosts (getrennt vom Kea-DHCP-*Server* fuer Clients): - Backend-Erkennung (NetworkManager vs. dhcpcd via systemctl is-active). Kein erkanntes Backend -> Seite bleibt rein lesend statt zu raten. - Status-Anzeige: Interface, IP/Prefix, Gateway, DNS, Modus (DHCP/Statisch). - Aenderung: Interface, Modus, bei Statisch IP/Prefix/Gateway, DNS unabhaengig von Modus setzbar (IP per DHCP + feste DNS moeglich). - Sicherheitsnetz analog "netplan try": Backup vor jeder Aenderung, automatisches Rollback nach 45s ohne explizite Bestaetigung (nur moeglich, wenn die Seite ueber die neue Config noch erreichbar ist) -- verhindert Aussperren durch einen Tippfehler bei IP/Gateway. - Bugfix waehrend der Implementierung gefunden: der Auto-Revert-Timer laeuft in einem Hintergrund-Thread ohne Request-Kontext: log_action() griff auf current_user zu und warf dort einen AttributeError (die Config wurde trotzdem korrekt zurueckgerollt, nur der Audit-Log-Eintrag fehlte und ein Fehler landete im Server-Log). Fix: log_action_system() ohne current_user-Abhaengigkeit fuer Code ausserhalb des Request- Kontexts. - Getestet: Backend-Erkennung + rein lesender Fallback live (WSL hat weder NetworkManager noch dhcpcd aktiv, korrekt erkannt), Rechte-Gating, Anwenden-/Rollback-Logik fuer beide Backends per gemocktem subprocess.run verifiziert (inkl. echtem dhcpcd.conf-Rewrite und vollständigem Anwenden-dann-Auto-Rollback-Durchlauf mit Audit-Log- Eintrag). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
3244 lines
144 KiB
Python
3244 lines
144 KiB
Python
#!/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 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, ipaddress, shutil, sqlite3, glob, json, os, re, secrets, subprocess, threading, time, traceback
|
|
import paramiko
|
|
|
|
|
|
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
|
|
|
|
|
|
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
|
|
)
|
|
""")
|
|
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)
|
|
)
|
|
""")
|
|
|
|
# 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 (Einführung des granularen, an die Navbar gespiegelten
|
|
# Rechtebaums): alte Permission-Keys auf die neuen übertragen, damit
|
|
# bestehende Gruppen durch das Update keinen Zugriff verlieren.
|
|
# - devices.toggle ist jetzt Teil von devices.edit (Ändern).
|
|
# - Zugangsdaten hatten bisher keine eigenen Rechte, sondern hingen an
|
|
# switches.* — bestehende Gruppen bekommen die entsprechenden
|
|
# credentials.*-Rechte gespiegelt, künftig sind beide unabhängig.
|
|
# - Jede Gruppe, die bereits ein Kind-Recht in einem Bereich hatte,
|
|
# bekommt automatisch auch das neue übergeordnete "Bereich
|
|
# sichtbar"-Recht (devices_group.view/logs_group.view/
|
|
# settings_group.view), da Kind-Rechte jetzt ohne dieses Recht wirkungslos
|
|
# wären (siehe User.has_permission).
|
|
# WICHTIG: Läuft nur EINMALIG (Guard über settings), nicht bei jedem
|
|
# Start — sonst würde das übergeordnete "Bereich sichtbar"-Recht bei
|
|
# jedem Neustart automatisch wieder gesetzt, sobald irgendein Kind-Recht
|
|
# noch aktiv ist. Das würde den eigentlichen Zweck des Kill-Switches
|
|
# zunichtemachen (ein Admin könnte den ganzen Bereich nie dauerhaft
|
|
# sperren, ohne zuvor jedes einzelne Kind-Recht zu entfernen).
|
|
_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,
|
|
}
|
|
|
|
|
|
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, der Countdown
|
|
'Nächste Prüfung' setzt sich dadurch automatisch auf den manuell
|
|
ausgelösten Zeitpunkt zurück."""
|
|
if not current_user.is_admin:
|
|
return {"success": False, "msg": "Keine Berechtigung."}, 403
|
|
_restart_check_service()
|
|
log_action("check.run_now", "Prüfintervall")
|
|
return {"success": True, "msg": "Prüfung gestartet."}
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Settings
|
|
# ---------------------------------------------------------------------------
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# System-Netzwerkeinstellungen (IP/DNS/DHCP DIESES Hosts — nicht zu
|
|
# verwechseln mit dem Kea-DHCP-*Server* für Client-Geräte weiter unten).
|
|
#
|
|
# Höchste Vorsicht geboten: eine falsche Änderung hier kann die Erreichbarkeit
|
|
# dieses Hosts (SSH/Web-UI) komplett kappen. Deshalb:
|
|
# - Erkennung des tatsächlich aktiven Netzwerk-Backends (NetworkManager
|
|
# oder dhcpcd) statt blind eine Methode anzunehmen; ist keins von beiden
|
|
# aktiv (z.B. in dieser WSL-Testumgebung), bleibt die Seite bewusst rein
|
|
# lesend statt zu raten.
|
|
# - Vor jeder Änderung wird die vorherige Konfiguration gesichert.
|
|
# - Nach dem Anwenden läuft ein Sicherheits-Timer im Hintergrund: wird die
|
|
# neue Konfiguration nicht innerhalb von NETWORK_REVERT_SECONDS explizit
|
|
# bestätigt (das ist nur möglich, wenn die Web-UI über die NEUE
|
|
# Konfiguration noch erreichbar ist), wird automatisch die gesicherte
|
|
# Konfiguration wiederhergestellt — analog zu "netplan try".
|
|
# ---------------------------------------------------------------------------
|
|
|
|
NETWORK_REVERT_SECONDS = 45
|
|
_pending_network_revert = {} # {"timer": Timer, "backend":..., "interface":..., "backup": {...}}
|
|
|
|
|
|
def _detect_network_backend():
|
|
"""Rein lesend: welcher der beiden gängigen Netzwerk-Manager auf
|
|
Debian/Raspberry Pi OS ist aktiv? Keine Annahme, falls keins von
|
|
beiden 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
|
|
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"
|
|
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
|
|
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)
|
|
|
|
return False, "Kein unterstütztes Netzwerk-Backend erkannt (weder NetworkManager noch dhcpcd 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", []),
|
|
)
|
|
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}")
|
|
flash(
|
|
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.",
|
|
"success",
|
|
)
|
|
else:
|
|
flash(f"Anwenden fehlgeschlagen: {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)
|
|
return render_template(
|
|
"settings.html",
|
|
interval=interval,
|
|
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 (kea-dhcp4-server), nicht das ältere isc-dhcp-server:
|
|
# Kea wird von ISC selbst aktiv weiterentwickelt (isc-dhcp-server ist seit
|
|
# 2022 EOL) und bildet "globaler Wert, pro Client überschreibbar" nativ ab
|
|
# (Reservation-Options überschreiben in Kea automatisch gleichnamige
|
|
# globale/Subnet-Options) — genau das von den Custom-Options gefordert.
|
|
#
|
|
# Installation & Dienststeuerung sind eigene, einzeln bestätigte Aktionen
|
|
# (apt-get install / systemctl enable+restart) statt automatisch an
|
|
# "Konfiguration speichern" gekoppelt — ein unerwartet aktiver oder
|
|
# fehlkonfigurierter DHCP-Server kann ein ganzes Netzsegment stören, das
|
|
# soll immer ein bewusster, separater Klick bleiben.
|
|
# ---------------------------------------------------------------------------
|
|
|
|
DHCP_PACKAGE = "kea-dhcp4-server"
|
|
DHCP_SERVICE = "kea-dhcp4-server"
|
|
|
|
DHCP_DEFAULTS = {
|
|
"dhcp_interface": "eth0",
|
|
"dhcp_range_start": "192.168.1.100",
|
|
"dhcp_range_end": "192.168.1.200",
|
|
"dhcp_dns": "192.168.1.1",
|
|
"dhcp_domain": "local",
|
|
"dhcp_lease_default": "600",
|
|
"dhcp_lease_max": "7200",
|
|
"dhcp_output_path": "/etc/kea/kea-dhcp4.conf",
|
|
}
|
|
|
|
# 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 _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_network(interface):
|
|
"""Liest Subnet, Netzmaske und Gateway direkt aus der laufenden
|
|
System-Netzwerkkonfiguration des gewählten Interfaces 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."""
|
|
result = {"ok": False, "ip": None, "prefix": None, "netmask": None, "network": None, "gateway": None, "error": None}
|
|
if not interface:
|
|
result["error"] = "Kein Interface ausgewählt."
|
|
return result
|
|
try:
|
|
addr_out = subprocess.run(
|
|
["ip", "-4", "-o", "addr", "show", "dev", interface],
|
|
capture_output=True, text=True, timeout=5,
|
|
)
|
|
match = re.search(r"inet (\d+\.\d+\.\d+\.\d+)/(\d+)", addr_out.stdout)
|
|
if not match:
|
|
result["error"] = f"Keine IPv4-Adresse auf „{interface}“ gefunden."
|
|
return result
|
|
ip_str, prefix = match.group(1), int(match.group(2))
|
|
network = ipaddress.IPv4Network(f"{ip_str}/{prefix}", strict=False)
|
|
result.update(ok=True, ip=ip_str, prefix=prefix, network=network, netmask=str(network.netmask))
|
|
|
|
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)
|
|
result["gateway"] = gw_match.group(1) if gw_match else None
|
|
except Exception as e:
|
|
result["error"] = str(e)
|
|
return result
|
|
|
|
|
|
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_reservation_candidates(conn, network):
|
|
"""Aktive Geräte mit gültiger MAC + IP, aus denen sich eine feste
|
|
DHCP-Reservierung erzeugen lässt. Geräte ohne rpi_ip/mac werden
|
|
übersprungen statt eine kaputte Reservierung zu erzeugen. Ebenso
|
|
übersprungen: Geräte, deren IP außerhalb des (aus der System-
|
|
Netzwerkkonfiguration erkannten) Subnets liegt — Kea lehnt eine
|
|
Reservierung außerhalb ihres subnet4-Blocks als Konfigurationsfehler ab
|
|
(mit einem Live-Test gegen echtes Kea verifiziert). `network` ist ein
|
|
ipaddress.IPv4Network oder None, falls die Erkennung fehlgeschlagen ist
|
|
(dann werden alle Geräte als "außerhalb" übersprungen, statt eine mit
|
|
Sicherheit ungültige Konfiguration zu erzeugen)."""
|
|
rows = conn.execute(
|
|
"SELECT mac, rpi_ip, name FROM devices WHERE is_active=1 AND mac IS NOT NULL AND rpi_ip IS NOT NULL ORDER BY name ASC"
|
|
).fetchall()
|
|
seen_hostnames = set()
|
|
reservations = []
|
|
out_of_subnet = 0
|
|
for row in rows:
|
|
try:
|
|
in_subnet = network is not None and ipaddress.IPv4Address(row["rpi_ip"]) in network
|
|
except ValueError:
|
|
in_subnet = False
|
|
if not in_subnet:
|
|
out_of_subnet += 1
|
|
continue
|
|
hostname = _dhcp_safe_hostname(row["name"], row["mac"])
|
|
# Kollisionen bei generierten Namen (z.B. zwei Geräte mit gleichem
|
|
# Namen) auflösen, statt zwei Reservierungen mit demselben
|
|
# Bezeichner zu erzeugen (Kea würde das als Fehler ablehnen).
|
|
base_hostname, suffix = hostname, 2
|
|
while hostname in seen_hostnames:
|
|
hostname = f"{base_hostname}-{suffix}"
|
|
suffix += 1
|
|
seen_hostnames.add(hostname)
|
|
reservations.append({"mac": row["mac"], "ip": row["rpi_ip"], "hostname": hostname, "name": row["name"]})
|
|
return reservations, out_of_subnet
|
|
|
|
|
|
def _dhcp_option_defs(conn):
|
|
return conn.execute("SELECT id, code, name, type, description 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 _render_kea_config(cfg, net_info, reservations, option_defs, option_values):
|
|
"""Baut die komplette Kea-DHCPv4-Konfiguration (als dict, JSON-serialisierbar).
|
|
Subnet/Netzmaske kommen bewusst NICHT aus manueller Eingabe, sondern aus
|
|
net_info (siehe _detect_interface_network) — die Konfiguration koppelt
|
|
sich damit immer an das Netz, in dem der gewählte Interface tatsächlich
|
|
hängt. Globale Options landen im Top-Level option-data (gilt für alle
|
|
Subnetze), Client-Overrides 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."""
|
|
dns_servers = ", ".join(s.strip() for s in cfg["dhcp_dns"].split(",") if s.strip())
|
|
router = net_info.get("gateway") or (cfg["dhcp_dns"].split(",")[0].strip() if cfg["dhcp_dns"] else "")
|
|
|
|
option_def_list = [
|
|
{"name": d["name"], "code": d["code"], "type": d["type"], "space": "dhcp4"}
|
|
for d in option_defs
|
|
]
|
|
|
|
global_option_data = []
|
|
if router:
|
|
global_option_data.append({"name": "routers", "data": router})
|
|
if dns_servers:
|
|
global_option_data.append({"name": "domain-name-servers", "data": dns_servers})
|
|
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})
|
|
|
|
kea_reservations = []
|
|
for r in reservations:
|
|
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)
|
|
|
|
return {
|
|
"Dhcp4": {
|
|
"interfaces-config": {"interfaces": [cfg["dhcp_interface"]]},
|
|
"lease-database": {"type": "memfile", "lfc-interval": 3600},
|
|
"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": [
|
|
{
|
|
"id": 1,
|
|
"subnet": f"{net_info['network'].network_address}/{net_info['network'].prefixlen}",
|
|
"pools": [{"pool": f"{cfg['dhcp_range_start']} - {cfg['dhcp_range_end']}"}],
|
|
"reservations": kea_reservations,
|
|
}
|
|
] if net_info.get("ok") else [],
|
|
}
|
|
}
|
|
|
|
|
|
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:
|
|
submitted_interface = request.form.get("dhcp_interface", "").strip()
|
|
if submitted_interface and submitted_interface not in _list_network_interfaces():
|
|
flash(f"Interface „{submitted_interface}“ existiert nicht auf diesem Host.", "danger")
|
|
else:
|
|
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_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 FROM dhcp_option_defs WHERE id=?", (option_def_id,)).fetchone()
|
|
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 "write_dhcp_file" in request.form:
|
|
cfg = _load_dhcp_settings()
|
|
net_info = _detect_interface_network(cfg["dhcp_interface"])
|
|
if not net_info["ok"]:
|
|
flash(f"Netzwerk-Erkennung für „{cfg['dhcp_interface']}“ fehlgeschlagen: {net_info['error']} — nichts geschrieben.", "danger")
|
|
conn.close()
|
|
return redirect(url_for("settings_dhcp"))
|
|
reservations, _ = _dhcp_reservation_candidates(conn, net_info["network"])
|
|
option_defs = _dhcp_option_defs(conn)
|
|
option_values = _dhcp_option_values(conn)
|
|
content = json.dumps(_render_kea_config(cfg, net_info, reservations, option_defs, option_values), indent=2, ensure_ascii=False)
|
|
output_path = cfg["dhcp_output_path"]
|
|
try:
|
|
os.makedirs(os.path.dirname(output_path), exist_ok=True)
|
|
with open(output_path, "w", encoding="utf-8") as f:
|
|
f.write(content + "\n")
|
|
log_action("settings.dhcp_write", output_path, f"{len(reservations)} Reservierungen, Netz {net_info['network']}")
|
|
flash(
|
|
f"{len(reservations)} Reservierungen für Netz {net_info['network']} (Interface {cfg['dhcp_interface']}) "
|
|
f"nach {output_path} geschrieben. Der Dienst übernimmt die Änderung erst nach einem Neustart (Button unten) — "
|
|
f"das führt die App bewusst nicht automatisch mit aus.",
|
|
"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")
|
|
if 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)
|
|
log_action("settings.dhcp_service", DHCP_SERVICE, "stop " + ("ok" if ok_stop else "fehlgeschlagen"))
|
|
flash(f"{DHCP_SERVICE} gestoppt." 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()
|
|
net_info = _detect_interface_network(cfg["dhcp_interface"])
|
|
reservations, out_of_subnet_count = _dhcp_reservation_candidates(conn, net_info.get("network"))
|
|
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, net_info, reservations, option_defs, option_values), indent=2, ensure_ascii=False)
|
|
return render_template(
|
|
"settings_dhcp.html",
|
|
cfg=cfg,
|
|
status=status,
|
|
net_info=net_info,
|
|
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,
|
|
)
|
|
|
|
|
|
@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.
|
|
"""
|
|
ws.send(message)
|
|
time.sleep(0.15)
|
|
ws.close()
|
|
|
|
|
|
class _AbortHostKey(Exception):
|
|
"""Wird geworfen, wenn der Nutzer einen unbekannten Host-Key ablehnt."""
|
|
|
|
|
|
class _InteractiveHostKeyPolicy(paramiko.MissingHostKeyPolicy):
|
|
"""
|
|
Emuliert das bekannte SSH-CLI-Verhalten bei unbekannten Host-Keys
|
|
("Are you sure you want to continue connecting?"), aber interaktiv über
|
|
das Browser-Terminal statt eines lokalen TTY.
|
|
"""
|
|
|
|
def __init__(self, ws):
|
|
self.ws = ws
|
|
|
|
def missing_host_key(self, client, hostname, key):
|
|
fingerprint = ":".join(f"{b:02x}" for b in key.get_fingerprint())
|
|
self.ws.send(
|
|
f"\r\nDie Authentizität von Host '{hostname}' kann nicht festgestellt werden.\r\n"
|
|
f"{key.get_name()}-Key-Fingerabdruck: {fingerprint}\r\n"
|
|
f"Trotzdem verbinden (yes/no)? "
|
|
)
|
|
if not self._read_yes_no():
|
|
raise _AbortHostKey()
|
|
client.get_host_keys().add(hostname, key.get_name(), key)
|
|
try:
|
|
# save_host_keys() lädt die Datei intern zuerst neu (um parallele
|
|
# Änderungen nicht zu überschreiben) — existiert sie noch nicht,
|
|
# scheitert das mit FileNotFoundError. Leere Datei vorab anlegen.
|
|
if not os.path.exists(SSH_KNOWN_HOSTS_PATH):
|
|
open(SSH_KNOWN_HOSTS_PATH, "a").close()
|
|
client.save_host_keys(SSH_KNOWN_HOSTS_PATH)
|
|
except OSError:
|
|
app.logger.error("save_host_keys failed:\n%s", traceback.format_exc())
|
|
|
|
def _read_yes_no(self):
|
|
"""
|
|
Liest zeichenweise vom Terminal (wie normale Tastatureingaben), bis
|
|
Enter kommt, mit lokalem Echo — es existiert an dieser Stelle noch
|
|
keine Remote-Shell, die das für uns übernehmen könnte.
|
|
"""
|
|
answer = ""
|
|
while True:
|
|
raw = self.ws.receive(timeout=120)
|
|
if raw is None:
|
|
return False
|
|
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"):
|
|
self.ws.send("\r\n")
|
|
return answer.strip().lower() in ("yes", "y")
|
|
elif ch in ("\x7f", "\b"):
|
|
if answer:
|
|
answer = answer[:-1]
|
|
self.ws.send("\b \b")
|
|
else:
|
|
answer += ch
|
|
self.ws.send(ch)
|
|
|
|
|
|
@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()
|
|
password = str(init.get("password") or "")
|
|
port = str(init.get("port") or "22").strip()
|
|
|
|
if not (_SSH_HOST_RE.match(host) and _SSH_USER_RE.match(username) and port.isdigit()):
|
|
_send_and_close(ws, "\r\n\x1b[31mUngültiger Host oder Benutzername.\x1b[0m\r\n")
|
|
return
|
|
|
|
ws.send(f"\x1b[90m→ Verbinde zu {username}@{host}:{port} …\x1b[0m\r\n")
|
|
|
|
client = paramiko.SSHClient()
|
|
try:
|
|
client.load_host_keys(SSH_KNOWN_HOSTS_PATH)
|
|
except (OSError, paramiko.SSHException):
|
|
pass
|
|
client.set_missing_host_key_policy(_InteractiveHostKeyPolicy(ws))
|
|
|
|
try:
|
|
client.connect(
|
|
hostname=host,
|
|
port=int(port),
|
|
username=username,
|
|
password=password or None,
|
|
timeout=10,
|
|
banner_timeout=10,
|
|
auth_timeout=15,
|
|
look_for_keys=False,
|
|
allow_agent=False,
|
|
)
|
|
except _AbortHostKey:
|
|
_send_and_close(ws, "\r\n\x1b[33mHost-Key abgelehnt — Verbindung abgebrochen.\x1b[0m\r\n")
|
|
return
|
|
except paramiko.AuthenticationException:
|
|
_send_and_close(ws, "\r\n\x1b[31m✗ Zugangsdaten falsch (Authentifizierung fehlgeschlagen).\x1b[0m\r\n")
|
|
return
|
|
except (paramiko.SSHException, OSError, EOFError) as e:
|
|
_send_and_close(ws, f"\r\n\x1b[31m✗ Verbindung fehlgeschlagen: {e}\x1b[0m\r\n")
|
|
return
|
|
|
|
ws.send("\r\n\x1b[32m✓ Login erfolgreich — Sitzung wird geöffnet …\x1b[0m\r\n")
|
|
|
|
try:
|
|
channel = client.invoke_shell(term="xterm-256color", width=80, height=24)
|
|
except paramiko.SSHException as e:
|
|
_send_and_close(ws, f"\r\n\x1b[31m✗ Konnte keine Shell öffnen: {e}\x1b[0m\r\n")
|
|
client.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:
|
|
client.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)
|