Vollstaendiger Audit aller Export-/Import-Kategorien gegen das tatsaechliche DB-Schema (7 parallele Pruef-Agents je Kategorie) ergab mehrere Luecken -- alle behoben: - Auditlog-Export (v1.1.10-Nacharbeit): kein Deckel mehr bei 500 Zeilen, exportiert wird das komplette aktuell in der DB stehende Auditlog (bereits archivierte Tage liegen ohnehin als eigene Dateien vor). - Clients: ssh_port und Zugangsdaten-Zuordnung (als portabler credential_name statt roher ID) fehlten komplett im Export. - Switche: ssh_port fehlte komplett im Export. - Zugangsdaten: category (z.B. "Linux-Client" fuer Wartung) fehlte. - Custom-Gruppen: is_default fehlte (war beim Import zusaetzlich hart auf 0 gesetzt statt aus der Importdatei uebernommen). - LDAP-Gruppenzuordnungen: app_group_id wurde als ROHE lokale Datenbank-ID exportiert -- auf einem frisch installierten Zielsystem (der eigentliche Zweck dieser Funktion, siehe Seiten-Untertitel "Umzug auf eine neue Umgebung") haben Gruppen dort andere IDs, die Zuordnung waere kaputt oder auf die falsche Gruppe gezeigt. Jetzt wird der Gruppenname exportiert (bzw. "Admin" als Sonderfall) und beim Import auf dem Zielsystem wieder zur dortigen ID aufgeloest -- unbekannte Zielgruppen werden übersprungen statt eine kaputte Referenz anzulegen. - ldap_fileshare_mappings (AD-Gruppe -> Freigabe) fehlte im Export/Import komplett -- alle Fileshare-Zuordnungen gingen bei jedem Umzug verloren. - ldap_required_login_group (Login-Beschraenkung auf eine AD-Gruppe) fehlte in LDAP_SETTING_KEYS und wurde nie exportiert. - NGINX-Einstellungen (Domain, Ports, SSL/HSTS-Schalter) hatten bisher gar keine Export-/Import-Kategorie. Bewusst AUSGESCHLOSSEN bleiben ssl_source, das TLS-Zertifikat und der private Schluessel sowie jeglicher Let's-Encrypt-/certbot-Zustand -- das sind host-gebundene reale Dateien bzw. eine ACME-Registrierung fuer genau diesen Host, kein portabler Einstellungswert. - known_hosts: sobald Zugangsdaten exportiert werden, wird zusaetzlich der Inhalt von known_hosts mitexportiert (jeder per SSH bereits bestaetigte Host-Key gehoert inhaltlich zu den Zugangsdaten) und beim Import ergaenzend (nicht ueberschreibend) mit Zeilen-Deduplizierung in die eigene known_hosts-Datei eingespielt. avatar_filename (Benutzer) bleibt bewusst vom Export ausgeschlossen -- reiner Dateiname ohne die zugehoerige Bilddatei waere auf dem Zielsystem nur ein kaputter Verweis, analog zur bewussten Nichtaufnahme von TLS-Zertifikat/ Schluessel bei NGINX. Verifiziert per isoliertem Roundtrip-Test gegen eine Kopie der echten Datenbank (nie die Produktivdaten selbst): Export -> Loeschen -> Re-Import fuer jede geaenderte Kategorie plus known_hosts-Merge (inkl. Deduplizierungs-Probe durch zweifachen Import derselben Datei), sowie ein echter Browser-Exportlauf gegen POETEST zur Bestaetigung der UI/Label-Texte. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
8420 lines
370 KiB
Python
8420 lines
370 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, session, send_file, abort
|
||
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, serialization
|
||
from cryptography import x509
|
||
from datetime import datetime, timedelta
|
||
import base64, csv, io, ipaddress, logging, shlex, shutil, socket, sqlite3, glob, json, os, re, secrets, stat, subprocess, threading, time, traceback, zipfile
|
||
import paramiko
|
||
import yaml
|
||
import ssl
|
||
from ldap3 import Server, Connection, Tls, ALL, SIMPLE, BASE
|
||
from ldap3.core.exceptions import (
|
||
LDAPException,
|
||
LDAPSocketOpenError,
|
||
LDAPSocketReceiveError,
|
||
LDAPSocketSendError,
|
||
LDAPResponseTimeoutError,
|
||
)
|
||
from ldap3.utils.conv import escape_filter_chars
|
||
|
||
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__))
|
||
|
||
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")
|
||
|
||
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")
|
||
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")
|
||
|
||
# Auditlog-Archivierung: eigenständig und bewusst UNABHÄNGIG von der
|
||
# logrotate-Rotation von changes.log (siehe _archive_old_audit_log_rows).
|
||
# Schwellenwerte mit dem Nutzer abgestimmt (20.000 Zeilen Trigger, Ziel
|
||
# 15.000) -- Aufbewahrung der Archivdateien selbst ist bewusst unbegrenzt,
|
||
# stattdessen nur eine Warnung bei knappem Speicherplatz plus manueller
|
||
# Export-und-Löschen-Funktion unter Verlauf.
|
||
AUDIT_ARCHIVE_DIR = os.path.join(TESM_LOG_DIR, "audit-archive")
|
||
AUDIT_LOG_ARCHIVE_THRESHOLD = 20000
|
||
AUDIT_LOG_ARCHIVE_TARGET = 15000
|
||
AUDIT_ARCHIVE_CHECK_INTERVAL_SECONDS = 24 * 60 * 60
|
||
LOG_DISK_SPACE_WARNING_BYTES = 1 * 1024 * 1024 * 1024 # 1 GiB frei
|
||
try:
|
||
os.makedirs(TESM_LOG_DIR, exist_ok=True)
|
||
# War früher 0o777, als noch nicht jeder schreibende Prozess (tesm.service,
|
||
# tesm-check.service) als root lief. Ein world-writable Verzeichnis lässt
|
||
# logrotate die Rotation aber aus Sicherheitsgründen komplett verweigern
|
||
# ("insecure permissions") -- live reproduziert: ohne Rotation blieb
|
||
# live.log unbegrenzt wachsen, siehe auch "su root root" in
|
||
# _write_logrotate_config als zusätzliche Absicherung.
|
||
os.chmod(TESM_LOG_DIR, 0o755)
|
||
except OSError:
|
||
pass
|
||
SWITCH_DEFAULT_SSH_PORT = 22
|
||
ALLOWED_AVATAR_EXT = {"png", "jpg", "jpeg", "gif", "webp"}
|
||
|
||
NGINX_SITE_CONFIG_PATH = os.environ.get("TESM_NGINX_CONFIG", "/etc/nginx/sites-available/tesm")
|
||
TESM_SSL_DIR = os.environ.get("TESM_SSL_DIR", "/etc/tesm/ssl")
|
||
TESM_SSL_CERT_PATH = os.path.join(TESM_SSL_DIR, "cert.pem")
|
||
TESM_SSL_KEY_PATH = os.path.join(TESM_SSL_DIR, "key.pem")
|
||
TESM_ACME_WEBROOT = os.environ.get("TESM_ACME_WEBROOT", "/var/www/tesm-acme")
|
||
CERTBOT_DEPLOY_HOOK_PATH = "/etc/letsencrypt/renewal-hooks/deploy/tesm-reload-nginx.sh"
|
||
try:
|
||
# Bewusst AUSSERHALB von /srv/tesm -- das rsync --delete beim Deployen
|
||
# (siehe install.sh) würde ein dort abgelegtes Zertifikat sonst bei
|
||
# jedem Update wieder löschen. Analog zu /etc/nginx/ selbst: liegt
|
||
# außerhalb des App-Verzeichnisses, übersteht also jedes Update.
|
||
os.makedirs(TESM_SSL_DIR, exist_ok=True)
|
||
os.chmod(TESM_SSL_DIR, 0o700)
|
||
os.makedirs(TESM_ACME_WEBROOT, exist_ok=True)
|
||
except OSError:
|
||
pass
|
||
|
||
|
||
def _le_cert_path(domain):
|
||
return f"/etc/letsencrypt/live/{domain}/fullchain.pem"
|
||
|
||
|
||
def _le_key_path(domain):
|
||
return f"/etc/letsencrypt/live/{domain}/privkey.pem"
|
||
|
||
|
||
def _write_certbot_deploy_hook():
|
||
"""certbot führt nach JEDER erfolgreichen Erneuerung (egal für welche
|
||
Domain) automatisch alle ausführbaren Skripte in diesem Verzeichnis aus
|
||
-- ohne diesen Hook würde nginx nach einer automatischen Verlängerung
|
||
weiter das alte (bald ablaufende) Zertifikat verwenden, da es die
|
||
Datei nicht von selbst neu einliest. Läuft wie _write_logrotate_config
|
||
bei jedem App-Start neu, damit ein frisch installiertes certbot den
|
||
Hook auch ohne manuellen Aufruf der NGINX-Seite bekommt."""
|
||
try:
|
||
os.makedirs(os.path.dirname(CERTBOT_DEPLOY_HOOK_PATH), exist_ok=True)
|
||
with open(CERTBOT_DEPLOY_HOOK_PATH, "w", encoding="utf-8") as f:
|
||
f.write("#!/bin/bash\nsystemctl reload nginx\n")
|
||
os.chmod(CERTBOT_DEPLOY_HOOK_PATH, 0o755)
|
||
except OSError:
|
||
pass
|
||
|
||
|
||
_write_certbot_deploy_hook()
|
||
|
||
CREDENTIAL_CATEGORIES = [
|
||
("switch", "Switch"),
|
||
("linux", "Linux-Client"),
|
||
("windows", "Windows-Client"),
|
||
("router", "Router/Sonstiges"),
|
||
]
|
||
CREDENTIAL_CATEGORY_KEYS = {c[0] for c in CREDENTIAL_CATEGORIES}
|
||
DEVICE_MAINTENANCE_CATEGORY = "linux"
|
||
os.makedirs(AVATAR_DIR, exist_ok=True)
|
||
|
||
app = Flask(__name__)
|
||
# Fileshare-Uploads: bislang gab es keine Grenze (nginx' eigenes Limit vor
|
||
# app.py griff mangels client_max_body_size faktisch bei 1MB, siehe
|
||
# etc/nginx/sites-available/tesm bzw. _NGINX_PROXY_LOCATIONS -- beides jetzt
|
||
# passend auf 15/16MB angehoben). Ohne dieses Flask-seitige Limit würde ein
|
||
# zu großer Upload erst ganz am Ende, nach vollständigem Empfang, an
|
||
# secure_filename()/os.path-Prüfungen scheitern -- mit MAX_CONTENT_LENGTH
|
||
# bricht Werkzeug den Request sofort ab (413), siehe Fehlerbehandlung unten.
|
||
app.config["MAX_CONTENT_LENGTH"] = 15 * 1024 * 1024
|
||
|
||
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
|
||
|
||
|
||
@app.errorhandler(413)
|
||
def _handle_request_too_large(_e):
|
||
"""Greift z.B. beim Fileshare-Upload (siehe MAX_CONTENT_LENGTH oben) --
|
||
ohne diesen Handler würde Werkzeug eine nackte 413-Fehlerseite ohne
|
||
App-Look ausliefern. request.referrer statt einer festen Route, damit
|
||
das auch für andere, spätere Uploads (nicht nur Fileshare) die richtige
|
||
Seite trifft."""
|
||
max_mb = (app.config.get("MAX_CONTENT_LENGTH") or 0) // (1024 * 1024)
|
||
flash(f"Die Datei ist zu groß (Limit: {max_mb} MB).", "danger")
|
||
return redirect(request.referrer or url_for("index"))
|
||
|
||
|
||
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)
|
||
|
||
|
||
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",
|
||
"rows": {
|
||
"view": "devices.view",
|
||
"create": "devices.create",
|
||
"edit": "devices.edit",
|
||
"extra": "devices.restart",
|
||
},
|
||
},
|
||
"switches": {
|
||
"label": "Switche",
|
||
"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",
|
||
"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_history": {"label": "Verlauf", "rows": {"view": "logs_history.view", "edit": "logs_history.edit"}},
|
||
"logs_activity": {"label": "Auditlog", "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",
|
||
"rows": {
|
||
"view": "settings_importexport.view",
|
||
"edit": "settings_importexport.edit",
|
||
"export": "settings_importexport.export",
|
||
},
|
||
},
|
||
"settings_dhcp": {
|
||
"label": "DHCP",
|
||
"rows": {"view": "settings_dhcp.view", "edit": "settings_dhcp.edit"},
|
||
},
|
||
"settings_ldap": {
|
||
"label": "LDAP",
|
||
"rows": {"view": "settings_ldap.view", "edit": "settings_ldap.edit"},
|
||
},
|
||
"settings_nginx": {
|
||
"label": "NGINX",
|
||
"rows": {"view": "settings_nginx.view", "edit": "settings_nginx.edit"},
|
||
},
|
||
"papierkorb": {
|
||
"label": "Papierkorb",
|
||
"rows": {"view": "papierkorb.view", "edit": "papierkorb.edit"},
|
||
},
|
||
},
|
||
},
|
||
"fileshare_group": {
|
||
"label": "Dateifreigaben",
|
||
"view_key": "fileshare_group.view",
|
||
"children": {
|
||
"fileshare": {
|
||
"label": "Dateifreigaben",
|
||
"rows": {
|
||
"view": "fileshare.view",
|
||
"create": "fileshare.create",
|
||
"edit": "fileshare.edit",
|
||
},
|
||
},
|
||
},
|
||
},
|
||
}
|
||
|
||
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)",
|
||
"papierkorb.view": "Papierkorb ansehen (alle gelöschten Geräte/Switche/Zugangsdaten/Benutzer/Gruppen, unabhängig von deren eigenen Leserechten)",
|
||
"papierkorb.edit": "Papierkorb bearbeiten (beliebige Einträge wiederherstellen oder endgültig löschen, unabhängig vom sonstigen Bearbeiten-Recht der jeweiligen Ressource)",
|
||
"logs_live.view": "Live-Log lesen", "logs_activity.view": "Auditlog lesen",
|
||
"logs_kea.view": "Kea-DHCP-Log lesen",
|
||
"logs_history.view": "Log-Verlauf lesen (ältere, durch logrotate rotierte Kopien des Live-Logs, sowie archivierte Auditlog-Tage)",
|
||
"logs_history.edit": "Archivierte Auditlog-Tage exportieren (ZIP-Download) und danach vom Server löschen",
|
||
"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)",
|
||
"settings_nginx.view": "NGINX-Konfiguration und Zertifikatsstatus lesen",
|
||
"settings_nginx.edit": "NGINX-Konfiguration ändern (Domain/Ports, Zertifikat hochladen oder per Let's Encrypt anfordern, HTTPS/HSTS aktivieren)",
|
||
"fileshare_group.view": "Dateifreigaben-Bereich anzeigen",
|
||
"fileshare.view": "Dateifreigaben lesen (Browsen/Herunterladen der über LDAP-Gruppen zugewiesenen Freigaben -- OHNE dieses Recht wird für den Nutzer nichts gemountet)",
|
||
"fileshare.create": "Dateien in Freigaben hochladen",
|
||
"fileshare.edit": "Dateien in Freigaben überschreiben, umbenennen oder löschen",
|
||
}
|
||
|
||
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"]
|
||
|
||
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",
|
||
]
|
||
|
||
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": "users", "label": "Benutzer", "icon": "users", "endpoint": "users"},
|
||
{"key": "groups", "label": "Gruppen", "icon": "groups", "endpoint": "groups"},
|
||
{"key": "fileshare", "label": "Dateifreigaben", "icon": "folder", "endpoint": "fileshare"},
|
||
{"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": "settings_nginx", "label": "NGINX", "icon": "server", "endpoint": "settings_nginx"},
|
||
{"key": "papierkorb", "label": "Papierkorb", "icon": "trash", "endpoint": "papierkorb"},
|
||
]},
|
||
{"key": "logs_group", "label": "Logs", "icon": "terminal", "children": [
|
||
{"key": "logs_live", "label": "Live", "icon": "terminal", "endpoint": "logs"},
|
||
{"key": "logs_history", "label": "Verlauf", "icon": "clock", "endpoint": "logs_history"},
|
||
{"key": "logs_activity", "label": "Auditlog", "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 == "fileshare":
|
||
return user.has_permission("fileshare.view") and bool(_current_fileshare_mounts())
|
||
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 == "settings_nginx":
|
||
return user.has_permission("settings_nginx.view")
|
||
if key == "logs_live":
|
||
return user.can_view_live_log
|
||
if key == "logs_history":
|
||
return user.can_view_log_history
|
||
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}
|
||
|
||
|
||
def _read_tesm_version():
|
||
"""Liest die Versionsnummer aus VERSION (im Repo neben app.py, wird von
|
||
install.sh 1:1 nach /srv/tesm mitkopiert). Fehlt die Datei (z.B. lokale
|
||
Entwicklung ohne vollständigen Checkout), lieber "unbekannt" anzeigen
|
||
als die App deswegen abstürzen zu lassen."""
|
||
try:
|
||
with open(os.path.join(BASE_DIR, "VERSION"), "r", encoding="utf-8") as f:
|
||
return f.read().strip() or "unbekannt"
|
||
except OSError:
|
||
return "unbekannt"
|
||
|
||
|
||
TESM_VERSION = _read_tesm_version()
|
||
|
||
|
||
@app.context_processor
|
||
def inject_tesm_version():
|
||
"""Für die Versionsanzeige im Sidebar-Footer (siehe base.html) — beim
|
||
Modulimport einmalig gelesen (Neustart des Dienstes nach einem Update
|
||
erforderlich, damit eine neue Versionsnummer sichtbar wird — geschieht
|
||
ohnehin bei jedem install.sh/update.sh-Lauf)."""
|
||
return {"tesm_version": TESM_VERSION}
|
||
|
||
|
||
@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: eigener, globaler Bereich unter Einstellungen, komplett
|
||
unabhängig von den Leserechten der einzelnen Ressourcen (Geräte/
|
||
Switche/Zugangsdaten/Benutzer/Gruppen) -- vorher implizit sichtbar
|
||
für jeden mit irgendeiner der fünf Ressourcen-Leserechte, wodurch
|
||
praktisch jeder Benutzer den Papierkorb sehen konnte (Standardgruppe
|
||
hat bereits devices.view). papierkorb.view zeigt jetzt ALLE fünf
|
||
Kategorien auf einmal, unabhängig davon, ob der Benutzer die
|
||
jeweilige aktive Ressource überhaupt sehen darf."""
|
||
return self.has_permission("papierkorb.view")
|
||
|
||
@property
|
||
def can_edit_papierkorb(self):
|
||
"""Wiederherstellen/endgültig Löschen im Papierkorb: eigenes,
|
||
globales Recht (papierkorb.edit) -- vorher an das jeweilige
|
||
Ressourcen-Recht gekoppelt (z.B. switches.edit für Switche im
|
||
Papierkorb). Wer den Papierkorb bearbeiten darf, darf jetzt JEDEN
|
||
Eintrag darin wiederherstellen oder endgültig löschen, unabhängig
|
||
vom sonstigen Bearbeiten-Recht der jeweiligen Ressource."""
|
||
return self.has_permission("papierkorb.edit")
|
||
|
||
@property
|
||
def can_view_live_log(self):
|
||
return self.has_permission("logs_live.view")
|
||
|
||
@property
|
||
def can_view_log_history(self):
|
||
"""Ältere, durch logrotate rotierte Kopien des Live-Logs (Seite
|
||
Verlauf) -- bewusst eigenes Recht statt an logs_live.view gekoppelt,
|
||
damit z.B. ein Nutzer den aktuellen Live-Status sehen darf, ohne
|
||
automatisch auch durch ältere Logstände blättern zu können."""
|
||
return self.has_permission("logs_history.view")
|
||
|
||
@property
|
||
def can_manage_log_history(self):
|
||
"""Zusätzlich zum reinen Lesen (can_view_log_history): archivierte
|
||
Auditlog-Tage als ZIP exportieren und danach vom Server löschen --
|
||
bewusst eigenes Edit-Recht, weil das eine destruktive Aktion ist,
|
||
die über reines Einsehen des Verlaufs hinausgeht."""
|
||
return self.has_permission("logs_history.edit")
|
||
|
||
@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):
|
||
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
|
||
|
||
|
||
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()
|
||
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)
|
||
)
|
||
""")
|
||
|
||
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")
|
||
|
||
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")
|
||
if "auth_source" not in user_cols:
|
||
conn.execute("ALTER TABLE users ADD COLUMN auth_source TEXT NOT NULL DEFAULT 'local'")
|
||
if "email" not in user_cols:
|
||
conn.execute("ALTER TABLE users ADD COLUMN email TEXT")
|
||
if "is_locked" not in user_cols:
|
||
conn.execute("ALTER TABLE users ADD COLUMN is_locked INTEGER NOT NULL DEFAULT 0")
|
||
|
||
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
|
||
)
|
||
""")
|
||
|
||
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)
|
||
)
|
||
""")
|
||
|
||
conn.execute("""
|
||
CREATE TABLE IF NOT EXISTS ldap_fileshare_mappings (
|
||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||
ad_group_dn TEXT NOT NULL,
|
||
ad_group_name TEXT NOT NULL,
|
||
share_label TEXT NOT NULL,
|
||
share_unc TEXT NOT NULL,
|
||
UNIQUE(ad_group_dn, share_unc)
|
||
)
|
||
""")
|
||
|
||
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
|
||
)
|
||
""")
|
||
|
||
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()}
|
||
|
||
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")
|
||
|
||
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'
|
||
)
|
||
""")
|
||
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")
|
||
if "ssh_port" not in switch_cols:
|
||
conn.execute("ALTER TABLE switches ADD COLUMN ssh_port INTEGER")
|
||
|
||
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")
|
||
|
||
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")
|
||
|
||
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
|
||
)
|
||
""")
|
||
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)
|
||
)
|
||
""")
|
||
_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,))
|
||
_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,))
|
||
_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,))
|
||
|
||
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
|
||
)
|
||
""")
|
||
|
||
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
|
||
)
|
||
""")
|
||
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_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,))
|
||
|
||
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_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_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,)
|
||
)
|
||
|
||
_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,))
|
||
|
||
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],
|
||
)
|
||
|
||
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()
|
||
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 → Auditlog, 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 _format_audit_line(ts, who, action, target, details):
|
||
"""Gleiches Zeilenformat wie _append_changes_log(), damit ein
|
||
archivierter Auditlog-Tag optisch identisch zu changes.log aussieht."""
|
||
line = f"{ts} [{who}] {action}"
|
||
if target:
|
||
line += f" — {target}"
|
||
if details:
|
||
line += f": {details}"
|
||
return line
|
||
|
||
|
||
def _audit_archive_file_path(day_str):
|
||
"""day_str: 'YYYY-MM-DD', ausschließlich aus intern erzeugten
|
||
DB-Zeitstempeln abgeleitet, nie aus Nutzereingabe -- anders als bei
|
||
_resolve_log_history_file() ist hier kein Path-Traversal-Schutz nötig."""
|
||
return os.path.join(AUDIT_ARCHIVE_DIR, f"audit-{day_str}.log")
|
||
|
||
|
||
def _audit_archive_files():
|
||
"""Für die Verlauf-Seite: alle bereits archivierten Auditlog-Tage,
|
||
neueste zuerst, mit Größe für die Dateiliste."""
|
||
if not os.path.isdir(AUDIT_ARCHIVE_DIR):
|
||
return []
|
||
out = []
|
||
for name in os.listdir(AUDIT_ARCHIVE_DIR):
|
||
m = re.fullmatch(r"audit-(\d{4}-\d{2}-\d{2})\.log", name)
|
||
if not m:
|
||
continue
|
||
full_path = os.path.join(AUDIT_ARCHIVE_DIR, name)
|
||
try:
|
||
size = os.path.getsize(full_path)
|
||
except OSError:
|
||
continue
|
||
out.append({"filename": name, "day": m.group(1), "size": size})
|
||
out.sort(key=lambda f: f["day"], reverse=True)
|
||
return out
|
||
|
||
|
||
def _resolve_audit_archive_file(filename):
|
||
"""Path-Traversal-Schutz analog _resolve_log_history_file(): nur exakt
|
||
ein Dateiname im erwarteten audit-YYYY-MM-DD.log-Muster, innerhalb von
|
||
AUDIT_ARCHIVE_DIR."""
|
||
if not filename or not re.fullmatch(r"audit-\d{4}-\d{2}-\d{2}\.log", filename):
|
||
return None
|
||
full_path = os.path.join(AUDIT_ARCHIVE_DIR, filename)
|
||
if os.path.dirname(os.path.abspath(full_path)) != os.path.abspath(AUDIT_ARCHIVE_DIR):
|
||
return None
|
||
if not os.path.isfile(full_path):
|
||
return None
|
||
return full_path
|
||
|
||
|
||
def _log_disk_space_warning():
|
||
"""None wenn genug Platz frei ist, sonst ein Warnhinweis-Text fürs
|
||
Verlauf-Template. Bewusst nur eine Warnung (kein automatisches
|
||
Löschen) -- Aufräumen bleibt eine bewusste, manuelle Entscheidung
|
||
über den Export-und-Löschen-Button."""
|
||
try:
|
||
free = shutil.disk_usage(TESM_LOG_DIR).free
|
||
except OSError:
|
||
return None
|
||
if free >= LOG_DISK_SPACE_WARNING_BYTES:
|
||
return None
|
||
free_mb = free // (1024 * 1024)
|
||
return (
|
||
f"Wenig Speicherplatz im Log-Verzeichnis (nur noch {free_mb} MB frei). "
|
||
f"Archivierte Auditlog-Tage können unten exportiert (ZIP-Download) und "
|
||
f"dabei vom Server gelöscht werden, um Platz zu schaffen."
|
||
)
|
||
|
||
|
||
def _archive_old_audit_log_rows():
|
||
"""Hält die audit_log-Tabelle auf eine vernünftige historische
|
||
Zeilenanzahl begrenzt: sobald AUDIT_LOG_ARCHIVE_THRESHOLD überschritten
|
||
ist, werden die ältesten VOLLSTÄNDIGEN Kalendertage (alles vor dem
|
||
heutigen Tag -- der laufende Tag wird nie mitten am Tag angefasst) in je
|
||
eine eigene Datei audit-YYYY-MM-DD.log unter AUDIT_ARCHIVE_DIR
|
||
geschrieben und anschließend aus der DB gelöscht, so lange bis
|
||
AUDIT_LOG_ARCHIVE_TARGET wieder unterschritten ist oder kein
|
||
archivierbarer (nicht-heutiger) Tag mehr übrig ist.
|
||
|
||
Bewusst unabhängig von der wöchentlichen logrotate-Rotation von
|
||
changes.log (siehe _write_logrotate_config): die Dateien hier sind
|
||
tagesgenau benannt, damit sie unter Verlauf direkt nach Kalendertag
|
||
auswählbar sind -- eine logrotate-Kopie (changes.log.1.gz usw.) lässt
|
||
sich nicht so eindeutig einem Tag zuordnen.
|
||
|
||
Schreiben je Tagesdatei erfolgt im Overwrite-Modus ('w', nicht 'a'):
|
||
die DB ist bis zum COMMIT des DELETE die alleinige Quelle der Wahrheit
|
||
für einen noch nicht archivierten Tag -- ein erneuter Lauf (z.B. nach
|
||
einem Absturz zwischen Schreiben und Löschen) erzeugt exakt denselben
|
||
Dateiinhalt erneut, statt Zeilen zu duplizieren."""
|
||
conn = get_db_connection()
|
||
archived_days = 0
|
||
archived_rows = 0
|
||
try:
|
||
total = conn.execute("SELECT COUNT(*) AS n FROM audit_log").fetchone()["n"]
|
||
if total <= AUDIT_LOG_ARCHIVE_THRESHOLD:
|
||
return
|
||
|
||
today_start = datetime.now().strftime("%Y-%m-%d 00:00:00")
|
||
os.makedirs(AUDIT_ARCHIVE_DIR, exist_ok=True)
|
||
|
||
while total > AUDIT_LOG_ARCHIVE_TARGET:
|
||
oldest = conn.execute(
|
||
"SELECT MIN(ts) AS ts FROM audit_log WHERE ts < ?", (today_start,)
|
||
).fetchone()["ts"]
|
||
if not oldest:
|
||
break # nur noch der heutige Tag übrig -- nicht anfassen
|
||
day_str = oldest[:10]
|
||
day_start, day_end = f"{day_str} 00:00:00", f"{day_str} 23:59:59"
|
||
rows = conn.execute(
|
||
"SELECT ts, username, action, target, details FROM audit_log "
|
||
"WHERE ts >= ? AND ts <= ? ORDER BY ts, id",
|
||
(day_start, day_end),
|
||
).fetchall()
|
||
if not rows:
|
||
break
|
||
|
||
lines = [
|
||
_format_audit_line(r["ts"], r["username"], r["action"], r["target"], r["details"])
|
||
for r in rows
|
||
]
|
||
with open(_audit_archive_file_path(day_str), "w", encoding="utf-8") as f:
|
||
f.write("\n".join(lines) + "\n")
|
||
|
||
conn.execute("DELETE FROM audit_log WHERE ts >= ? AND ts <= ?", (day_start, day_end))
|
||
conn.commit()
|
||
|
||
archived_days += 1
|
||
archived_rows += len(rows)
|
||
total -= len(rows)
|
||
finally:
|
||
conn.close()
|
||
|
||
if archived_rows:
|
||
log_action_system(
|
||
"auditlog.archive",
|
||
"Auditlog",
|
||
f"{archived_rows} Einträge über {archived_days} Tag(e) archiviert "
|
||
f"(Schwellenwert {AUDIT_LOG_ARCHIVE_THRESHOLD}, Ziel {AUDIT_LOG_ARCHIVE_TARGET}).",
|
||
)
|
||
|
||
|
||
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, Auditlog, 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). "su root root" ist eine explizite Absicherung
|
||
dagegen, dass logrotate die Rotation wegen "insecure permissions"
|
||
verweigert, sobald TESM_LOG_DIR aus irgendeinem Grund wieder
|
||
gruppen-/world-writable wird (live reproduziert: bei 0o777 rotierte
|
||
logrotate live.log gar nicht mehr, siehe auch os.chmod(TESM_LOG_DIR)
|
||
weiter oben, das den eigentlichen Regelfall -- 0o755 -- durchsetzt)."""
|
||
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" su root root\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()
|
||
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_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,
|
||
"default_group": get_setting("ldap_default_group", "") or "",
|
||
"required_login_group": get_setting("ldap_required_login_group", "") or "",
|
||
}
|
||
|
||
|
||
_LDAP_TRANSIENT_EXCEPTIONS = (
|
||
LDAPSocketOpenError,
|
||
LDAPSocketReceiveError,
|
||
LDAPSocketSendError,
|
||
LDAPResponseTimeoutError,
|
||
)
|
||
|
||
|
||
def _ldap_connect_with_retry(factory):
|
||
"""Baut eine LDAP-Verbindung auf, mit genau einem automatischen
|
||
Wiederholversuch bei einem rein netzwerk-/timeout-bedingten Fehler
|
||
(z.B. ein Domain-Controller in einem anderen, langsameren Netzsegment
|
||
-- die allererste Verbindung dorthin ist oft spürbar langsamer als
|
||
jede folgende, live reproduziert: erster Login schlug mit "Benutzer/
|
||
Passwort ungültig" fehl, ein sofortiger zweiter Versuch klappte). Der
|
||
Retry greift NICHT bei einer tatsächlich abgelehnten Anmeldung (z.B.
|
||
falsches Passwort, eigene Exception-Klasse in ldap3) -- sonst würde
|
||
ein einzelner Tippfehler beim Passwort einen AD-Kontosperren-Zähler
|
||
unnötig doppelt statt einmal hochzählen. factory ist ein parameterloser
|
||
Callable, das die Verbindung aufbaut (auto_bind=True) und zurückgibt."""
|
||
try:
|
||
return factory()
|
||
except _LDAP_TRANSIENT_EXCEPTIONS:
|
||
return factory()
|
||
|
||
|
||
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 = 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=10)
|
||
conn = _ldap_connect_with_retry(lambda: Connection(
|
||
server, user=cfg["bind_dn"], password=bind_password,
|
||
authentication=SIMPLE, auto_bind=True, receive_timeout=10,
|
||
))
|
||
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)
|
||
except ValueError:
|
||
pass
|
||
|
||
return {
|
||
"username": canonical_username,
|
||
"first_name": first_name,
|
||
"last_name": last_name,
|
||
"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()
|
||
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 _normalize_share_unc(raw):
|
||
"""Normalisiert einen vom Formular übergebenen Freigabe-Pfad auf das
|
||
von mount.cifs benötigte "//server/freigabe"-Format -- ein
|
||
Windows-Admin gibt naturgemäß "\\\\server\\freigabe" ein."""
|
||
unc = raw.strip().replace("\\", "/")
|
||
if unc and not unc.startswith("//"):
|
||
unc = "//" + unc.lstrip("/")
|
||
return unc
|
||
|
||
|
||
def _ldap_fileshare_mappings():
|
||
conn = get_db_connection()
|
||
rows = conn.execute(
|
||
"SELECT id, ad_group_dn, ad_group_name, share_label, share_unc "
|
||
"FROM ldap_fileshare_mappings ORDER BY ad_group_name ASC"
|
||
).fetchall()
|
||
conn.close()
|
||
return rows
|
||
|
||
|
||
def _ldap_resolve_fileshares(service_conn, user_dn):
|
||
"""Analog zu _ldap_resolve_app_groups, nur für Dateifreigaben: prüft für
|
||
jede konfigurierte AD-Gruppe -> Freigabe-Zuordnung die (rekursive)
|
||
Mitgliedschaft und gibt die Liste der passenden Freigaben zurück.
|
||
Bewusst getrennt vom TESM-eigenen Rechtesystem -- OB überhaupt gemountet
|
||
werden darf, entscheidet current_user.has_permission('fileshare.view')
|
||
(siehe _mount_fileshares_for_login), WELCHE Freigaben es im Erfolgsfall
|
||
sind, ausschließlich die AD-Gruppenmitgliedschaft hier."""
|
||
matched = []
|
||
seen_labels = set()
|
||
for mapping in _ldap_fileshare_mappings():
|
||
if _ldap_is_member_of(service_conn, user_dn, mapping["ad_group_dn"]):
|
||
label = mapping["share_label"]
|
||
if label in seen_labels:
|
||
continue
|
||
seen_labels.add(label)
|
||
matched.append({"label": label, "unc": mapping["share_unc"]})
|
||
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
|
||
if cfg["required_login_group"] and not _ldap_is_member_of(service_conn, user_dn, cfg["required_login_group"]):
|
||
# Kein Mitglied der erforderlichen AD-Gruppe -- Login wird
|
||
# abgelehnt wie bei falschen Zugangsdaten, ohne den eigentlichen
|
||
# Passwort-Bind unten überhaupt erst zu versuchen. Bewusst
|
||
# dieselbe generische Fehlermeldung wie jeder andere
|
||
# Login-Fehlschlag, um keine Gruppenmitgliedschaft zu verraten.
|
||
return False, None
|
||
info["app_groups"] = _ldap_resolve_app_groups(service_conn, user_dn)
|
||
info["fileshares"] = _ldap_resolve_fileshares(service_conn, user_dn)
|
||
except LDAPException:
|
||
return False, None
|
||
finally:
|
||
try:
|
||
service_conn.unbind()
|
||
except Exception:
|
||
pass
|
||
|
||
try:
|
||
user_conn = _ldap_connect_with_retry(lambda: Connection(
|
||
server, user=user_dn, password=password,
|
||
authentication=SIMPLE, auto_bind=True, receive_timeout=10,
|
||
))
|
||
user_conn.unbind()
|
||
except LDAPException:
|
||
return False, None
|
||
|
||
return True, info
|
||
|
||
|
||
|
||
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
|
||
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:
|
||
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
|
||
|
||
|
||
# ============================================================================
|
||
# Dateifreigaben (Fileshare) -- sessionabhängiges Mounten von SMB/CIFS-
|
||
# Freigaben je nach AD-Gruppenmitgliedschaft, analog zu einer
|
||
# GPO-Laufwerkszuordnung, nur serverseitig im Browser statt als
|
||
# Laufwerksbuchstabe auf dem Client. Zwei UNABHÄNGIGE Voraussetzungen
|
||
# müssen zutreffen, bevor überhaupt gemountet wird:
|
||
# 1. Das TESM-eigene Recht fileshare.view (Rechtesystem-Gate).
|
||
# 2. Mindestens eine per LDAP-Gruppe zugeordnete Freigabe (siehe
|
||
# _ldap_resolve_fileshares) -- WELCHE Freigaben es sind, entscheidet
|
||
# ausschließlich die AD-Gruppenmitgliedschaft, nicht das Rechtesystem.
|
||
# Gemountet wird mit den eigenen AD-Zugangsdaten des Nutzers (aus dem
|
||
# Login-Vorgang, nie gespeichert), NICHT mit einem festen Service-Konto --
|
||
# Dateiserver-eigene ACLs bleiben dadurch individuell wirksam.
|
||
# ============================================================================
|
||
|
||
FILESHARE_MOUNT_ROOT = os.environ.get("TESM_FILESHARE_MOUNT_ROOT", "/mnt/tesm-shares")
|
||
FILESHARE_MAX_AGE_SECONDS = 12 * 3600
|
||
FILESHARE_SWEEP_INTERVAL_SECONDS = 1800
|
||
|
||
# Inline-Vorschau (/fileshare/view, siehe unten): pro Endung, welche Art von
|
||
# Vorschau der Client bauen soll (steuert nur die UI/JS-Verzweigung) und mit
|
||
# welchem Content-Type die Datei ausgeliefert wird. Bewusst eine feste
|
||
# Positivliste -- alles andere bekommt gar keinen Vorschau-Button und die
|
||
# View-Route liefert für unbekannte Endungen 415 statt "irgendwas" mit vom
|
||
# Dateinamen geratenem Content-Type auszuliefern. .doc (altes Word-Binär-
|
||
# format) ist bewusst NICHT dabei -- mammoth.js kann nur .docx (OOXML)
|
||
# zuverlässig konvertieren; SheetJS dagegen liest sowohl alte .xls- als auch
|
||
# .xlsx-Dateien ordentlich, deshalb dort beide.
|
||
_FILESHARE_PREVIEW_KINDS = {
|
||
".pdf": "pdf",
|
||
".jpg": "image", ".jpeg": "image", ".png": "image", ".gif": "image",
|
||
".webp": "image", ".bmp": "image", ".svg": "image",
|
||
".txt": "text", ".csv": "text", ".log": "text", ".md": "text", ".json": "text",
|
||
".docx": "docx",
|
||
".xlsx": "xlsx", ".xls": "xlsx",
|
||
}
|
||
_FILESHARE_PREVIEW_MIME = {
|
||
".pdf": "application/pdf",
|
||
".jpg": "image/jpeg", ".jpeg": "image/jpeg", ".png": "image/png", ".gif": "image/gif",
|
||
".webp": "image/webp", ".bmp": "image/bmp", ".svg": "image/svg+xml",
|
||
".txt": "text/plain; charset=utf-8", ".csv": "text/plain; charset=utf-8",
|
||
".log": "text/plain; charset=utf-8", ".md": "text/plain; charset=utf-8",
|
||
".json": "text/plain; charset=utf-8",
|
||
".docx": "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
||
".xlsx": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||
".xls": "application/vnd.ms-excel",
|
||
}
|
||
|
||
_active_fileshare_mounts = {}
|
||
|
||
|
||
def _fileshare_cleanup_all_on_startup():
|
||
"""Räumt beim App-Start ALLE evtl. noch vorhandenen Mounts unter
|
||
FILESHARE_MOUNT_ROOT aus einer vorherigen Prozess-Lebensdauer auf. Das
|
||
In-Memory-Tracking (_active_fileshare_mounts) ist bei jedem Neustart
|
||
(Update, Absturz, ...) naturgemäß leer -- ohne dieses Aufräumen blieben
|
||
verwaiste Mounts für die App unsichtbar, aber real auf dem System
|
||
bestehen."""
|
||
if not os.path.isdir(FILESHARE_MOUNT_ROOT):
|
||
return
|
||
try:
|
||
token_dirs = os.listdir(FILESHARE_MOUNT_ROOT)
|
||
except OSError:
|
||
return
|
||
for token_dir in token_dirs:
|
||
token_path = os.path.join(FILESHARE_MOUNT_ROOT, token_dir)
|
||
if not os.path.isdir(token_path):
|
||
continue
|
||
try:
|
||
share_dirs = os.listdir(token_path)
|
||
except OSError:
|
||
share_dirs = []
|
||
for share_dir in share_dirs:
|
||
share_path = os.path.join(token_path, share_dir)
|
||
if os.path.ismount(share_path):
|
||
subprocess.run(["umount", "-l", share_path], capture_output=True, timeout=15)
|
||
try:
|
||
os.rmdir(share_path)
|
||
except OSError:
|
||
pass
|
||
try:
|
||
os.rmdir(token_path)
|
||
except OSError:
|
||
pass
|
||
|
||
|
||
# app.py wird nicht nur von gunicorn (tesm.service) gestartet, sondern auch
|
||
# von generate_ips.py per "from app import ..." als reines Hilfsmodul
|
||
# importiert (u.a. alle paar Sekunden aus poe.sh heraus) -- ein Import
|
||
# führt sämtlichen Modul-Level-Code hier erneut aus. Ohne dieses Gate
|
||
# würde JEDER dieser Imports _fileshare_cleanup_all_on_startup() erneut
|
||
# auslösen und damit gerade aktive Fileshare-Mounts anderer, echter
|
||
# Sitzungen sofort wieder aushängen. TESM_WEB_PROCESS wird nur von
|
||
# tesm.service selbst gesetzt (siehe dessen Unit-Datei) -- nur dort soll
|
||
# das Aufräumen beim (Neu-)Start überhaupt stattfinden.
|
||
_IS_WEB_PROCESS = os.environ.get("TESM_WEB_PROCESS") == "1"
|
||
|
||
if _IS_WEB_PROCESS:
|
||
_fileshare_cleanup_all_on_startup()
|
||
|
||
|
||
def _resolve_host_preferring_ipv4(hostname):
|
||
"""Löst EINEN Hostnamen auf -- IPv4-Adresse wenn vorhanden, sonst
|
||
IPv6, sonst None. IPv4 wird bevorzugt, weil sie in der Praxis
|
||
zuverlässiger durchgeroutet ist als in DNS eingetragene IPv6-Adressen
|
||
(siehe _resolve_unc_host_ip); eine funktionierende IPv6-Route wird
|
||
aber genutzt, wenn es keine IPv4-Adresse gibt, statt komplett
|
||
aufzugeben."""
|
||
try:
|
||
infos = socket.getaddrinfo(hostname, None, socket.AF_UNSPEC, socket.SOCK_STREAM)
|
||
except socket.gaierror:
|
||
return None
|
||
if not infos:
|
||
return None
|
||
ipv4 = next((i for i in infos if i[0] == socket.AF_INET), None)
|
||
return (ipv4 or infos[0])[4][0]
|
||
|
||
|
||
def _resolve_unc_host_ip(unc):
|
||
"""Löst den Host-Teil einer "//host/share"-UNC auf eine IP-Adresse
|
||
auf, oder gibt None zurück (Host ist bereits eine IP-Literal, oder es
|
||
lässt sich nichts ermitteln). Zwei Probleme werden hier abgefangen,
|
||
die beide dazu führen, dass eine Freigabe per IP klappt, per Hostname
|
||
aber nicht:
|
||
1. Ein reiner Kurzname (z.B. "s2025" statt "s2025.ad.eertmoed.net")
|
||
löst über die konfigurierte DNS des Servers oft GAR NICHT auf, weil
|
||
hier (anders als bei einem domänenbeigetretenen Windows-Client)
|
||
keine DNS-Suffixsuche eingerichtet ist. Als Fallback wird deshalb
|
||
zusätzlich mit dem aus der LDAP-Servereinstellung abgeleiteten
|
||
AD-Domänensuffix versucht (die dortige AD-DNS-Zone enthält
|
||
erfahrungsgemäß auch die Datei-Server).
|
||
2. Manche interne DNS-Zonen liefern für Server-Hostnamen NUR
|
||
AAAA-Einträge (siehe ad.eertmoed.net) -- IPv4 wird bevorzugt
|
||
verwendet, falls zusätzlich vorhanden, s.o.
|
||
Der Hostname bleibt in jedem Fall unverändert in der UNC stehen --
|
||
nur die tatsächliche Verbindung wird per "ip="-Mount-Option gezielt
|
||
auf die ermittelte Adresse gelenkt."""
|
||
host = unc[2:].split("/", 1)[0] if unc.startswith("//") else ""
|
||
if not host:
|
||
return None
|
||
try:
|
||
socket.inet_aton(host)
|
||
return None # Host ist bereits eine IPv4-Literal, nichts zu tun
|
||
except OSError:
|
||
pass
|
||
ip = _resolve_host_preferring_ipv4(host)
|
||
if ip:
|
||
return ip
|
||
if "." not in host:
|
||
domain = get_setting("ldap_server", "")
|
||
if domain and "." in domain:
|
||
ip = _resolve_host_preferring_ipv4(f"{host}.{domain}")
|
||
return ip
|
||
|
||
|
||
def _mount_one_fileshare(mount_root, label, unc, username, password):
|
||
"""Mountet EINE Freigabe unter mount_root/label per mount.cifs. Das
|
||
Passwort wird bewusst über die PASSWD-Umgebungsvariable übergeben statt
|
||
in der -o-Optionsliste (die kurzzeitig in der Prozessliste sichtbar
|
||
wäre) -- von mount.cifs offiziell unterstützter Mechanismus genau für
|
||
diesen Zweck. Gibt (ok, message) zurück, wirft nie -- ein nicht
|
||
erreichbarer Server soll weder den Login blockieren noch andere
|
||
Freigaben verhindern, nur diese eine fehlt dann."""
|
||
target = os.path.join(mount_root, label)
|
||
try:
|
||
os.makedirs(target, exist_ok=True)
|
||
except OSError as e:
|
||
return False, str(e)
|
||
options = f"username={username},vers=3.0,uid=0,gid=0,file_mode=0770,dir_mode=0770,iocharset=utf8"
|
||
resolved_ip = _resolve_unc_host_ip(unc)
|
||
if resolved_ip:
|
||
options += f",ip={resolved_ip}"
|
||
env = dict(os.environ)
|
||
env["PASSWD"] = password
|
||
try:
|
||
result = subprocess.run(
|
||
["mount", "-t", "cifs", unc, target, "-o", options],
|
||
capture_output=True, text=True, timeout=20, env=env,
|
||
)
|
||
ok = result.returncode == 0
|
||
out = (result.stdout or "") + (result.stderr or "")
|
||
except Exception as e:
|
||
ok, out = False, str(e)
|
||
if not ok:
|
||
try:
|
||
os.rmdir(target)
|
||
except OSError:
|
||
pass
|
||
return ok, out
|
||
|
||
|
||
def _mount_fileshares_for_login(user_obj, username, password, fileshares):
|
||
"""Direkt nach erfolgreichem LDAP-Login aufzurufen, solange das
|
||
Klartext-Passwort noch im Scope ist -- wird nie gespeichert, nur für
|
||
diesen einen mount-Aufruf verwendet. Mountet NUR, wenn der Nutzer das
|
||
TESM-Recht fileshare.view hat (siehe PERMISSIONS/fileshare_group) --
|
||
fehlt es, wird bewusst gar nicht erst versucht zu mounten, unabhängig
|
||
davon, ob AD-seitig passende Freigaben existieren würden."""
|
||
if not fileshares or not user_obj.has_permission("fileshare.view"):
|
||
return
|
||
token = secrets.token_hex(16)
|
||
mount_root = os.path.join(FILESHARE_MOUNT_ROOT, token)
|
||
mounted = []
|
||
for share in fileshares:
|
||
ok, err = _mount_one_fileshare(mount_root, share["label"], share["unc"], username, password)
|
||
if ok:
|
||
mounted.append({"label": share["label"], "mount_path": os.path.join(mount_root, share["label"])})
|
||
else:
|
||
app.logger.warning("Fileshare-Mount fehlgeschlagen (%s, Freigabe %s): %s", username, share["label"], err)
|
||
if mounted:
|
||
session["fileshare_token"] = token
|
||
_active_fileshare_mounts[token] = {"mounted_at": time.time(), "shares": mounted}
|
||
else:
|
||
try:
|
||
os.rmdir(mount_root)
|
||
except OSError:
|
||
pass
|
||
|
||
|
||
def _unmount_fileshare_token(token):
|
||
"""Hängt alle Freigaben eines Tokens aus und räumt dessen Mount-Root
|
||
weg -- von /logout (mit dem Token der eigenen Session) UND vom
|
||
Hintergrund-Sweep (mit einem beliebigen, zu alten Token) genutzt.
|
||
"umount -l" (lazy) statt eines normalen umount, damit ein zufällig
|
||
noch offener Dateihandle das Aushängen nicht mit "target busy"
|
||
blockiert -- das Verzeichnis verschwindet dann, sobald der letzte
|
||
Handle geschlossen wird, ohne dass TESM darauf warten muss."""
|
||
entry = _active_fileshare_mounts.pop(token, None)
|
||
if not entry:
|
||
return
|
||
for share in entry["shares"]:
|
||
subprocess.run(["umount", "-l", share["mount_path"]], capture_output=True, timeout=15)
|
||
try:
|
||
os.rmdir(share["mount_path"])
|
||
except OSError:
|
||
pass
|
||
mount_root = os.path.join(FILESHARE_MOUNT_ROOT, token)
|
||
try:
|
||
os.rmdir(mount_root)
|
||
except OSError:
|
||
pass
|
||
|
||
|
||
def _unmount_fileshares_for_current_session():
|
||
token = session.pop("fileshare_token", None)
|
||
if token:
|
||
_unmount_fileshare_token(token)
|
||
|
||
|
||
def _fileshare_sweep_loop():
|
||
"""Hintergrund-Thread (ein einziger Prozess dank --workers 1, siehe
|
||
tesm.service): hängt Freigaben aus, deren Session seit
|
||
FILESHARE_MAX_AGE_SECONDS besteht, unabhängig davon, ob sich der
|
||
Nutzer je explizit abgemeldet hat -- Sicherheitsnetz gegen "Tab
|
||
einfach geschlossen statt abgemeldet", da eine normale
|
||
Flask-Session (signierter Cookie) dem Server sonst keinerlei Signal
|
||
gibt, dass sie nicht mehr genutzt wird."""
|
||
while True:
|
||
time.sleep(FILESHARE_SWEEP_INTERVAL_SECONDS)
|
||
try:
|
||
now = time.time()
|
||
for token, entry in list(_active_fileshare_mounts.items()):
|
||
if now - entry["mounted_at"] > FILESHARE_MAX_AGE_SECONDS:
|
||
_unmount_fileshare_token(token)
|
||
except Exception:
|
||
app.logger.error("Fileshare-Sweep fehlgeschlagen:\n%s", traceback.format_exc())
|
||
|
||
|
||
if _IS_WEB_PROCESS:
|
||
threading.Thread(target=_fileshare_sweep_loop, daemon=True).start()
|
||
|
||
|
||
def _audit_archive_loop():
|
||
"""Prüft einmal täglich (siehe AUDIT_ARCHIVE_CHECK_INTERVAL_SECONDS), ob
|
||
die audit_log-Tabelle den Schwellenwert überschritten hat, und
|
||
archiviert dann bei Bedarf über _archive_old_audit_log_rows(). Läuft
|
||
direkt beim Start einmal sofort (statt erst nach 24h zu prüfen), damit
|
||
ein frisch aktualisierter/neu gestarteter Dienst eine bereits
|
||
übervolle Tabelle nicht einen ganzen Tag lang unangetastet lässt."""
|
||
while True:
|
||
try:
|
||
_archive_old_audit_log_rows()
|
||
except Exception:
|
||
app.logger.error("Auditlog-Archivierung fehlgeschlagen:\n%s", traceback.format_exc())
|
||
time.sleep(AUDIT_ARCHIVE_CHECK_INTERVAL_SECONDS)
|
||
|
||
|
||
if _IS_WEB_PROCESS:
|
||
threading.Thread(target=_audit_archive_loop, daemon=True).start()
|
||
|
||
|
||
def _current_fileshare_mounts():
|
||
"""Für die Fileshare-Seite und die Nav-Sichtbarkeit: gemountete
|
||
Freigaben der AKTUELLEN Session, oder eine leere Liste (lokale Nutzer,
|
||
LDAP-Nutzer ohne fileshare.view, ohne passende AD-Gruppe, oder deren
|
||
Session wurde inzwischen vom Sweep ausgehängt)."""
|
||
token = session.get("fileshare_token")
|
||
if not token:
|
||
return []
|
||
entry = _active_fileshare_mounts.get(token)
|
||
return entry["shares"] if entry else []
|
||
|
||
|
||
def _fileshare_resolve_path(label, rel_path):
|
||
"""Löst einen vom Client übergebenen relativen Pfad GEGEN DIE MOUNT-
|
||
ROOT DER EIGENEN SESSION auf und lehnt alles ab, was per Path-Traversal
|
||
(z.B. "../../etc") außerhalb davon landen würde -- der zentrale
|
||
Sicherheitspunkt des gesamten Features. Gibt den validierten absoluten
|
||
Pfad zurück, oder None bei ungültigem Share/Pfad."""
|
||
mounts = {m["label"]: m["mount_path"] for m in _current_fileshare_mounts()}
|
||
root = mounts.get(label)
|
||
if not root:
|
||
return None
|
||
root_real = os.path.realpath(root)
|
||
candidate = os.path.realpath(os.path.join(root_real, (rel_path or "").lstrip("/\\")))
|
||
if candidate != root_real and not candidate.startswith(root_real + os.sep):
|
||
return None
|
||
return candidate
|
||
|
||
|
||
def _fileshare_list_dir(abs_path):
|
||
"""Verzeichnisinhalt für die Fileshare-Seite -- Ordner zuerst, dann
|
||
Dateien, jeweils alphabetisch. _format_log_size (bereits für
|
||
NGINX-Zertifikate/Log-Dateigrößen genutzt) wird hier wiederverwendet."""
|
||
entries = []
|
||
try:
|
||
with os.scandir(abs_path) as it:
|
||
for entry in it:
|
||
try:
|
||
st = entry.stat(follow_symlinks=False)
|
||
except OSError:
|
||
continue
|
||
is_dir = entry.is_dir(follow_symlinks=False)
|
||
ext = os.path.splitext(entry.name)[1].lower()
|
||
entries.append({
|
||
"name": entry.name,
|
||
"is_dir": is_dir,
|
||
"size_str": "" if is_dir else _format_log_size(st.st_size),
|
||
"mtime_str": datetime.fromtimestamp(st.st_mtime).strftime("%d.%m.%Y %H:%M"),
|
||
"preview_kind": None if is_dir else _FILESHARE_PREVIEW_KINDS.get(ext),
|
||
})
|
||
except OSError:
|
||
pass
|
||
entries.sort(key=lambda e: (not e["is_dir"], e["name"].lower()))
|
||
return entries
|
||
|
||
|
||
def _fileshare_tree_ancestors(label, rel_path):
|
||
"""Für die Baumansicht: liefert für JEDE Ebene von der Freigabe-Wurzel
|
||
bis zum aktuellen Pfad die dortigen Unterordner (nur Ordner, keine
|
||
Dateien) -- damit der Baum serverseitig schon bis zur aktuellen
|
||
Position aufgeklappt gerendert werden kann. Alles darüber hinaus
|
||
(Geschwister-Ordner, die der Nutzer selbst aufklappt) lädt der Client
|
||
bei Bedarf über /fileshare/subfolders nach. Schlüssel ist der jeweilige
|
||
Teilpfad ("" für die Freigabe-Wurzel selbst)."""
|
||
segments = [p for p in rel_path.split("/") if p]
|
||
expanded = {}
|
||
acc = []
|
||
for depth in range(len(segments) + 1):
|
||
current_rel = "/".join(acc)
|
||
abs_path = _fileshare_resolve_path(label, current_rel)
|
||
if not abs_path or not os.path.isdir(abs_path):
|
||
break
|
||
folders = [e["name"] for e in _fileshare_list_dir(abs_path) if e["is_dir"]]
|
||
expanded[current_rel] = folders
|
||
if depth < len(segments):
|
||
acc.append(segments[depth])
|
||
return expanded
|
||
|
||
|
||
@app.route("/fileshare")
|
||
@login_required
|
||
def fileshare():
|
||
if not current_user.has_permission("fileshare.view"):
|
||
flash("Keine Berechtigung, Dateifreigaben einzusehen.", "danger")
|
||
return redirect(url_for("index"))
|
||
mounts = _current_fileshare_mounts()
|
||
if not mounts:
|
||
flash(
|
||
"Keine Dateifreigabe verfügbar -- keine passende AD-Gruppenmitgliedschaft beim letzten Login, "
|
||
"oder das Mounten ist fehlgeschlagen.",
|
||
"danger",
|
||
)
|
||
return redirect(url_for("index"))
|
||
|
||
labels = [m["label"] for m in mounts]
|
||
selected_share = request.args.get("share") or labels[0]
|
||
if selected_share not in labels:
|
||
selected_share = labels[0]
|
||
rel_path = request.args.get("path", "").strip("/\\")
|
||
|
||
abs_path = _fileshare_resolve_path(selected_share, rel_path)
|
||
if not abs_path or not os.path.isdir(abs_path):
|
||
flash("Ungültiger Pfad -- zurück zum Freigabe-Root.", "danger")
|
||
rel_path = ""
|
||
abs_path = _fileshare_resolve_path(selected_share, "")
|
||
|
||
breadcrumbs = []
|
||
acc = []
|
||
for part in [p for p in rel_path.split("/") if p]:
|
||
acc.append(part)
|
||
breadcrumbs.append({"name": part, "path": "/".join(acc)})
|
||
|
||
return render_template(
|
||
"fileshare.html",
|
||
shares=labels, selected_share=selected_share, rel_path=rel_path, breadcrumbs=breadcrumbs,
|
||
entries=_fileshare_list_dir(abs_path) if abs_path else [],
|
||
can_create=current_user.has_permission("fileshare.create"),
|
||
can_edit=current_user.has_permission("fileshare.edit"),
|
||
tree_expanded=_fileshare_tree_ancestors(selected_share, rel_path) if abs_path else {"": []},
|
||
)
|
||
|
||
|
||
@app.route("/fileshare/subfolders")
|
||
@login_required
|
||
def fileshare_subfolders():
|
||
"""Lazy-Nachladen EINER Baumebene (nur Unterordner) für die Baum-
|
||
Navigation der Fileshare-Seite -- die Wurzel-bis-aktuell-Kette liefert
|
||
die Hauptroute bereits serverseitig mit (siehe tree_expanded), alles
|
||
andere (vom Nutzer aufgeklappte Geschwisterordner) holt der Client
|
||
gezielt über diese Route nach, ohne die Freigabe komplett zu durchlaufen."""
|
||
if not current_user.has_permission("fileshare.view"):
|
||
return jsonify({"error": "Keine Berechtigung."}), 403
|
||
abs_path = _fileshare_resolve_path(request.args.get("share", ""), request.args.get("path", ""))
|
||
if not abs_path or not os.path.isdir(abs_path):
|
||
return jsonify({"error": "Ungültiger Pfad."}), 404
|
||
return jsonify({"folders": [e["name"] for e in _fileshare_list_dir(abs_path) if e["is_dir"]]})
|
||
|
||
|
||
@app.route("/fileshare/view")
|
||
@login_required
|
||
def fileshare_view():
|
||
"""Inline-Vorschau (Content-Disposition NICHT 'attachment', anders als
|
||
/fileshare/download) für eine feste Positivliste von Dateitypen (siehe
|
||
_FILESHARE_PREVIEW_MIME) -- alles andere liefert bewusst 415 statt mit
|
||
geratenem Content-Type etwas potenziell Falsches inline auszuliefern.
|
||
nosniff + eine restriktive CSP zusätzlich als Tiefenverteidigung, falls
|
||
diese URL direkt (statt über das Vorschau-Modal) aufgerufen wird."""
|
||
if not current_user.has_permission("fileshare.view"):
|
||
return "Keine Berechtigung.", 403
|
||
abs_path = _fileshare_resolve_path(request.args.get("share", ""), request.args.get("path", ""))
|
||
if not abs_path or not os.path.isfile(abs_path):
|
||
return "Datei nicht gefunden.", 404
|
||
ext = os.path.splitext(abs_path)[1].lower()
|
||
mimetype = _FILESHARE_PREVIEW_MIME.get(ext)
|
||
if not mimetype:
|
||
return "Vorschau für diesen Dateityp nicht verfügbar.", 415
|
||
resp = send_file(abs_path, as_attachment=False, mimetype=mimetype, conditional=True)
|
||
resp.headers["X-Content-Type-Options"] = "nosniff"
|
||
resp.headers["Content-Security-Policy"] = "default-src 'none'; style-src 'unsafe-inline'; sandbox"
|
||
return resp
|
||
|
||
|
||
@app.route("/fileshare/download")
|
||
@login_required
|
||
def fileshare_download():
|
||
if not current_user.has_permission("fileshare.view"):
|
||
return "Keine Berechtigung.", 403
|
||
abs_path = _fileshare_resolve_path(request.args.get("share", ""), request.args.get("path", ""))
|
||
if not abs_path or not os.path.isfile(abs_path):
|
||
return "Datei nicht gefunden.", 404
|
||
return send_file(abs_path, as_attachment=True, download_name=os.path.basename(abs_path))
|
||
|
||
|
||
@app.route("/fileshare/upload", methods=["POST"])
|
||
@login_required
|
||
def fileshare_upload():
|
||
share = request.form.get("share", "")
|
||
rel_path = request.form.get("path", "")
|
||
if not current_user.has_permission("fileshare.create"):
|
||
flash("Keine Berechtigung, Dateien hochzuladen.", "danger")
|
||
return redirect(url_for("fileshare", share=share, path=rel_path))
|
||
|
||
abs_dir = _fileshare_resolve_path(share, rel_path)
|
||
# request.files.getlist() statt .get(): das Upload-Feld erlaubt jetzt
|
||
# Mehrfachauswahl (name="file" multiple) -- ein einzelner Dateiauswahl-
|
||
# Dialog liefert dann mehrere Files unter demselben Feldnamen, klassisch
|
||
# eine Datei liefert genauso eine Liste mit einem Element.
|
||
files = [f for f in request.files.getlist("file") if f and f.filename]
|
||
if not abs_dir or not os.path.isdir(abs_dir) or not files:
|
||
flash("Ungültiges Ziel oder keine Datei ausgewählt.", "danger")
|
||
else:
|
||
uploaded, rejected = [], []
|
||
for file in files:
|
||
filename = secure_filename(file.filename)
|
||
dest = os.path.join(abs_dir, filename) if filename else None
|
||
if not filename or os.path.dirname(os.path.realpath(dest)) != os.path.realpath(abs_dir):
|
||
rejected.append(file.filename)
|
||
continue
|
||
file.save(dest)
|
||
uploaded.append(filename)
|
||
if uploaded:
|
||
log_action("fileshare.upload", share, f"{rel_path}/".strip("/") + f" ({len(uploaded)} Datei(en): {', '.join(uploaded)})")
|
||
if len(uploaded) == 1:
|
||
flash(f"„{uploaded[0]}“ hochgeladen.", "success")
|
||
else:
|
||
flash(f"{len(uploaded)} Dateien hochgeladen: {', '.join(uploaded)}.", "success")
|
||
if rejected:
|
||
flash(f"Ungültiger Dateiname, übersprungen: {', '.join(rejected)}.", "danger")
|
||
return redirect(url_for("fileshare", share=share, path=rel_path))
|
||
|
||
|
||
@app.route("/fileshare/mkdir", methods=["POST"])
|
||
@login_required
|
||
def fileshare_mkdir():
|
||
share = request.form.get("share", "")
|
||
rel_path = request.form.get("path", "")
|
||
if not current_user.has_permission("fileshare.create"):
|
||
flash("Keine Berechtigung, Ordner anzulegen.", "danger")
|
||
return redirect(url_for("fileshare", share=share, path=rel_path))
|
||
|
||
name = secure_filename(request.form.get("name", "").strip())
|
||
abs_dir = _fileshare_resolve_path(share, rel_path)
|
||
if not abs_dir or not name:
|
||
flash("Ungültiger Ordnername.", "danger")
|
||
else:
|
||
try:
|
||
os.mkdir(os.path.join(abs_dir, name))
|
||
log_action("fileshare.mkdir", share, f"{rel_path}/{name}".strip("/"))
|
||
flash(f"Ordner „{name}“ angelegt.", "success")
|
||
except OSError as e:
|
||
flash(f"Ordner konnte nicht angelegt werden: {e}", "danger")
|
||
return redirect(url_for("fileshare", share=share, path=rel_path))
|
||
|
||
|
||
def _fileshare_resolve_child(share, rel_path, name):
|
||
"""Löst EINEN Kind-Eintrag (Datei oder Ordner) von rel_path auf und
|
||
stellt zusätzlich sicher, dass er auch tatsächlich direkt DARIN liegt
|
||
(per os.sep-Präfix-Vergleich des bereits Path-Traversal-geprüften
|
||
_fileshare_resolve_path) -- von delete/delete-multi/download-multi
|
||
gemeinsam genutzt. None bei jedem ungültigen Fall, wirft nie."""
|
||
if not name:
|
||
return None
|
||
parent_abs = _fileshare_resolve_path(share, rel_path)
|
||
target_abs = _fileshare_resolve_path(share, f"{rel_path}/{name}".strip("/"))
|
||
if not parent_abs or not target_abs or not target_abs.startswith(parent_abs + os.sep):
|
||
return None
|
||
return target_abs
|
||
|
||
|
||
@app.route("/fileshare/delete", methods=["POST"])
|
||
@login_required
|
||
def fileshare_delete():
|
||
share = request.form.get("share", "")
|
||
rel_path = request.form.get("path", "")
|
||
if not current_user.has_permission("fileshare.edit"):
|
||
flash("Keine Berechtigung, Dateien/Ordner zu löschen.", "danger")
|
||
return redirect(url_for("fileshare", share=share, path=rel_path))
|
||
|
||
name = request.form.get("name", "")
|
||
target_abs = _fileshare_resolve_child(share, rel_path, name)
|
||
if not target_abs:
|
||
flash("Ungültiges Ziel.", "danger")
|
||
else:
|
||
try:
|
||
if os.path.isdir(target_abs):
|
||
shutil.rmtree(target_abs)
|
||
else:
|
||
os.remove(target_abs)
|
||
log_action("fileshare.delete", share, f"{rel_path}/{name}".strip("/"))
|
||
flash(f"„{name}“ gelöscht.", "success")
|
||
except OSError as e:
|
||
flash(f"Löschen fehlgeschlagen: {e}", "danger")
|
||
return redirect(url_for("fileshare", share=share, path=rel_path))
|
||
|
||
|
||
@app.route("/fileshare/delete-multi", methods=["POST"])
|
||
@login_required
|
||
def fileshare_delete_multi():
|
||
"""Wie /fileshare/delete, nur für eine per Checkbox ausgewählte Menge
|
||
an Dateien/Ordnern auf einmal (Mehrfachauswahl in der Tabelle)."""
|
||
share = request.form.get("share", "")
|
||
rel_path = request.form.get("path", "")
|
||
if not current_user.has_permission("fileshare.edit"):
|
||
flash("Keine Berechtigung, Dateien/Ordner zu löschen.", "danger")
|
||
return redirect(url_for("fileshare", share=share, path=rel_path))
|
||
|
||
names = [n for n in request.form.getlist("names") if n]
|
||
if not names:
|
||
flash("Keine Elemente ausgewählt.", "danger")
|
||
return redirect(url_for("fileshare", share=share, path=rel_path))
|
||
|
||
deleted, failed = [], []
|
||
for name in names:
|
||
target_abs = _fileshare_resolve_child(share, rel_path, name)
|
||
if not target_abs:
|
||
failed.append(name)
|
||
continue
|
||
try:
|
||
if os.path.isdir(target_abs):
|
||
shutil.rmtree(target_abs)
|
||
else:
|
||
os.remove(target_abs)
|
||
deleted.append(name)
|
||
except OSError:
|
||
failed.append(name)
|
||
|
||
if deleted:
|
||
log_action("fileshare.delete", share, f"{rel_path}/".strip("/") + f" ({len(deleted)} Element(e): {', '.join(deleted)})")
|
||
flash(f"{len(deleted)} Element(e) gelöscht: {', '.join(deleted)}.", "success")
|
||
if failed:
|
||
flash(f"Löschen fehlgeschlagen für: {', '.join(failed)}.", "danger")
|
||
return redirect(url_for("fileshare", share=share, path=rel_path))
|
||
|
||
|
||
@app.route("/fileshare/download-multi", methods=["POST"])
|
||
@login_required
|
||
def fileshare_download_multi():
|
||
"""Baut eine Auswahl aus mehreren Dateien/Ordnern zu EINEM ZIP zusammen
|
||
-- vermeidet, dass der Browser bei vielen einzelnen Downloads auf
|
||
einmal blockiert/nachfragt, und ist die auch anderswo (Drive, Nextcloud
|
||
etc.) übliche Erwartung bei Mehrfachauswahl. Ordner werden rekursiv mit
|
||
aufgenommen (relativer Pfad innerhalb des Ordners als Archivpfad)."""
|
||
share = request.form.get("share", "")
|
||
rel_path = request.form.get("path", "")
|
||
if not current_user.has_permission("fileshare.view"):
|
||
return "Keine Berechtigung.", 403
|
||
|
||
names = [n for n in request.form.getlist("names") if n]
|
||
if not names:
|
||
flash("Keine Elemente ausgewählt.", "danger")
|
||
return redirect(url_for("fileshare", share=share, path=rel_path))
|
||
|
||
targets = []
|
||
skipped = []
|
||
for name in names:
|
||
target_abs = _fileshare_resolve_child(share, rel_path, name)
|
||
if not target_abs or not os.path.exists(target_abs):
|
||
skipped.append(name)
|
||
continue
|
||
targets.append((name, target_abs))
|
||
if not targets:
|
||
flash("Keines der ausgewählten Elemente konnte gefunden werden.", "danger")
|
||
return redirect(url_for("fileshare", share=share, path=rel_path))
|
||
if skipped:
|
||
flash(f"Übersprungen (nicht gefunden): {', '.join(skipped)}.", "danger")
|
||
|
||
buffer = io.BytesIO()
|
||
with zipfile.ZipFile(buffer, "w", zipfile.ZIP_DEFLATED) as zf:
|
||
for name, target_abs in targets:
|
||
if os.path.isdir(target_abs):
|
||
for root, _dirs, files in os.walk(target_abs):
|
||
for fname in files:
|
||
full = os.path.join(root, fname)
|
||
arcname = os.path.join(name, os.path.relpath(full, target_abs))
|
||
zf.write(full, arcname)
|
||
else:
|
||
zf.write(target_abs, name)
|
||
buffer.seek(0)
|
||
|
||
if len(targets) == 1:
|
||
base_name, _ext = os.path.splitext(targets[0][0])
|
||
zip_name = secure_filename(base_name if not os.path.isdir(targets[0][1]) else targets[0][0]) or "Download"
|
||
else:
|
||
zip_name = secure_filename(f"{share}-Auswahl") or "Download"
|
||
log_action("fileshare.download", share, f"{len(targets)} Element(e) als ZIP: {', '.join(n for n, _ in targets)}")
|
||
return send_file(buffer, as_attachment=True, download_name=f"{zip_name}.zip", mimetype="application/zip")
|
||
|
||
|
||
@app.route("/fileshare/rename", methods=["POST"])
|
||
@login_required
|
||
def fileshare_rename():
|
||
share = request.form.get("share", "")
|
||
rel_path = request.form.get("path", "")
|
||
if not current_user.has_permission("fileshare.edit"):
|
||
flash("Keine Berechtigung zum Umbenennen.", "danger")
|
||
return redirect(url_for("fileshare", share=share, path=rel_path))
|
||
|
||
old_name = request.form.get("old_name", "")
|
||
new_name = secure_filename(request.form.get("new_name", "").strip())
|
||
parent_abs = _fileshare_resolve_path(share, rel_path)
|
||
old_abs = _fileshare_resolve_path(share, f"{rel_path}/{old_name}".strip("/")) if old_name else None
|
||
if not parent_abs or not old_abs or not new_name:
|
||
flash("Ungültige Angabe.", "danger")
|
||
else:
|
||
new_abs = os.path.join(parent_abs, new_name)
|
||
if os.path.dirname(os.path.realpath(new_abs)) != os.path.realpath(parent_abs):
|
||
flash("Ungültiger neuer Name.", "danger")
|
||
else:
|
||
try:
|
||
os.rename(old_abs, new_abs)
|
||
log_action("fileshare.rename", share, f"{old_name} -> {new_name}")
|
||
flash(f"„{old_name}“ umbenannt zu „{new_name}“.", "success")
|
||
except OSError as e:
|
||
flash(f"Umbenennen fehlgeschlagen: {e}", "danger")
|
||
return redirect(url_for("fileshare", share=share, path=rel_path))
|
||
|
||
|
||
@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()
|
||
|
||
user = conn.execute(
|
||
"SELECT * FROM users WHERE username = ? AND deleted_at IS NULL", (username,)
|
||
).fetchone()
|
||
|
||
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()
|
||
|
||
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"]:
|
||
conn.close()
|
||
flash("Dieses Konto ist gesperrt.", "danger")
|
||
return render_template("login.html")
|
||
|
||
if user and user["auth_source"] == "ldap":
|
||
ok, info = _ldap_authenticate(user["username"], password)
|
||
if ok:
|
||
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()
|
||
logged_in_user = _build_user(refreshed)
|
||
login_user(logged_in_user)
|
||
_mount_fileshares_for_login(
|
||
logged_in_user, info.get("username") or username, password, info.get("fileshares") or [],
|
||
)
|
||
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:
|
||
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")
|
||
logged_in_user = _build_user(new_user)
|
||
login_user(logged_in_user)
|
||
_mount_fileshares_for_login(
|
||
logged_in_user, canonical_username, password, info.get("fileshares") or [],
|
||
)
|
||
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():
|
||
_unmount_fileshares_for_current_session()
|
||
logout_user()
|
||
return redirect(url_for("index"))
|
||
|
||
|
||
|
||
@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:
|
||
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"))
|
||
|
||
|
||
|
||
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
|
||
|
||
|
||
LIVE_LOG_TAIL_LINES = 300
|
||
|
||
|
||
def _tail_lines(path, n=LIVE_LOG_TAIL_LINES, chunk_size=65536):
|
||
"""Liest effizient nur die letzten n Zeilen einer Datei, ohne sie
|
||
komplett einzulesen -- sucht dazu vom Dateiende rückwärts in Blöcken,
|
||
bis n Zeilenumbrüche gefunden wurden oder der Dateianfang erreicht ist.
|
||
Auf größeren Umgebungen (viele Geräte, kurzes Prüfintervall) kann
|
||
live.log zwischen zwei logrotate-Läufen mehrere MB groß werden; ein
|
||
komplettes Einlesen bei jedem Seitenaufruf/Live-Poll hat dort spürbare
|
||
Ladezeiten verursacht -- das war der eigentliche Auslöser dieser
|
||
Funktion."""
|
||
with open(path, "rb") as f:
|
||
f.seek(0, os.SEEK_END)
|
||
remaining = f.tell()
|
||
data = b""
|
||
while remaining > 0 and data.count(b"\n") <= n:
|
||
read_size = min(chunk_size, remaining)
|
||
remaining -= read_size
|
||
f.seek(remaining)
|
||
data = f.read(read_size) + data
|
||
text = data.decode("utf-8", errors="replace")
|
||
lines = text.split("\n")
|
||
return "\n".join(lines[-n:]) if len(lines) > n else text
|
||
|
||
|
||
def _count_lines(path):
|
||
"""Schnelle Zeilenzählung über rohe Byte-Chunks (nur Zählen von \\n,
|
||
kein Aufbau einer Python-Zeilenliste) -- für die "letzte n von insgesamt
|
||
X Zeilen"-Anzeige, ohne den Performance-Vorteil von _tail_lines wieder
|
||
durch ein volles Einlesen zunichte zu machen."""
|
||
try:
|
||
count = 0
|
||
with open(path, "rb") as f:
|
||
for chunk in iter(lambda: f.read(1024 * 1024), b""):
|
||
count += chunk.count(b"\n")
|
||
return count
|
||
except OSError:
|
||
return None
|
||
|
||
|
||
def _format_log_size(num_bytes):
|
||
size = float(num_bytes)
|
||
for unit in ("B", "KB", "MB", "GB"):
|
||
if size < 1024 or unit == "GB":
|
||
return f"{size:.0f} {unit}" if unit == "B" else f"{size:.1f} {unit}"
|
||
size /= 1024
|
||
return f"{size:.1f} GB"
|
||
|
||
|
||
_LOG_HISTORY_FILE_RE = re.compile(r"^live\.log\.([1-9][0-9]*)$")
|
||
_LOG_HISTORY_DATE_FMT = "%d.%m.%Y %H:%M"
|
||
|
||
|
||
def _live_log_history_files():
|
||
"""Alle über die Verlauf-Seite auswählbaren Stände des Live-Logs --
|
||
generation 0 ist die aktuelle, noch laufende live.log selbst, 1 die
|
||
zuletzt rotierte Kopie, 2 der Lauf davor usw. (copytruncate ohne
|
||
compress, siehe _write_logrotate_config, daher reine Textdateien ohne
|
||
.gz), sortiert von neu (0) nach alt.
|
||
|
||
Jede Kopie deckt den Zeitraum zwischen ZWEI Rotationen ab: ihr eigenes
|
||
mtime ist der Zeitpunkt, an dem ihr Inhalt eingefroren wurde (Ende des
|
||
Zeitraums), das mtime der NÄCHSTälteren Generation der Beginn -- ein
|
||
reines rename() bei jeder weiteren Rotation ändert mtime nicht, jede
|
||
Generation behält also dauerhaft den Zeitpunkt ihrer eigenen
|
||
Entstehung. Für die aktuelle live.log gibt es kein eigenes "Ende",
|
||
dort wird stattdessen "laufend" angezeigt."""
|
||
results = []
|
||
try:
|
||
if os.path.isfile(TESM_LIVE_LOG_PATH):
|
||
mtime = os.path.getmtime(TESM_LIVE_LOG_PATH)
|
||
size = os.path.getsize(TESM_LIVE_LOG_PATH)
|
||
results.append({
|
||
"filename": "live.log", "generation": 0,
|
||
"mtime": mtime, "size_str": _format_log_size(size),
|
||
})
|
||
except OSError:
|
||
pass
|
||
|
||
try:
|
||
entries = os.listdir(TESM_LOG_DIR)
|
||
except OSError:
|
||
entries = []
|
||
for entry in entries:
|
||
m = _LOG_HISTORY_FILE_RE.match(entry)
|
||
if not m:
|
||
continue
|
||
full_path = os.path.join(TESM_LOG_DIR, entry)
|
||
if not os.path.isfile(full_path):
|
||
continue
|
||
try:
|
||
mtime = os.path.getmtime(full_path)
|
||
size = os.path.getsize(full_path)
|
||
except OSError:
|
||
continue
|
||
results.append({
|
||
"filename": entry, "generation": int(m.group(1)),
|
||
"mtime": mtime, "size_str": _format_log_size(size),
|
||
})
|
||
results.sort(key=lambda r: r["generation"])
|
||
|
||
for i, entry in enumerate(results):
|
||
older = results[i + 1] if i + 1 < len(results) else None
|
||
start_str = datetime.fromtimestamp(older["mtime"]).strftime(_LOG_HISTORY_DATE_FMT) if older else None
|
||
if entry["generation"] == 0:
|
||
end_str = "laufend"
|
||
else:
|
||
end_str = datetime.fromtimestamp(entry["mtime"]).strftime(_LOG_HISTORY_DATE_FMT)
|
||
entry["range_label"] = f"{start_str or '?'} – {end_str}"
|
||
return results
|
||
|
||
|
||
def _resolve_log_history_file(filename):
|
||
"""Validiert einen vom Client übergebenen Dateinamen (?file=...) gegen
|
||
die tatsächlich vorhandene aktuelle live.log bzw. rotierte Kopien davon
|
||
in TESM_LOG_DIR -- verhindert Path-Traversal bzw. beliebiges
|
||
Datei-Lesen über den Query-Parameter. Gibt den vollen Pfad oder None
|
||
zurück."""
|
||
if filename == "live.log":
|
||
return TESM_LIVE_LOG_PATH if os.path.isfile(TESM_LIVE_LOG_PATH) else None
|
||
if not filename or not _LOG_HISTORY_FILE_RE.match(filename):
|
||
return None
|
||
full_path = os.path.join(TESM_LOG_DIR, filename)
|
||
if os.path.dirname(os.path.abspath(full_path)) != os.path.abspath(TESM_LOG_DIR):
|
||
return None
|
||
if not os.path.isfile(full_path):
|
||
return None
|
||
return full_path
|
||
|
||
|
||
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
|
||
|
||
|
||
|
||
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
|
||
|
||
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
|
||
|
||
|
||
@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")
|
||
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,
|
||
}
|
||
|
||
|
||
|
||
|
||
NETWORK_REVERT_SECONDS = 45
|
||
_pending_network_revert = {}
|
||
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
|
||
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:
|
||
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]
|
||
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":
|
||
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":
|
||
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:
|
||
eth_cfg["dhcp4-overrides"] = {"use-dns": False}
|
||
if dns_list:
|
||
eth_cfg["nameservers"] = {"addresses": dns_list}
|
||
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}}
|
||
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:
|
||
try:
|
||
os.remove(NETPLAN_CONFIG_PATH)
|
||
except OSError:
|
||
pass
|
||
subprocess.run(["netplan", "apply"], timeout=20)
|
||
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)
|
||
|
||
|
||
# ============================================================================
|
||
# NGINX (Systemeinstellungen -> NGINX)
|
||
# Verwaltet den kompletten Reverse-Proxy: Domain/Ports, SSL/HSTS,
|
||
# Zertifikat (Upload ODER Let's Encrypt).
|
||
#
|
||
# Let's Encrypt läuft bewusst über "certbot certonly --webroot", NICHT über
|
||
# das certbot-nginx-Plugin -- das Plugin würde NGINX_SITE_CONFIG_PATH
|
||
# direkt editieren (eigene server-Blöcke/Zeilen einfügen) und diese
|
||
# Änderungen bei der nächsten Änderung über diese Seite (die die Datei
|
||
# komplett neu schreibt, siehe _render_nginx_config) wieder verlieren.
|
||
# Mit der webroot-Methode bleibt _render_nginx_config die EINZIGE Quelle
|
||
# für den Datei-Inhalt; certbot legt nur Dateien unter TESM_ACME_WEBROOT
|
||
# ab und liest/schreibt sein Zertifikat unter /etc/letsencrypt/live/.
|
||
# ============================================================================
|
||
|
||
NGINX_REVERT_SECONDS = 45
|
||
_pending_nginx_revert = {}
|
||
|
||
_DOMAIN_RE = re.compile(r"^(?=.{1,253}$)([a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\.)+[a-zA-Z]{2,63}$")
|
||
|
||
|
||
def _valid_server_name(name):
|
||
""""_" (nginx-Catch-all, kein bestimmter Hostname -- Standard für rein
|
||
interne Instanzen ohne eigene Domain) oder eine plausibel geformte
|
||
FQDN."""
|
||
return name == "_" or bool(_DOMAIN_RE.match(name))
|
||
|
||
|
||
def _valid_port(value):
|
||
try:
|
||
n = int(value)
|
||
except (TypeError, ValueError):
|
||
return False
|
||
return 1 <= n <= 65535 and n != 5000
|
||
|
||
|
||
_NGINX_PROXY_LOCATIONS = """ location /ws/ {
|
||
proxy_pass http://127.0.0.1:5000;
|
||
proxy_http_version 1.1;
|
||
proxy_set_header Upgrade $http_upgrade;
|
||
proxy_set_header Connection $connection_upgrade;
|
||
proxy_set_header Host $host;
|
||
proxy_set_header X-Real-IP $remote_addr;
|
||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||
proxy_set_header X-Forwarded-Proto $scheme;
|
||
proxy_read_timeout 3600s;
|
||
proxy_send_timeout 3600s;
|
||
}
|
||
|
||
location / {
|
||
# nginx' Standard (1m) reicht für Fileshare-Uploads nicht -- etwas
|
||
# großzügiger als Flasks eigenes MAX_CONTENT_LENGTH (siehe app.py),
|
||
# damit bei einer knapp 15MB großen Datei nginx nicht schon vor
|
||
# Flask mit seiner eigenen, unschöneren 413-Seite abbricht.
|
||
client_max_body_size 16m;
|
||
proxy_pass http://127.0.0.1:5000;
|
||
proxy_set_header Host $host;
|
||
proxy_set_header X-Real-IP $remote_addr;
|
||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||
proxy_set_header X-Forwarded-Proto $scheme;
|
||
}
|
||
|
||
location /static/ {
|
||
alias /srv/tesm/static/;
|
||
expires 7d;
|
||
}
|
||
"""
|
||
|
||
|
||
def _render_nginx_config(server_name, http_port, https_port, ssl_enabled, hsts_enabled, cert_path, key_path):
|
||
"""Erzeugt den kompletten Inhalt von NGINX_SITE_CONFIG_PATH. Die
|
||
Reverse-Proxy-Regeln (WebSocket-Terminal, Haupt-App, statische Dateien)
|
||
sind bei aktivem SSL identisch, nur hinter einem HTTPS-Block mit
|
||
Zertifikat/HSTS statt direkt hinter dem HTTP-Port -- der HTTP-Port
|
||
leitet dann nur noch auf https weiter, damit ein alter Bookmark/Link
|
||
nicht plötzlich ins Leere läuft. Die ACME-Challenge-Location wird
|
||
IMMER mitgeschrieben (auch ohne aktives SSL) -- eine Let's-Encrypt-
|
||
(Erst-)Anforderung braucht sie schon VOR dem ersten SSL-Aktivieren,
|
||
eine Verlängerung später ebenso. ACME HTTP-01 verbindet sich immer auf
|
||
Port 80 (protokollbedingt, nicht konfigurierbar) -- weicht der
|
||
eingestellte HTTP-Port davon ab, wird zusätzlich ein minimaler, nur
|
||
dafür zuständiger Port-80-Block ergänzt, damit Let's Encrypt trotzdem
|
||
funktioniert."""
|
||
header = 'map $http_upgrade $connection_upgrade {\n default upgrade;\n "" close;\n}\n\n'
|
||
acme_location = (
|
||
" location /.well-known/acme-challenge/ {\n"
|
||
f" root {TESM_ACME_WEBROOT};\n"
|
||
" try_files $uri =404;\n"
|
||
" }\n\n"
|
||
)
|
||
|
||
extra_acme_block = ""
|
||
if str(http_port) != "80":
|
||
extra_acme_block = (
|
||
"server {\n listen 80;\n server_name _;\n\n"
|
||
+ acme_location
|
||
+ " location / { return 404; }\n}\n\n"
|
||
)
|
||
|
||
if not ssl_enabled:
|
||
return (
|
||
header + extra_acme_block
|
||
+ f"server {{\n listen {http_port};\n server_name {server_name};\n\n"
|
||
+ acme_location
|
||
+ _NGINX_PROXY_LOCATIONS
|
||
+ "}\n"
|
||
)
|
||
|
||
hsts_line = ""
|
||
if hsts_enabled:
|
||
hsts_line = ' add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;\n\n'
|
||
redirect_target = "https://$host$request_uri" if str(https_port) == "443" else f"https://$host:{https_port}$request_uri"
|
||
return (
|
||
header + extra_acme_block
|
||
+ f"server {{\n listen {http_port};\n server_name {server_name};\n\n"
|
||
+ acme_location
|
||
+ f" location / {{\n return 301 {redirect_target};\n }}\n"
|
||
+ "}\n\n"
|
||
+ f"server {{\n listen {https_port} ssl;\n server_name {server_name};\n\n"
|
||
+ f" ssl_certificate {cert_path};\n"
|
||
+ f" ssl_certificate_key {key_path};\n\n"
|
||
+ hsts_line
|
||
+ _NGINX_PROXY_LOCATIONS
|
||
+ "}\n"
|
||
)
|
||
|
||
|
||
def _current_cert_paths():
|
||
"""(cert_path, key_path) je nach aktiver Zertifikatsquelle (Setting
|
||
ssl_source: "upload" oder "letsencrypt"), oder (None, None) wenn
|
||
keins vorhanden ist."""
|
||
if get_setting("ssl_source", "upload") == "letsencrypt":
|
||
domain = get_setting("nginx_server_name", "_")
|
||
cert, key = _le_cert_path(domain), _le_key_path(domain)
|
||
if os.path.isfile(cert) and os.path.isfile(key):
|
||
return cert, key
|
||
return None, None
|
||
if os.path.isfile(TESM_SSL_CERT_PATH) and os.path.isfile(TESM_SSL_KEY_PATH):
|
||
return TESM_SSL_CERT_PATH, TESM_SSL_KEY_PATH
|
||
return None, None
|
||
|
||
|
||
def _apply_nginx_config(server_name, http_port, https_port, ssl_enabled, hsts_enabled):
|
||
"""Schreibt die neue nginx-Konfiguration, validiert sie (nginx -t) und
|
||
lädt nginx erst danach neu -- schlägt Validierung ODER Reload fehl,
|
||
wird der vorherige Inhalt sofort wiederhergestellt (Selbstschutz gegen
|
||
einen Tippfehler/ein defektes Zertifikat, das sonst den kompletten
|
||
Reverse-Proxy lahmlegen würde). Gibt (ok, fehlermeldung, alter_inhalt)
|
||
zurück -- den alten Inhalt braucht der Aufrufer zusätzlich für das
|
||
zeitgesteuerte Rückroll-Sicherheitsnetz, siehe NGINX_REVERT_SECONDS."""
|
||
try:
|
||
with open(NGINX_SITE_CONFIG_PATH, encoding="utf-8") as f:
|
||
previous = f.read()
|
||
except OSError:
|
||
previous = None
|
||
|
||
cert_path = key_path = None
|
||
if ssl_enabled:
|
||
cert_path, key_path = _current_cert_paths()
|
||
if not cert_path:
|
||
return False, "Kein Zertifikat verfügbar.", previous
|
||
|
||
new_content = _render_nginx_config(server_name, http_port, https_port, ssl_enabled, hsts_enabled, cert_path, key_path)
|
||
try:
|
||
with open(NGINX_SITE_CONFIG_PATH, "w", encoding="utf-8") as f:
|
||
f.write(new_content)
|
||
except OSError as e:
|
||
return False, f"Konfiguration konnte nicht geschrieben werden: {e}", previous
|
||
|
||
ok, out = _dhcp_run_privileged(["nginx", "-t"], timeout=10)
|
||
if ok:
|
||
ok, out = _dhcp_run_privileged(["systemctl", "reload", "nginx"], timeout=10)
|
||
|
||
if not ok:
|
||
if previous is not None:
|
||
try:
|
||
with open(NGINX_SITE_CONFIG_PATH, "w", encoding="utf-8") as f:
|
||
f.write(previous)
|
||
subprocess.run(["systemctl", "reload", "nginx"], capture_output=True, timeout=10)
|
||
except OSError:
|
||
pass
|
||
return False, out, previous
|
||
|
||
return True, "", previous
|
||
|
||
|
||
def _revert_nginx_config(token):
|
||
"""Timer-Callback analog zu _revert_network_config: keine Bestätigung
|
||
binnen NGINX_REVERT_SECONDS -> vorherige nginx-Konfiguration UND alle
|
||
zugehörigen Einstellungen (Domain, Ports, SSL, HSTS) wiederherstellen."""
|
||
entry = _pending_nginx_revert.get(token)
|
||
if not entry:
|
||
return
|
||
try:
|
||
if entry["previous"] is not None:
|
||
with open(NGINX_SITE_CONFIG_PATH, "w", encoding="utf-8") as f:
|
||
f.write(entry["previous"])
|
||
subprocess.run(["nginx", "-t"], capture_output=True, timeout=10)
|
||
subprocess.run(["systemctl", "reload", "nginx"], capture_output=True, timeout=10)
|
||
for key, value in entry["prev_settings"].items():
|
||
set_setting(key, value)
|
||
log_action_system("settings.nginx_revert", "nginx", "automatisch nach Timeout zurückgerollt")
|
||
except Exception as e:
|
||
app.logger.error("nginx auto-revert failed: %s", e)
|
||
finally:
|
||
_pending_nginx_revert.pop(token, None)
|
||
|
||
|
||
def _parse_cert(cert_bytes):
|
||
"""Lädt ein PEM-Zertifikat und liefert sowohl das cryptography-Objekt
|
||
(für den Schlüssel-Abgleich) als auch die für die UI relevanten Felder
|
||
(Subject, Aussteller, Ablaufdatum)."""
|
||
cert = x509.load_pem_x509_certificate(cert_bytes)
|
||
try:
|
||
not_after = cert.not_valid_after_utc.replace(tzinfo=None)
|
||
except AttributeError:
|
||
not_after = cert.not_valid_after
|
||
return cert, {
|
||
"subject": cert.subject.rfc4514_string(),
|
||
"issuer": cert.issuer.rfc4514_string(),
|
||
"not_after": not_after,
|
||
}
|
||
|
||
|
||
def _validate_cert_key_pair(cert_bytes, key_bytes):
|
||
"""Prüft ein hochgeladenes Zertifikat+Key-Paar VOR dem Speichern --
|
||
verhindert, dass ein kaputtes oder nicht zusammenpassendes Paar erst
|
||
beim tatsächlichen SSL-Aktivieren auffällt (mit dem entsprechenden
|
||
Ausfallrisiko für den Reverse-Proxy). Der Schlüssel-Abgleich
|
||
funktioniert algorithmus-unabhängig (RSA/EC/...), da nur die
|
||
öffentlichen Schlüssel als DER-Bytes verglichen werden, nicht
|
||
algorithmus-spezifische Felder. Gibt (ok, fehlermeldung, info-dict)
|
||
zurück."""
|
||
try:
|
||
cert, info = _parse_cert(cert_bytes)
|
||
except ValueError as e:
|
||
return False, f"Datei ist kein gültiges PEM-Zertifikat ({e}).", {}
|
||
|
||
if info["not_after"] < datetime.utcnow():
|
||
return False, f"Zertifikat ist bereits abgelaufen (gültig bis {info['not_after']:%d.%m.%Y}).", info
|
||
|
||
try:
|
||
private_key = serialization.load_pem_private_key(key_bytes, password=None)
|
||
except (ValueError, TypeError) as e:
|
||
return False, f"Datei ist kein gültiger, unverschlüsselter privater Schlüssel ({e}).", info
|
||
|
||
cert_pub = cert.public_key().public_bytes(serialization.Encoding.DER, serialization.PublicFormat.SubjectPublicKeyInfo)
|
||
key_pub = private_key.public_key().public_bytes(serialization.Encoding.DER, serialization.PublicFormat.SubjectPublicKeyInfo)
|
||
if cert_pub != key_pub:
|
||
return False, "Zertifikat und privater Schlüssel passen nicht zusammen.", info
|
||
|
||
return True, "", info
|
||
|
||
|
||
def _current_cert_info():
|
||
"""Für die NGINX-Seite: Status des aktuell aktiven Zertifikats (Upload
|
||
oder Let's Encrypt, je nach ssl_source) inkl. verbleibender
|
||
Gültigkeitsdauer."""
|
||
cert_path, _key_path = _current_cert_paths()
|
||
if not cert_path:
|
||
return None
|
||
try:
|
||
with open(cert_path, "rb") as f:
|
||
_, info = _parse_cert(f.read())
|
||
info["days_left"] = (info["not_after"] - datetime.utcnow()).days
|
||
info["source"] = get_setting("ssl_source", "upload")
|
||
return info
|
||
except (OSError, ValueError):
|
||
return None
|
||
|
||
|
||
def _request_letsencrypt_cert(domain, email=None):
|
||
"""Fordert per certbot (webroot-Methode, siehe TESM_ACME_WEBROOT) ein
|
||
Zertifikat für die angegebene Domain an. Setzt voraus, dass die Domain
|
||
öffentlich auf diesen Host auflöst UND Port 80 aus dem Internet
|
||
erreichbar ist (ACME HTTP-01) -- beides kann diese App nicht prüfen,
|
||
ein Fehlschlag hier liegt i.d.R. an der Netzwerk-/DNS-Situation, nicht
|
||
an TESM selbst. Automatische Verlängerung übernimmt certbots eigener
|
||
systemd-Timer/Cron (Standard-Paketverhalten unter Debian/Ubuntu), der
|
||
Deploy-Hook (_write_certbot_deploy_hook) lädt nginx danach neu."""
|
||
args = [
|
||
"certbot", "certonly", "--webroot", "-w", TESM_ACME_WEBROOT,
|
||
"-d", domain, "--non-interactive", "--agree-tos", "--cert-name", domain,
|
||
]
|
||
args += ["--email", email] if email else ["--register-unsafely-without-email"]
|
||
return _dhcp_run_privileged(args, timeout=120)
|
||
|
||
|
||
def _certbot_timer_status():
|
||
"""Status von certbot.timer -- dem systemd-Timer, der die automatische
|
||
Verlängerung tatsächlich antreibt (Standard-Paketverhalten unter
|
||
Debian/Ubuntu, zweimal täglich). Rein informativ für die NGINX-Seite:
|
||
"automatische Verlängerung" soll für den Admin sichtbar/nachprüfbar
|
||
sein, nicht nur eine Behauptung im Quelltext bleiben. None, wenn
|
||
certbot (noch) nicht installiert ist."""
|
||
active_ok, active_out = _dhcp_run_privileged(["systemctl", "is-active", "certbot.timer"], timeout=5)
|
||
if not active_ok and "could not be found" in active_out.lower():
|
||
return None
|
||
next_run = None
|
||
ok, out = _dhcp_run_privileged(["systemctl", "list-timers", "certbot.timer", "--no-legend"], timeout=5)
|
||
if ok and out.strip():
|
||
parts = out.strip().split(None, 4)
|
||
if len(parts) >= 4:
|
||
next_run = " ".join(parts[0:4])
|
||
return {"active": active_out.strip() == "active", "next_run": next_run}
|
||
|
||
|
||
@app.route("/settings/nginx/raw")
|
||
@login_required
|
||
def settings_nginx_raw():
|
||
"""Komplette, unbearbeitete nginx-Konfiguration für das RAW-Popup --
|
||
gleiches Muster wie beim Live-Log (window.openRawLogModal)."""
|
||
if not current_user.has_permission("settings_nginx.view"):
|
||
return "Keine Berechtigung.", 403
|
||
try:
|
||
with open(NGINX_SITE_CONFIG_PATH, "r", encoding="utf-8", errors="replace") as f:
|
||
content = f.read()
|
||
except OSError as e:
|
||
content = f"Fehler beim Lesen der Konfiguration: {e}"
|
||
response = app.response_class(content, mimetype="text/plain")
|
||
response.headers["X-Log-Name"] = os.path.basename(NGINX_SITE_CONFIG_PATH)
|
||
return response
|
||
|
||
|
||
@app.route("/settings/nginx", methods=["GET", "POST"])
|
||
@login_required
|
||
def settings_nginx():
|
||
if request.method == "GET" and not current_user.has_permission("settings_nginx.view"):
|
||
flash("Keine Berechtigung, die NGINX-Einstellungen anzusehen.", "danger")
|
||
return redirect(url_for("index"))
|
||
|
||
if request.method == "POST":
|
||
if not current_user.has_permission("settings_nginx.edit"):
|
||
flash("Keine Berechtigung, die NGINX-Einstellungen zu ändern.", "danger")
|
||
return redirect(url_for("settings_nginx"))
|
||
|
||
if "upload_cert" in request.form:
|
||
cert_file = request.files.get("cert_file")
|
||
key_file = request.files.get("key_file")
|
||
if not cert_file or not cert_file.filename or not key_file or not key_file.filename:
|
||
flash("Bitte sowohl Zertifikat (PEM) als auch privaten Schlüssel (PEM) auswählen.", "danger")
|
||
else:
|
||
cert_bytes = cert_file.read()
|
||
key_bytes = key_file.read()
|
||
ok, msg, info = _validate_cert_key_pair(cert_bytes, key_bytes)
|
||
if not ok:
|
||
flash(f"Zertifikat/Schlüssel nicht gespeichert: {msg}", "danger")
|
||
else:
|
||
try:
|
||
os.makedirs(TESM_SSL_DIR, exist_ok=True)
|
||
with open(TESM_SSL_CERT_PATH, "wb") as f:
|
||
f.write(cert_bytes)
|
||
with open(TESM_SSL_KEY_PATH, "wb") as f:
|
||
f.write(key_bytes)
|
||
os.chmod(TESM_SSL_KEY_PATH, 0o600)
|
||
os.chmod(TESM_SSL_CERT_PATH, 0o644)
|
||
set_setting("ssl_source", "upload")
|
||
log_action("settings.nginx_cert_upload", info.get("subject", ""), f"gültig bis {info['not_after']:%d.%m.%Y}")
|
||
flash(
|
||
f"Zertifikat gespeichert (Subject: {info.get('subject')}, gültig bis "
|
||
f"{info['not_after']:%d.%m.%Y}). SSL ist damit noch nicht aktiviert -- unten aktivieren.",
|
||
"success",
|
||
)
|
||
except OSError as e:
|
||
flash(f"Zertifikat konnte nicht gespeichert werden: {e}", "danger")
|
||
|
||
elif "request_letsencrypt" in request.form:
|
||
domain = request.form.get("le_domain", "").strip()
|
||
email = request.form.get("le_email", "").strip() or None
|
||
if not domain or not _DOMAIN_RE.match(domain):
|
||
flash("Bitte eine gültige, öffentlich auflösbare Domain angeben.", "danger")
|
||
else:
|
||
ok, out = _request_letsencrypt_cert(domain, email)
|
||
if not ok:
|
||
flash(f"Let's-Encrypt-Anforderung fehlgeschlagen: {out.strip()[-800:]}", "danger")
|
||
else:
|
||
set_setting("ssl_source", "letsencrypt")
|
||
set_setting("nginx_server_name", domain)
|
||
log_action("settings.nginx_letsencrypt_request", domain, "Zertifikat erfolgreich ausgestellt")
|
||
flash(
|
||
f"Let's-Encrypt-Zertifikat für {domain} ausgestellt (automatische Verlängerung läuft über "
|
||
f"certbots eigenen Timer). SSL ist damit noch nicht aktiviert -- unten aktivieren.",
|
||
"success",
|
||
)
|
||
|
||
elif "test_renewal" in request.form:
|
||
if get_setting("ssl_source", "upload") != "letsencrypt":
|
||
flash("Verlängerungs-Test setzt ein per Let's Encrypt ausgestelltes Zertifikat voraus.", "danger")
|
||
else:
|
||
domain = get_setting("nginx_server_name", "_")
|
||
ok, out = _dhcp_run_privileged(["certbot", "renew", "--dry-run", "--cert-name", domain], timeout=120)
|
||
if ok:
|
||
log_action("settings.nginx_letsencrypt_test_renewal", domain)
|
||
flash("Verlängerungs-Test erfolgreich (Dry-Run — es wurde nichts tatsächlich verändert).", "success")
|
||
else:
|
||
flash(f"Verlängerungs-Test fehlgeschlagen: {out.strip()[-800:]}", "danger")
|
||
|
||
elif "force_renew" in request.form:
|
||
if get_setting("ssl_source", "upload") != "letsencrypt":
|
||
flash("Manuelle Verlängerung setzt ein per Let's Encrypt ausgestelltes Zertifikat voraus.", "danger")
|
||
else:
|
||
domain = get_setting("nginx_server_name", "_")
|
||
ok, out = _dhcp_run_privileged(["certbot", "renew", "--force-renewal", "--cert-name", domain], timeout=120)
|
||
if ok:
|
||
log_action("settings.nginx_letsencrypt_force_renew", domain)
|
||
flash("Zertifikat manuell erneuert — nginx wurde über den Deploy-Hook automatisch neu geladen.", "success")
|
||
else:
|
||
flash(f"Manuelle Verlängerung fehlgeschlagen: {out.strip()[-800:]}", "danger")
|
||
|
||
elif "apply_nginx" in request.form:
|
||
new_server_name = request.form.get("server_name", "_").strip() or "_"
|
||
new_http_port = request.form.get("http_port", "80").strip()
|
||
new_https_port = request.form.get("https_port", "443").strip()
|
||
new_ssl = "ssl_enabled" in request.form
|
||
new_hsts = "hsts_enabled" in request.form
|
||
|
||
if not _valid_server_name(new_server_name):
|
||
flash("Ungültiger Domain-/Servername.", "danger")
|
||
elif not _valid_port(new_http_port) or not _valid_port(new_https_port):
|
||
flash("Port muss eine Zahl zwischen 1 und 65535 sein (5000 ist für die App selbst reserviert).", "danger")
|
||
elif new_http_port == new_https_port:
|
||
flash("HTTP- und HTTPS-Port müssen unterschiedlich sein.", "danger")
|
||
elif new_ssl and not _current_cert_paths()[0]:
|
||
flash("Kein Zertifikat verfügbar -- bitte zuerst hochladen oder per Let's Encrypt anfordern.", "danger")
|
||
elif new_hsts and not new_ssl:
|
||
flash("HSTS setzt aktives SSL voraus.", "danger")
|
||
else:
|
||
prev_settings = {
|
||
"nginx_server_name": get_setting("nginx_server_name", "_"),
|
||
"nginx_http_port": get_setting("nginx_http_port", "80"),
|
||
"nginx_https_port": get_setting("nginx_https_port", "443"),
|
||
"ssl_enabled": get_setting("ssl_enabled", "0"),
|
||
"hsts_enabled": get_setting("hsts_enabled", "0"),
|
||
}
|
||
ok, err, previous = _apply_nginx_config(new_server_name, new_http_port, new_https_port, new_ssl, new_hsts)
|
||
if not ok:
|
||
flash(f"nginx-Konfiguration konnte nicht angewendet werden, unverändert gelassen: {err}", "danger")
|
||
else:
|
||
set_setting("nginx_server_name", new_server_name)
|
||
set_setting("nginx_http_port", new_http_port)
|
||
set_setting("nginx_https_port", new_https_port)
|
||
set_setting("ssl_enabled", "1" if new_ssl else "0")
|
||
set_setting("hsts_enabled", "1" if new_hsts else "0")
|
||
log_action(
|
||
"settings.nginx_apply", new_server_name,
|
||
f"http={new_http_port} https={new_https_port} ssl={new_ssl} hsts={new_hsts}",
|
||
)
|
||
token = secrets.token_hex(8)
|
||
timer = threading.Timer(NGINX_REVERT_SECONDS, _revert_nginx_config, args=(token,))
|
||
timer.daemon = True
|
||
_pending_nginx_revert.clear()
|
||
_pending_nginx_revert[token] = {"timer": timer, "previous": previous, "prev_settings": prev_settings}
|
||
timer.start()
|
||
scheme = "https" if new_ssl else "http"
|
||
port = new_https_port if new_ssl else new_http_port
|
||
port_suffix = "" if port in ("80", "443") else f":{port}"
|
||
flash(
|
||
f"nginx-Konfiguration angewendet. Falls diese Seite jetzt noch über {scheme}{port_suffix} "
|
||
f"erreichbar ist, bitte unten bestätigen — sonst wird nach {NGINX_REVERT_SECONDS}s "
|
||
f"automatisch zurückgerollt.",
|
||
"success",
|
||
)
|
||
|
||
elif "confirm_nginx" in request.form:
|
||
token = request.form.get("confirm_nginx")
|
||
entry = _pending_nginx_revert.pop(token, None)
|
||
if entry:
|
||
entry["timer"].cancel()
|
||
log_action("settings.nginx_confirm", "nginx")
|
||
flash("nginx-Konfiguration bestätigt — kein automatisches Rollback mehr.", "success")
|
||
else:
|
||
flash("Keine ausstehende Bestätigung gefunden (evtl. bereits abgelaufen).", "danger")
|
||
|
||
elif "remove_cert" in request.form:
|
||
if get_setting("ssl_enabled", "0") == "1":
|
||
flash("Zertifikat kann nicht entfernt werden, solange SSL aktiv ist -- bitte zuerst deaktivieren.", "danger")
|
||
elif get_setting("ssl_source", "upload") == "letsencrypt":
|
||
domain = get_setting("nginx_server_name", "_")
|
||
ok, out = _dhcp_run_privileged(["certbot", "delete", "--cert-name", domain, "--non-interactive"], timeout=30)
|
||
if not ok:
|
||
flash(f"Let's-Encrypt-Zertifikat konnte nicht entfernt werden: {out}", "danger")
|
||
else:
|
||
log_action("settings.nginx_cert_remove", domain, "Let's Encrypt")
|
||
flash("Let's-Encrypt-Zertifikat entfernt.", "success")
|
||
else:
|
||
for path in (TESM_SSL_CERT_PATH, TESM_SSL_KEY_PATH):
|
||
try:
|
||
os.remove(path)
|
||
except OSError:
|
||
pass
|
||
log_action("settings.nginx_cert_remove", "upload")
|
||
flash("Hochgeladenes Zertifikat entfernt.", "success")
|
||
|
||
return redirect(url_for("settings_nginx"))
|
||
|
||
pending_token = next(iter(_pending_nginx_revert), None)
|
||
return render_template(
|
||
"settings_nginx.html",
|
||
cert_info=_current_cert_info(),
|
||
ssl_enabled=get_setting("ssl_enabled", "0") == "1",
|
||
hsts_enabled=get_setting("hsts_enabled", "0") == "1",
|
||
server_name=get_setting("nginx_server_name", "_"),
|
||
http_port=get_setting("nginx_http_port", "80"),
|
||
https_port=get_setting("nginx_https_port", "443"),
|
||
pending_nginx_token=pending_token,
|
||
nginx_revert_seconds=NGINX_REVERT_SECONDS,
|
||
certbot_timer=_certbot_timer_status(),
|
||
)
|
||
|
||
|
||
@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()
|
||
_pending_network_revert[token] = {"timer": timer, "backup": backup}
|
||
timer.start()
|
||
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."
|
||
)
|
||
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:
|
||
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")
|
||
|
||
|
||
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
|
||
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)
|
||
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_PACKAGE = "kea-dhcp4-server"
|
||
DHCP_SERVICE = "kea-dhcp4-server"
|
||
KEA_LEASE_FILE = "/var/lib/kea/kea-leases4.csv"
|
||
|
||
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"])
|
||
|
||
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:
|
||
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)
|
||
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()
|
||
|
||
candidates = {}
|
||
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"])
|
||
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())
|
||
by_ip = {}
|
||
for row in rows:
|
||
if (row.get("state") or "0") != "0":
|
||
continue
|
||
try:
|
||
expire = int(row.get("expire") or 0)
|
||
except ValueError:
|
||
expire = 0
|
||
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
|
||
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 "",
|
||
"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:
|
||
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)
|
||
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."""
|
||
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):
|
||
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})},
|
||
"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,
|
||
"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:
|
||
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")
|
||
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)
|
||
_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,
|
||
)
|
||
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
|
||
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())
|
||
set_setting("ldap_required_login_group", request.form.get("ldap_required_login_group", "").strip())
|
||
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:
|
||
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()
|
||
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 "edit_ldap_group_mapping" in request.form:
|
||
mapping_id = request.form.get("edit_ldap_group_mapping")
|
||
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()
|
||
try:
|
||
conn.execute(
|
||
"UPDATE ldap_group_mappings SET ad_group_dn=?, ad_group_name=?, app_group_id=? WHERE id=?",
|
||
(ad_group_dn, ad_group_name or ad_group_dn, app_group_id, mapping_id),
|
||
)
|
||
conn.commit()
|
||
log_action("settings.update", "LDAP-Gruppenzuordnung geändert", f"{ad_group_name or ad_group_dn} → {app_group_id}")
|
||
flash("Gruppenzuordnung aktualisiert.", "success")
|
||
except sqlite3.IntegrityError:
|
||
flash("Diese AD-Gruppe ist bereits einer anderen Rechtegruppe zugeordnet.", "danger")
|
||
conn.close()
|
||
|
||
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")
|
||
|
||
elif "add_fileshare_mapping" in request.form:
|
||
ad_group_dn = request.form.get("fs_ad_group_dn", "").strip()
|
||
ad_group_name = request.form.get("fs_ad_group_name", "").strip()
|
||
share_label = request.form.get("fs_share_label", "").strip()
|
||
share_unc = _normalize_share_unc(request.form.get("fs_share_unc", ""))
|
||
if not ad_group_dn or not share_label or not share_unc:
|
||
flash("AD-Gruppe, Bezeichnung und Freigabe-Pfad müssen angegeben werden.", "danger")
|
||
else:
|
||
conn = get_db_connection()
|
||
conn.execute(
|
||
"INSERT INTO ldap_fileshare_mappings (ad_group_dn, ad_group_name, share_label, share_unc) "
|
||
"VALUES (?, ?, ?, ?) "
|
||
"ON CONFLICT(ad_group_dn, share_unc) DO UPDATE SET ad_group_name=excluded.ad_group_name, share_label=excluded.share_label",
|
||
(ad_group_dn, ad_group_name or ad_group_dn, share_label, share_unc),
|
||
)
|
||
conn.commit()
|
||
conn.close()
|
||
log_action("settings.update", "Fileshare-Gruppenzuordnung", f"{ad_group_name or ad_group_dn} → {share_label} ({share_unc})")
|
||
flash("Fileshare-Zuordnung gespeichert.", "success")
|
||
|
||
elif "edit_fileshare_mapping" in request.form:
|
||
mapping_id = request.form.get("edit_fileshare_mapping")
|
||
ad_group_dn = request.form.get("fs_ad_group_dn", "").strip()
|
||
ad_group_name = request.form.get("fs_ad_group_name", "").strip()
|
||
share_label = request.form.get("fs_share_label", "").strip()
|
||
share_unc = _normalize_share_unc(request.form.get("fs_share_unc", ""))
|
||
if not ad_group_dn or not share_label or not share_unc:
|
||
flash("AD-Gruppe, Bezeichnung und Freigabe-Pfad müssen angegeben werden.", "danger")
|
||
else:
|
||
conn = get_db_connection()
|
||
try:
|
||
conn.execute(
|
||
"UPDATE ldap_fileshare_mappings SET ad_group_dn=?, ad_group_name=?, share_label=?, share_unc=? WHERE id=?",
|
||
(ad_group_dn, ad_group_name or ad_group_dn, share_label, share_unc, mapping_id),
|
||
)
|
||
conn.commit()
|
||
log_action("settings.update", "Fileshare-Gruppenzuordnung geändert", f"{ad_group_name or ad_group_dn} → {share_label} ({share_unc})")
|
||
flash("Fileshare-Zuordnung aktualisiert.", "success")
|
||
except sqlite3.IntegrityError:
|
||
flash("Diese Kombination aus AD-Gruppe und Freigabe-Pfad existiert bereits.", "danger")
|
||
conn.close()
|
||
|
||
elif "delete_fileshare_mapping" in request.form:
|
||
mapping_id = request.form.get("delete_fileshare_mapping")
|
||
conn = get_db_connection()
|
||
row = conn.execute("SELECT share_label FROM ldap_fileshare_mappings WHERE id=?", (mapping_id,)).fetchone()
|
||
conn.execute("DELETE FROM ldap_fileshare_mappings WHERE id=?", (mapping_id,))
|
||
conn.commit()
|
||
conn.close()
|
||
log_action("settings.update", "Fileshare-Zuordnung gelöscht", row["share_label"] if row else mapping_id)
|
||
flash("Fileshare-Zuordnung 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(),
|
||
fileshare_mappings=_ldap_fileshare_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))
|
||
|
||
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"))
|
||
|
||
|
||
|
||
def _read_known_hosts_content():
|
||
"""Für den Export: der Inhalt von known_hosts gehört inhaltlich zu den
|
||
Zugangsdaten (jeder per SSH bereits bestätigte Host-Key eines Geräts/
|
||
Switches, das über gespeicherte Zugangsdaten erreicht wurde) -- landet
|
||
daher NICHT als eigene EXPORT_BUILDERS-Kategorie, sondern als
|
||
zusätzlicher Payload-Schlüssel "known_hosts", sobald "credentials"
|
||
mitexportiert wird (siehe export_data())."""
|
||
try:
|
||
with open(SSH_KNOWN_HOSTS_PATH, "r", encoding="utf-8") as f:
|
||
content = f.read()
|
||
return content if content.strip() else None
|
||
except OSError:
|
||
return None
|
||
|
||
|
||
def _merge_known_hosts_content(new_content):
|
||
"""Für den Import: ergänzt die eigene known_hosts-Datei um die im
|
||
Export enthaltenen Zeilen -- bewusst ergänzend statt überschreibend
|
||
(die Zielumgebung kann bereits eigene, unabhängig bestätigte Host-Keys
|
||
haben) und mit Deduplizierung nach vollständiger Zeile, damit ein
|
||
wiederholter Import derselben Exportdatei nicht bei jedem Mal weitere
|
||
Duplikate anhäuft. Gibt die Anzahl tatsächlich neu hinzugefügter
|
||
Zeilen zurück."""
|
||
try:
|
||
try:
|
||
with open(SSH_KNOWN_HOSTS_PATH, "r", encoding="utf-8") as f:
|
||
existing_lines = set(f.read().splitlines())
|
||
except OSError:
|
||
existing_lines = set()
|
||
new_lines = [ln for ln in new_content.splitlines() if ln.strip() and ln not in existing_lines]
|
||
if not new_lines:
|
||
return 0
|
||
os.makedirs(os.path.dirname(SSH_KNOWN_HOSTS_PATH) or ".", exist_ok=True)
|
||
with open(SSH_KNOWN_HOSTS_PATH, "a", encoding="utf-8") as f:
|
||
f.write("\n".join(new_lines) + "\n")
|
||
return len(new_lines)
|
||
except OSError:
|
||
return 0
|
||
|
||
|
||
EXPORT_SECTIONS = [
|
||
("devices", "Clients"),
|
||
("switches", "Switche"),
|
||
("credentials", "Zugangsdaten (inkl. bekannter SSH-Host-Keys aus known_hosts)"),
|
||
("users", "Benutzer (lokal)"),
|
||
("groups", "Custom-Gruppen"),
|
||
("ldap", "LDAP/AD-Einstellungen"),
|
||
("dhcp", "DHCP-Einstellungen"),
|
||
("nginx", "NGINX-Einstellungen (Domain, Ports, SSL/HSTS — ohne Zertifikat/Schlüssel)"),
|
||
("logs", "Auditlog (alle aktuell in der Datenbank vorhandenen Einträge)"),
|
||
]
|
||
EXPORT_SECTION_LABELS = dict(EXPORT_SECTIONS)
|
||
|
||
IMPORT_EXPORT_ADMIN_ONLY_SECTIONS = {"users", "groups", "ldap"}
|
||
|
||
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", "ldap_required_login_group",
|
||
]
|
||
|
||
|
||
def _export_devices(conn):
|
||
return [dict(r) for r in conn.execute("""
|
||
SELECT devices.mac, devices.ip, devices.port, devices.name, devices.switch_hostname,
|
||
devices.is_active, devices.ssh_port, credentials.name AS credential_name
|
||
FROM devices LEFT JOIN credentials ON credentials.id = devices.credential_id
|
||
WHERE devices.deleted_at IS NULL
|
||
""").fetchall()]
|
||
|
||
|
||
def _export_switches(conn):
|
||
return [dict(r) for r in conn.execute("""
|
||
SELECT switches.hostname, switches.ip, switches.ssh_port, 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, category FROM credentials WHERE deleted_at IS NULL").fetchall()
|
||
return [
|
||
{"name": r["name"], "username": r["username"], "password": decrypt_password(r["password"]), "category": r["category"]}
|
||
for r in rows
|
||
]
|
||
|
||
|
||
def _export_users(conn):
|
||
"""avatar_filename wird bewusst NICHT exportiert -- das ist nur ein
|
||
Dateiname, die eigentliche Bilddatei liegt unter static/uploads/avatars
|
||
auf DIESEM Host und würde auf dem Zielsystem nicht mitkommen; ein
|
||
exportierter Verweis auf eine dort nicht existierende Datei wäre
|
||
schlimmer als gar keiner (kaputtes <img>, siehe auch die analoge
|
||
Begründung bei _export_nginx() für TLS-Zertifikat/Schlüssel)."""
|
||
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, is_default 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"], "is_default": g["is_default"], "permissions": perms, "members": members})
|
||
return result
|
||
|
||
|
||
def _export_ldap(conn):
|
||
"""app_group_id ist eine rohe, lokale groups.id (oder der Sonderwert
|
||
"admin") -- über eine Umgebungsgrenze hinweg garantiert NICHT stabil
|
||
(frische Installation vergibt Gruppen-IDs neu). Exportiert wird daher
|
||
der Gruppenname (analog _export_switches()' credential_name), _import_ldap
|
||
löst ihn auf dem Zielsystem wieder zur dortigen ID auf -- gleiches
|
||
"admin"/g.name-Mapping wie in _ldap_group_mappings() weiter oben."""
|
||
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": [], "fileshare_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 m.ad_group_dn, m.ad_group_name,
|
||
CASE WHEN m.app_group_id = 'admin' THEN 'Admin' 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
|
||
""").fetchall()]
|
||
data["fileshare_mappings"] = [dict(m) for m in conn.execute(
|
||
"SELECT ad_group_dn, ad_group_name, share_label, share_unc FROM ldap_fileshare_mappings"
|
||
).fetchall()]
|
||
if not (settings_data or "bind_username" in data or data["group_mappings"] or data["fileshare_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
|
||
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}
|
||
|
||
|
||
NGINX_SETTING_KEYS = ["nginx_server_name", "nginx_http_port", "nginx_https_port", "ssl_enabled", "hsts_enabled"]
|
||
|
||
|
||
def _export_nginx(conn):
|
||
"""Exportiert nur portable Konfiguration (Domain, Ports, SSL/HSTS-
|
||
Schalter) -- bewusst OHNE ssl_source, TLS-Zertifikat/Schlüssel oder
|
||
Let's-Encrypt-/certbot-Zustand: das sind entweder host-gebundene reale
|
||
Dateien (TESM_SSL_CERT_PATH/TESM_SSL_KEY_PATH) oder eine ACME-
|
||
Registrierung für genau diesen Host/diese Domain, kein portabler
|
||
Einstellungswert (gleiche Begründung wie bei avatar_filename in
|
||
_export_users())."""
|
||
settings_data = {k: get_setting(k) for k in NGINX_SETTING_KEYS if get_setting(k) not in (None, "")}
|
||
if not settings_data:
|
||
return None
|
||
return {"settings": settings_data}
|
||
|
||
|
||
def _export_logs(conn):
|
||
"""Exportiert das komplette, aktuell in der DB stehende Auditlog --
|
||
bereits archivierte Tage (siehe _archive_old_audit_log_rows) liegen
|
||
als eigene Dateien unter AUDIT_ARCHIVE_DIR und sind bewusst NICHT Teil
|
||
dieses Exports, das Auditlog-Export-Formular exportiert nur die
|
||
Datenbank-Kategorien."""
|
||
rows = conn.execute(
|
||
"SELECT ts, username, action, target, details FROM audit_log ORDER BY id DESC"
|
||
).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,
|
||
"nginx": _export_nginx, "logs": _export_logs,
|
||
}
|
||
|
||
|
||
def _import_devices(conn, items):
|
||
n = 0
|
||
for d in items:
|
||
cred_id = None
|
||
if d.get("credential_name"):
|
||
row = conn.execute(
|
||
"SELECT id FROM credentials WHERE name=? AND deleted_at IS NULL", (d["credential_name"],)
|
||
).fetchone()
|
||
cred_id = row["id"] if row else None
|
||
conn.execute(
|
||
"""INSERT INTO devices (mac, ip, port, name, switch_hostname, is_active, ssh_port, credential_id)
|
||
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,
|
||
ssh_port=excluded.ssh_port, credential_id=excluded.credential_id
|
||
WHERE devices.deleted_at IS NULL""",
|
||
(d["mac"], d["ip"], d.get("port"), d["name"], d.get("switch_hostname"), d.get("is_active", 1),
|
||
d.get("ssh_port"), cred_id),
|
||
)
|
||
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, ssh_port, credential_id) VALUES (?, ?, ?, ?)
|
||
ON CONFLICT(hostname) DO UPDATE SET ip=excluded.ip, ssh_port=excluded.ssh_port,
|
||
credential_id=excluded.credential_id
|
||
WHERE switches.deleted_at IS NULL""",
|
||
(s["hostname"], s["ip"], s.get("ssh_port"), 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, category) VALUES (?, ?, ?, ?)
|
||
ON CONFLICT(name) DO UPDATE SET username=excluded.username, password=excluded.password,
|
||
category=excluded.category
|
||
WHERE credentials.deleted_at IS NULL""",
|
||
(c["name"], c["username"], encrypt_password(c["password"]), c.get("category", "switch")),
|
||
)
|
||
n += 1
|
||
return n
|
||
|
||
|
||
def _import_users(conn, items):
|
||
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:
|
||
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):
|
||
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)",
|
||
(g["name"], g.get("is_default", 0)),
|
||
)
|
||
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:
|
||
conn.execute(
|
||
"INSERT INTO settings (key, value) VALUES (?, ?) "
|
||
"ON CONFLICT(key) DO UPDATE SET value=excluded.value",
|
||
(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", []):
|
||
app_group_name = m.get("app_group_name")
|
||
if app_group_name == "Admin":
|
||
app_group_id = "admin"
|
||
else:
|
||
grow = conn.execute(
|
||
"SELECT id FROM groups WHERE name=? AND deleted_at IS NULL", (app_group_name,)
|
||
).fetchone()
|
||
if not grow:
|
||
continue # Rechtegruppe existiert auf dem Zielsystem (noch) nicht -- Zuordnung überspringen statt kaputt anzulegen
|
||
app_group_id = grow["id"]
|
||
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"], app_group_id),
|
||
)
|
||
n += 1
|
||
for m in data.get("fileshare_mappings", []):
|
||
conn.execute(
|
||
"INSERT INTO ldap_fileshare_mappings (ad_group_dn, ad_group_name, share_label, share_unc) VALUES (?, ?, ?, ?) "
|
||
"ON CONFLICT(ad_group_dn, share_unc) DO UPDATE SET ad_group_name=excluded.ad_group_name, share_label=excluded.share_label",
|
||
(m["ad_group_dn"], m["ad_group_name"], m["share_label"], m["share_unc"]),
|
||
)
|
||
n += 1
|
||
return n
|
||
|
||
|
||
def _import_dhcp(conn, data):
|
||
for key, value in data.get("settings", {}).items():
|
||
if key in DHCP_DEFAULTS:
|
||
conn.execute(
|
||
"INSERT INTO settings (key, value) VALUES (?, ?) "
|
||
"ON CONFLICT(key) DO UPDATE SET value=excluded.value",
|
||
(key, value),
|
||
)
|
||
n = 0
|
||
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_nginx(conn, data):
|
||
n = 0
|
||
for key, value in data.get("settings", {}).items():
|
||
if key in NGINX_SETTING_KEYS:
|
||
conn.execute(
|
||
"INSERT INTO settings (key, value) VALUES (?, ?) "
|
||
"ON CONFLICT(key) DO UPDATE SET value=excluded.value",
|
||
(key, 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,
|
||
"nginx": _import_nginx, "logs": _import_logs,
|
||
}
|
||
|
||
_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():
|
||
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"))
|
||
|
||
if "credentials" in payload:
|
||
known_hosts_content = _read_known_hosts_content()
|
||
if known_hosts_content:
|
||
payload["known_hosts"] = known_hosts_content
|
||
|
||
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 if k in EXPORT_SECTION_LABELS)
|
||
if "known_hosts" in payload:
|
||
summary += " (inkl. known_hosts)"
|
||
log_action("data.export", ", ".join(EXPORT_SECTION_LABELS[k] for k in payload if k in EXPORT_SECTION_LABELS), 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"))
|
||
|
||
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)
|
||
if "credentials" in selected and payload.get("known_hosts"):
|
||
added = _merge_known_hosts_content(payload["known_hosts"])
|
||
summary += f", known_hosts: {added} neue Zeile(n)"
|
||
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"))
|
||
|
||
|
||
|
||
@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_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
|
||
|
||
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"))
|
||
|
||
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:
|
||
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
|
||
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):
|
||
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}
|
||
|
||
|
||
|
||
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):
|
||
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"))
|
||
|
||
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"))
|
||
|
||
|
||
|
||
@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:
|
||
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()
|
||
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)
|
||
|
||
|
||
|
||
_maintenance_jobs_lock = threading.Lock()
|
||
_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()
|
||
|
||
|
||
|
||
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):
|
||
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,))
|
||
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("papierkorb.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("papierkorb.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("papierkorb.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("papierkorb.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("papierkorb.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("papierkorb.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("papierkorb.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("papierkorb.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("papierkorb.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("papierkorb.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:
|
||
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"))
|
||
_purge_expired_trash()
|
||
conn = get_db_connection()
|
||
# Global: papierkorb.view zeigt alle fünf Kategorien, unabhängig vom
|
||
# jeweiligen Ressourcen-Leserecht (siehe can_view_papierkorb).
|
||
trash = {
|
||
"devices": _trashed_devices(conn),
|
||
"switches": _trashed_switches(conn),
|
||
"credentials": _trashed_credentials(conn),
|
||
"users": _trashed_users(conn),
|
||
"groups": _trashed_groups(conn),
|
||
}
|
||
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
|
||
TESM_CLIENT_UPDATE_LOG_DIR = os.path.join(TESM_LOG_DIR, "clientupdate")
|
||
|
||
|
||
def _write_client_update_error_log(name, exit_code, lines):
|
||
"""Schreibt bei einem mit Exit-Code fehlgeschlagenen Wartungs-Update
|
||
eine eigene Fehler-Logdatei pro Client (überschreibt eine vorherige aus
|
||
einem früheren Fehlschlag desselben Clients) -- zusätzlich zur ohnehin
|
||
im Job-Status/Live-Fenster sichtbaren Ausgabe, damit ein Fehlschlag
|
||
auch ohne offenes Browser-Fenster im Nachhinein auffindbar bleibt."""
|
||
try:
|
||
os.makedirs(TESM_CLIENT_UPDATE_LOG_DIR, exist_ok=True)
|
||
os.chmod(TESM_CLIENT_UPDATE_LOG_DIR, 0o755)
|
||
safe_name = secure_filename(name) or "unbenannt"
|
||
path = os.path.join(TESM_CLIENT_UPDATE_LOG_DIR, f"{safe_name}.error")
|
||
with open(path, "w", encoding="utf-8") as f:
|
||
f.write(f"Update fehlgeschlagen: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}\n")
|
||
f.write(f"Exit-Code: {exit_code}\n\n")
|
||
f.write("\n".join(lines))
|
||
f.write("\n")
|
||
except OSError:
|
||
app.logger.error("_write_client_update_error_log failed:\n%s", traceback.format_exc())
|
||
|
||
|
||
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,
|
||
)
|
||
|
||
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))
|
||
|
||
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")
|
||
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,
|
||
)
|
||
_write_client_update_error_log(name, result["exit_code"], lines)
|
||
log_action_system("maintenance.update", name, f"fehlgeschlagen: Exit-Code {result['exit_code']}")
|
||
|
||
|
||
_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)
|
||
|
||
|
||
|
||
_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
|
||
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":
|
||
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 "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
|
||
elif "keyboard-interactive" in allowed:
|
||
def handler(title, instructions, prompt_list):
|
||
for text in (title, instructions):
|
||
if text:
|
||
ws.send(f"\r\n{text}")
|
||
responses = []
|
||
for prompt_text, echo in prompt_list:
|
||
ws.send(f"\r\n{prompt_text or 'Passwort: '}")
|
||
responses.append(_terminal_read_line(ws, echo=bool(echo)) or "")
|
||
return responses
|
||
try:
|
||
transport.auth_interactive(username, handler)
|
||
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):
|
||
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")
|
||
|
||
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
|
||
|
||
|
||
|
||
_LINUX_UPDATE_STEPS = [
|
||
("apt update", "apt-get update"),
|
||
("initramfs MODULES=most setzen", "sed -i 's/^MODULES=.*/MODULES=most/' /etc/initramfs-tools/initramfs.conf"),
|
||
(
|
||
"apt upgrade",
|
||
'apt-get -y -o Dpkg::Options::="--force-confdef" -o Dpkg::Options::="--force-confold" upgrade',
|
||
),
|
||
(
|
||
"apt full-upgrade",
|
||
'apt-get -y -o Dpkg::Options::="--force-confdef" -o Dpkg::Options::="--force-confold" full-upgrade',
|
||
),
|
||
("apt clean", "apt-get clean"),
|
||
("apt autoremove", "apt-get -y autoremove"),
|
||
]
|
||
|
||
|
||
def _apt_upgrade_command():
|
||
"""Baut die Wartungs-Update-Kette aus _LINUX_UPDATE_STEPS zusammen --
|
||
jeder Schritt einzeln mit eigenem "=== Schritt N/M: ... ===" und
|
||
danach "Schritt N (...) -> erfolgreich"/"-> fehlgeschlagen (Exit-Code
|
||
...)" im Live-Log, statt nur einer einzigen Erfolgs-/Fehlermeldung für
|
||
die ganze Kette. Bricht beim ersten fehlgeschlagenen Schritt sofort ab
|
||
(dessen Exit-Code wird zum Exit-Code des Gesamtkommandos), statt
|
||
blind mit dem nächsten Schritt weiterzumachen.
|
||
|
||
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).
|
||
|
||
MODULES=most wird -- anders als zuvor -- dauerhaft in
|
||
/etc/initramfs-tools/initramfs.conf gesetzt (nicht nur für diesen einen
|
||
Aufruf per Umgebungsvariable): falls apt upgrade/full-upgrade einen
|
||
neuen Kernel installiert und dabei ein initramfs neu gebaut wird,
|
||
landen so ALLE verfügbaren Treiber darin (statt nur die per "dep"
|
||
erkannten), damit das Gerät nach einem Kernel-Wechsel zuverlässig
|
||
bootet -- und das bleibt auch für jeden künftigen, unabhängig von
|
||
dieser App ausgelösten Kernel-Update/initramfs-Rebuild so bestehen."""
|
||
script_lines = ["export DEBIAN_FRONTEND=noninteractive"]
|
||
total = len(_LINUX_UPDATE_STEPS)
|
||
for i, (label, cmd) in enumerate(_LINUX_UPDATE_STEPS, start=1):
|
||
script_lines.append(f"echo '=== Schritt {i}/{total}: {label} ==='")
|
||
script_lines.append(cmd)
|
||
script_lines.append("_rc=$?")
|
||
script_lines.append(
|
||
f'if [ "$_rc" -eq 0 ]; then echo "Schritt {i} ({label}) -> erfolgreich"; '
|
||
f'else echo "Schritt {i} ({label}) -> fehlgeschlagen (Exit-Code $_rc)"; exit "$_rc"; fi'
|
||
)
|
||
script = "\n".join(script_lines)
|
||
return f"sudo -S -p '' bash -c {shlex.quote(script)}"
|
||
|
||
|
||
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)
|
||
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
|
||
finally:
|
||
try:
|
||
client.close()
|
||
except Exception:
|
||
pass
|
||
|
||
result["success"] = True
|
||
return result
|
||
|
||
|
||
|
||
@app.route("/get_log")
|
||
@login_required
|
||
def get_log():
|
||
"""Wird vom Live-Log-Poll (Intervall-Timer + manueller Aktualisieren-
|
||
Button) aufgerufen -- liefert bewusst nur die letzten LIVE_LOG_TAIL_LINES
|
||
Zeilen statt der kompletten Datei (siehe _tail_lines), damit dieser
|
||
wiederkehrende Abruf auf größeren Umgebungen keine spürbaren Ladezeiten
|
||
mehr verursacht. Das komplette Log bleibt über get_log_raw erreichbar."""
|
||
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:
|
||
content = _tail_lines(latest_log)
|
||
total_lines = _count_lines(latest_log)
|
||
except OSError as e:
|
||
content = f"Fehler beim Lesen des Logs: {e}"
|
||
total_lines = None
|
||
response = app.response_class(content, mimetype="text/plain")
|
||
response.headers["X-Log-Name"] = os.path.basename(latest_log)
|
||
if total_lines is not None:
|
||
response.headers["X-Total-Lines"] = str(total_lines)
|
||
return response
|
||
|
||
|
||
@app.route("/logs/live/raw")
|
||
@login_required
|
||
def get_log_raw():
|
||
"""Komplettes, unbearbeitetes Live-Log für das RAW-Popup -- bewusst ein
|
||
eigener Endpoint statt eines Query-Parameters an /get_log, damit der
|
||
normale Live-Poll (jeder Nutzer mit logs_live.view, in jedem
|
||
Prüfintervall) unter keinen Umständen versehentlich die komplette Datei
|
||
lädt. Wird nur einmalig auf explizite Nutzeraktion (Button-Klick)
|
||
nachgeladen."""
|
||
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", encoding="utf-8", errors="replace") as f:
|
||
content = f.read()
|
||
except OSError as e:
|
||
content = f"Fehler beim Lesen des Logs: {e}"
|
||
response = app.response_class(content, mimetype="text/plain")
|
||
response.headers["X-Log-Name"] = os.path.basename(latest_log)
|
||
return response
|
||
|
||
|
||
@app.route("/logs/history")
|
||
@login_required
|
||
def logs_history():
|
||
"""Verlauf: ältere, von logrotate rotierte Kopien des Live-Logs, als
|
||
eigene Unterseite von Live erreichbar (eigenes Recht logs_history.view,
|
||
siehe User.can_view_log_history) -- bewusst getrennt vom laufenden
|
||
Live-Poll, damit das Durchblättern alter Logstände nicht denselben
|
||
Performance-Pfad wie die Live-Ansicht belastet."""
|
||
if not current_user.can_view_log_history:
|
||
flash("Keine Berechtigung, den Log-Verlauf einzusehen.", "danger")
|
||
return redirect(url_for("index"))
|
||
|
||
files = _live_log_history_files()
|
||
requested = request.args.get("file", "")
|
||
selected_path = _resolve_log_history_file(requested) if requested else None
|
||
selected_name = requested if selected_path else None
|
||
if not selected_path and files:
|
||
selected_name = files[0]["filename"]
|
||
selected_path = _resolve_log_history_file(selected_name)
|
||
selected_file = next((f for f in files if f["filename"] == selected_name), None)
|
||
|
||
log_content = None
|
||
total_lines = None
|
||
if selected_path:
|
||
try:
|
||
log_content = _tail_lines(selected_path)
|
||
total_lines = _count_lines(selected_path)
|
||
except OSError as e:
|
||
log_content = f"Fehler beim Lesen des Logs: {e}"
|
||
|
||
return render_template(
|
||
"logs_history.html", files=files, selected_name=selected_name, selected_file=selected_file,
|
||
log_content=log_content, total_lines=total_lines, tail_lines=LIVE_LOG_TAIL_LINES,
|
||
audit_files=_audit_archive_files(), disk_warning=_log_disk_space_warning(),
|
||
audit_threshold=AUDIT_LOG_ARCHIVE_THRESHOLD, audit_target=AUDIT_LOG_ARCHIVE_TARGET,
|
||
)
|
||
|
||
|
||
@app.route("/logs/history/raw")
|
||
@login_required
|
||
def logs_history_raw():
|
||
if not current_user.can_view_log_history:
|
||
return "Keine Berechtigung.", 403
|
||
path = _resolve_log_history_file(request.args.get("file", ""))
|
||
if not path:
|
||
return "Unbekannte oder ungültige Log-Datei.", 404
|
||
try:
|
||
with open(path, "r", encoding="utf-8", errors="replace") as f:
|
||
content = f.read()
|
||
except OSError as e:
|
||
content = f"Fehler beim Lesen des Logs: {e}"
|
||
response = app.response_class(content, mimetype="text/plain")
|
||
response.headers["X-Log-Name"] = os.path.basename(path)
|
||
return response
|
||
|
||
|
||
@app.route("/logs/history/audit-raw")
|
||
@login_required
|
||
def logs_history_audit_raw():
|
||
"""Wie logs_history_raw(), aber für einen einzelnen bereits
|
||
archivierten Auditlog-Tag (audit-YYYY-MM-DD.log) statt für eine
|
||
Live-Log-Rotation."""
|
||
if not current_user.can_view_log_history:
|
||
return "Keine Berechtigung.", 403
|
||
path = _resolve_audit_archive_file(request.args.get("file", ""))
|
||
if not path:
|
||
return "Unbekannte oder ungültige Archivdatei.", 404
|
||
try:
|
||
with open(path, "r", encoding="utf-8", errors="replace") as f:
|
||
content = f.read()
|
||
except OSError as e:
|
||
content = f"Fehler beim Lesen der Datei: {e}"
|
||
response = app.response_class(content, mimetype="text/plain")
|
||
response.headers["X-Log-Name"] = os.path.basename(path)
|
||
return response
|
||
|
||
|
||
@app.route("/logs/history/audit-export", methods=["POST"])
|
||
@login_required
|
||
def logs_history_audit_export():
|
||
"""Exportiert ALLE aktuell archivierten Auditlog-Tage als ein ZIP und
|
||
löscht sie danach vom Server -- bewusst eine einzige, manuell
|
||
ausgelöste Aktion (Recht logs_history.edit, siehe
|
||
User.can_manage_log_history) statt einer automatischen Löschung nach
|
||
Ablaufzeit: die Archivdateien selbst bleiben unbegrenzt liegen, bis ein
|
||
Admin hier gezielt aufräumt (z.B. wenn _log_disk_space_warning()
|
||
anschlägt)."""
|
||
if not current_user.can_manage_log_history:
|
||
return "Keine Berechtigung.", 403
|
||
|
||
files = _audit_archive_files()
|
||
if not files:
|
||
flash("Keine archivierten Auditlog-Tage zum Exportieren vorhanden.", "info")
|
||
return redirect(url_for("logs_history"))
|
||
|
||
buf = io.BytesIO()
|
||
with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as zf:
|
||
for f in files:
|
||
zf.write(os.path.join(AUDIT_ARCHIVE_DIR, f["filename"]), arcname=f["filename"])
|
||
buf.seek(0)
|
||
|
||
deleted = []
|
||
for f in files:
|
||
try:
|
||
os.remove(os.path.join(AUDIT_ARCHIVE_DIR, f["filename"]))
|
||
deleted.append(f["filename"])
|
||
except OSError:
|
||
pass
|
||
|
||
log_action(
|
||
"auditlog.export_delete",
|
||
"Auditlog",
|
||
f"{len(deleted)} Datei(en) exportiert und gelöscht: {', '.join(deleted)}",
|
||
)
|
||
|
||
today_str = datetime.now().strftime("%Y-%m-%d")
|
||
return send_file(
|
||
buf, mimetype="application/zip", as_attachment=True,
|
||
download_name=f"auditlog-archiv-{today_str}.zip",
|
||
)
|
||
|
||
|
||
@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,
|
||
total_lines=None, tail_lines=LIVE_LOG_TAIL_LINES,
|
||
)
|
||
|
||
try:
|
||
log_content = _tail_lines(latest_log)
|
||
total_lines = _count_lines(latest_log)
|
||
except OSError as e:
|
||
log_content = f"Fehler beim Lesen des Logs: {e}"
|
||
total_lines = None
|
||
|
||
return render_template(
|
||
"logs.html", log_content=log_content, log_name=os.path.basename(latest_log), interval=interval,
|
||
total_lines=total_lines, tail_lines=LIVE_LOG_TAIL_LINES,
|
||
)
|
||
|
||
|
||
@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/
|
||
Auditlog — 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)
|
||
|
||
|
||
AUDIT_LOG_PAGE_SIZE = 300
|
||
|
||
|
||
@app.route("/logs/aenderungen")
|
||
@login_required
|
||
def activity_log():
|
||
"""Auditlog: wer hat was geändert (Aktivieren/Deaktivieren, Anlegen,
|
||
Bearbeiten, Löschen). Bewusst OHNE PoE-Neustarts — die stehen im Live-Log.
|
||
Lädt anfangs nur die neuesten AUDIT_LOG_PAGE_SIZE Zeilen (Performance bei
|
||
einer ggf. noch nicht archivierten, großen Tabelle) -- ältere Zeilen
|
||
werden bei Bedarf über "Weitere 300 laden" bzw. "Alle laden" (siehe
|
||
activity_log_more()) nachgeladen, ohne die Seite neu zu laden."""
|
||
if not current_user.can_view_activity_log:
|
||
flash("Keine Berechtigung, das Auditlog 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 ?",
|
||
(AUDIT_LOG_PAGE_SIZE,),
|
||
).fetchall()
|
||
# Gesamtzahlen je Kategorie (nicht nur der geladenen Zeilen) für die
|
||
# "X / Y geladen"-Anzeige in den Statistik-Kacheln -- eine einzige
|
||
# aggregierte Abfrage statt die komplette Tabelle einzulesen. Die
|
||
# LIKE-Muster bilden exakt dieselbe Kategorisierung wie category_of()
|
||
# in _audit_log_macros.html ab (kind = action.split(".")[-1]).
|
||
totals_row = conn.execute(
|
||
"SELECT COUNT(*) AS total, "
|
||
"SUM(CASE WHEN action LIKE '%.create' OR action LIKE '%.upload' OR action LIKE '%.mkdir' THEN 1 ELSE 0 END) AS create_n, "
|
||
"SUM(CASE WHEN action LIKE '%.delete' THEN 1 ELSE 0 END) AS delete_n "
|
||
"FROM audit_log"
|
||
).fetchone()
|
||
conn.close()
|
||
|
||
total_count = totals_row["total"] or 0
|
||
total_create = totals_row["create_n"] or 0
|
||
total_delete = totals_row["delete_n"] or 0
|
||
total_edit = total_count - total_create - total_delete
|
||
|
||
oldest_loaded_id = entries[-1]["id"] if entries else None
|
||
has_more = len(entries) >= AUDIT_LOG_PAGE_SIZE and total_count > len(entries)
|
||
return render_template(
|
||
"activity_log.html", entries=entries, total_count=total_count,
|
||
total_create=total_create, total_edit=total_edit, total_delete=total_delete,
|
||
oldest_loaded_id=oldest_loaded_id, has_more=has_more,
|
||
)
|
||
|
||
|
||
@app.route("/logs/aenderungen/more")
|
||
@login_required
|
||
def activity_log_more():
|
||
"""AJAX-Nachladen älterer Auditlog-Zeilen in AUDIT_LOG_PAGE_SIZE-Schritten
|
||
(Cursor-Pagination über die id-Spalte, absteigend) -- liefert reines
|
||
HTML-Zeilenfragment (dieselbe Zeilen-Darstellung wie die Erstladung, über
|
||
das gemeinsame Makro in _audit_log_macros.html) statt JSON, damit das
|
||
Anhängen im Frontend ein einfaches insertAdjacentHTML bleibt."""
|
||
if not current_user.can_view_activity_log:
|
||
return "Keine Berechtigung.", 403
|
||
try:
|
||
before_id = int(request.args.get("before_id", ""))
|
||
except (TypeError, ValueError):
|
||
return "Ungültiger Parameter.", 400
|
||
|
||
conn = get_db_connection()
|
||
entries = conn.execute(
|
||
"SELECT id, ts, username, action, target, details FROM audit_log WHERE id < ? ORDER BY id DESC LIMIT ?",
|
||
(before_id, AUDIT_LOG_PAGE_SIZE),
|
||
).fetchall()
|
||
conn.close()
|
||
|
||
oldest_loaded_id = entries[-1]["id"] if entries else before_id
|
||
has_more = len(entries) >= AUDIT_LOG_PAGE_SIZE
|
||
html = render_template("_audit_log_rows.html", entries=entries)
|
||
resp = app.response_class(html, mimetype="text/html")
|
||
resp.headers["X-Row-Count"] = str(len(entries))
|
||
resp.headers["X-Has-More"] = "1" if has_more else "0"
|
||
resp.headers["X-Oldest-Id"] = str(oldest_loaded_id)
|
||
return resp
|
||
|
||
|
||
|
||
@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
|
||
|
||
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
|
||
|
||
|
||
|
||
@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
|
||
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"))
|
||
|
||
user_id = request.form["user_id"]
|
||
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"))
|
||
|
||
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 ""
|
||
|
||
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"))
|
||
|
||
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:
|
||
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()
|
||
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":
|
||
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:
|
||
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:
|
||
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:
|
||
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()
|
||
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])
|
||
|
||
|
||
|
||
@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,))
|
||
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"))
|
||
|
||
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"))
|
||
|
||
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:
|
||
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],
|
||
)
|
||
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:
|
||
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:
|
||
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],
|
||
})
|
||
|
||
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"
|
||
app.run(host="0.0.0.0", port=int(os.environ.get("PORT", 5000)), debug=debug, threaded=True)
|