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