Fork von TESM (srv/tesm), auf Kunden-/Lizenzverwaltung reduziert statt
Geraete-/PoE-Management. Wiederverwendet unveraendert: Login/Session/
Benutzer- und Gruppenverwaltung, LDAP/AD, NGINX- und Systemeinstellungen,
Live-Log/Verlauf/Auditlog, Im-/Export-Grundgeruest, das komplette
PERMISSIONS/NAV_ITEMS/inject_nav()-Rechtesystem sowie das in Phase 1
gebaute Lizenzsystem selbst sowohl fuer die MASTER-eigene Bootstrap-Lizenz
als auch fuer das exakt gleiche licensing.py (byte-identisch zu TESM --
Signieren/Verifizieren muss zwischen beiden Seiten kompatibel bleiben).
Entfernt: Clients/Switche/Zugangsdaten/DHCP/Fileshare/Wartung/Papierkorb/
manueller PoE-Neustart/SSH-Terminal inkl. aller zugehoerigen Tabellen,
Routen, Permissions, Nav-Eintraege und Abhaengigkeiten (paramiko/Flask-Sock/
simple-websocket/PyNaCl/pyasn1 aus requirements.txt). Benutzer-/Gruppen-
Loeschung von Soft- auf Hard-Delete umgestellt (kein Papierkorb mehr).
Eigener Pfad-/Env-Var-Namespace (TESM_LICENSE_* statt TESM_*, /var/log/
tesm-license statt /var/log/tesm usw.), damit Master und TESM testweise
sogar auf demselben Host nebeneinander laufen koennen, ohne sich Log-/
Config-Pfade streitig zu machen.
Neu -- der eigentliche Lizenzserver:
- license_customers/licenses-Tabellen, Master-Signaturschluessel
(master_signing_key.json, einmalig erzeugt, NIE automatisch rotiert --
jede Kundenlizenz traegt den zum Ausstellungszeitpunkt aktuellen
master_pubkey fest eingebettet).
- Dashboard ('/') als Kunden-/Lizenzuebersicht (Typ, Module, Ablauf,
Status, letzter Heartbeat), eigene Kunden-Verwaltungsseite.
- Lizenz-Ausstellung (Typ/Module/Laufzeit -> signierte Datei via
licensing.issue_license), Detailseite, Download, Widerruf.
- /api/activate, /api/deactivate, /api/heartbeat (unauthentifiziert per
Design -- die Signatur der Anfrage IST der Berechtigungsnachweis) sowie
eine manuelle Offline-Code-Seite, die dieselben drei Verarbeitungs-
funktionen (_process_activate/_process_deactivate/_process_heartbeat)
nutzt wie die Online-API -- ein Protokoll, zwei Transportwege.
- E-Mail-Versand ausgestellter Lizenzen per Microsoft Graph
(Client-Credentials-Flow, reine Standardbibliothek/urllib, keine neue
Abhaengigkeit) inkl. Einrichtungsanleitung und Verbindungstest.
- create_master_license.py: lokales Bootstrap-/Erneuerungs-Skript fuer
die eigene Lizenz des Masters (kein externer Super-Master noetig).
Verifiziert auf dem Testsystem (192.168.82.51): App laeuft parallel zu der
dort laufenden echten TESM-Instanz (Port 5001 vs. 80/5000, eigene
Log-/DB-Pfade, TESM unangetastet). Kompletter ECHTER End-to-End-Rundlauf
per Playwright durchgespielt -- kein simulierter Gegenpart: Kunde anlegen
-> Custom-Lizenz (dhcp+fileshare) ausstellen -> herunterladen -> auf der
echten TESM-Instanz hochladen -> ECHTE Online-Aktivierung (Master
verzeichnet Fingerprint, TESM zeigt 'Aktiviert') -> ECHTE Online-
Deaktivierung (TESM zeigt 'lizenzlos, Export bleibt moeglich', Master
zeigt Status 'Deaktiviert'). Lokale Syntax-/Templatepruefung (py_compile,
pyflakes, jinja2-Parse aller Templates) sauber.
Bewusst NICHT nach main gemergt/getaggt/ausgerollt -- folgt zusammen mit
Phase 3 (Infrastruktur-Umzug + POETEST-Enterprise-Lizenz), siehe Plan
toasty-twirling-hickey.md (Phase 2 von 3).
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
5410 lines
238 KiB
Python
5410 lines
238 KiB
Python
#!/usr/bin/env python3
|
||
"""
|
||
TESM-License — Master-Lizenzserver, als Fork von TESM (TimEShepManager)
|
||
entstanden. Alles Geräte-/PoE-/Switch-spezifische (Devices, Switche,
|
||
Zugangsdaten, Wartung, DHCP, Dateifreigaben, Papierkorb, manueller
|
||
PoE-Neustart, SSH-Terminal) wurde entfernt -- ein Lizenzserver verwaltet
|
||
Kunden und Lizenzen, keine Netzwerkgeräte.
|
||
|
||
Unverändert aus TESM übernommen: Login/Session/Benutzer- und
|
||
Gruppenverwaltung, LDAP/AD-Anbindung, NGINX- und Systemeinstellungen,
|
||
Live-Log/Log-Verlauf/Auditlog, das Im-/Export-Grundgerüst sowie das in
|
||
Phase 1 ergänzte Lizenzsystem selbst (licensing.py). Das Dashboard ("/")
|
||
ist aktuell nur ein Platzhalter -- die eigentliche Kunden-/
|
||
Lizenzübersicht folgt in einem späteren Schritt.
|
||
"""
|
||
from flask import Flask, render_template, request, redirect, url_for, flash, jsonify, send_file, abort
|
||
from flask_login import LoginManager, login_user, login_required, logout_user, UserMixin, current_user
|
||
from flask_bcrypt import Bcrypt
|
||
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
|
||
import base64, io, ipaddress, logging, math, shutil, socket, sqlite3, glob, json, os, re, secrets, subprocess, threading, time, traceback, zipfile
|
||
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
|
||
import urllib.request
|
||
import urllib.error
|
||
import urllib.parse
|
||
import licensing
|
||
|
||
LDAP_MATCHING_RULE_IN_CHAIN = "1.2.840.113556.1.4.1941"
|
||
|
||
BASE_DIR = os.path.dirname(os.path.abspath(__file__))
|
||
|
||
DB_PATH = os.environ.get("TESM_LICENSE_DB_PATH", os.path.join(BASE_DIR, "sqlite.db"))
|
||
FERNET_KEY_PATH = os.environ.get("TESM_LICENSE_FERNET_KEY", os.path.join(BASE_DIR, "fernet.key"))
|
||
SECRET_KEY_PATH = os.environ.get("TESM_LICENSE_SECRET_KEY_FILE", os.path.join(BASE_DIR, "secret.key"))
|
||
LICENSE_PATH = os.environ.get("TESM_LICENSE_PATH", os.path.join(BASE_DIR, "license.json"))
|
||
AVATAR_DIR = os.path.join(BASE_DIR, "static", "uploads", "avatars")
|
||
|
||
# Eigener Pfad-Namespace (tesm-license statt tesm) -- läuft testweise auch
|
||
# NEBEN einer echten TESM-Instanz auf demselben Host (siehe Phase 2/3 der
|
||
# Lizenzsystem-Umstellung), da sonst beide Dienste dieselben Log-/Config-
|
||
# Pfade beanspruchen würden.
|
||
TESM_LOG_DIR = os.environ.get("TESM_LICENSE_LOG_DIR", "/var/log/tesm-license")
|
||
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")
|
||
LOGROTATE_CONFIG_PATH = os.environ.get("TESM_LICENSE_LOGROTATE_CONFIG", "/etc/logrotate.d/tesm-license")
|
||
|
||
# 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
|
||
ALLOWED_AVATAR_EXT = {"png", "jpg", "jpeg", "gif", "webp"}
|
||
|
||
NGINX_SITE_CONFIG_PATH = os.environ.get("TESM_LICENSE_NGINX_CONFIG", "/etc/nginx/sites-available/tesm-license")
|
||
TESM_SSL_DIR = os.environ.get("TESM_LICENSE_SSL_DIR", "/etc/tesm-license/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_LICENSE_ACME_WEBROOT", "/var/www/tesm-license-acme")
|
||
CERTBOT_DEPLOY_HOOK_PATH = "/etc/letsencrypt/renewal-hooks/deploy/tesm-license-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()
|
||
|
||
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_LICENSE_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()
|
||
|
||
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 = {
|
||
"licensing_group": {
|
||
"label": "Lizenzierung",
|
||
"view_key": "licensing_group.view",
|
||
"children": {
|
||
"customers": {
|
||
"label": "Kunden",
|
||
"rows": {"view": "customers.view", "create": "customers.create", "edit": "customers.edit"},
|
||
},
|
||
"licenses": {
|
||
"label": "Lizenzen",
|
||
"rows": {"view": "licenses.view", "create": "licenses.create", "edit": "licenses.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"}},
|
||
},
|
||
},
|
||
"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_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"},
|
||
},
|
||
},
|
||
},
|
||
}
|
||
|
||
PERMISSION_LABELS = {
|
||
"licensing_group.view": "Lizenzierung-Bereich anzeigen",
|
||
"customers.view": "Kunden lesen", "customers.create": "Kunden anlegen",
|
||
"customers.edit": "Kunden ändern (inkl. Löschen)",
|
||
"licenses.view": "Lizenzen und Aktivierungsstatus lesen",
|
||
"licenses.create": "Lizenzen ausstellen",
|
||
"licenses.edit": "Lizenzen ändern (Deaktivieren/Widerrufen, erneut versenden)",
|
||
"logs_group.view": "Logs-Bereich anzeigen",
|
||
"settings_group.view": "Einstellungen-Bereich anzeigen",
|
||
"logs_live.view": "Live-Log lesen", "logs_activity.view": "Auditlog 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_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)",
|
||
}
|
||
|
||
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 = [
|
||
"logs_group.view", "logs_live.view",
|
||
]
|
||
|
||
NAV_ITEMS = [
|
||
{"key": "index", "label": "Dashboard", "icon": "grid", "endpoint": "index"},
|
||
{"key": "customers", "label": "Kunden", "icon": "users", "endpoint": "customers"},
|
||
{"key": "users", "label": "Benutzer", "icon": "users", "endpoint": "users"},
|
||
{"key": "groups", "label": "Gruppen", "icon": "groups", "endpoint": "groups"},
|
||
{"key": "settings_group", "label": "Einstellungen", "icon": "sliders", "children": [
|
||
{"key": "settings_system", "label": "Systemeinstellungen", "icon": "sliders", "endpoint": "settings"},
|
||
{"key": "settings_license", "label": "Lizenz", "icon": "key", "endpoint": "settings_license"},
|
||
{"key": "settings_ldap", "label": "LDAP", "icon": "users", "endpoint": "settings_ldap"},
|
||
{"key": "settings_importexport", "label": "Im-/Export", "icon": "transfer", "endpoint": "settings_import_export"},
|
||
{"key": "settings_nginx", "label": "NGINX", "icon": "server", "endpoint": "settings_nginx"},
|
||
]},
|
||
{"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"},
|
||
]},
|
||
]
|
||
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 == "customers":
|
||
return user.can_manage_customers
|
||
if key == "users":
|
||
return user.can_manage_users
|
||
if key == "groups":
|
||
return user.can_manage_groups
|
||
if key == "settings_system":
|
||
return user.can_view_settings_system
|
||
if key == "settings_license":
|
||
# Immer erreichbar für jeden mit Systemeinstellungen-Leserecht --
|
||
# muss auch OHNE Lizenz sichtbar bleiben, sonst könnte niemand
|
||
# jemals eine erste Lizenz hochladen/aktivieren.
|
||
return user.can_view_settings_system
|
||
if key == "settings_importexport":
|
||
return user.can_view_settings_importexport
|
||
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
|
||
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_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_manage_customers(self):
|
||
return self.has_any_permission(
|
||
["customers.view", "customers.create", "customers.edit"]
|
||
)
|
||
|
||
@property
|
||
def can_manage_licenses(self):
|
||
return self.has_any_permission(
|
||
["licenses.view", "licenses.create", "licenses.edit"]
|
||
)
|
||
|
||
@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_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
|
||
|
||
|
||
|
||
|
||
def _ensure_schema():
|
||
"""Legt neue Tabellen (Gruppen/Rechte, Kunden/Lizenzen) an, falls die DB
|
||
noch aus einer aelteren Version stammt -- idempotent, sicher bei jedem
|
||
Start aufzurufen. Sorgt ausserdem dafuer, dass die Standardgruppe
|
||
'Benutzer' existiert und jeder Benutzer ohne Gruppe ihr zugeordnet ist.
|
||
|
||
Gegenueber TESM bewusst ohne jede devices/switches/credentials/dhcp_*-
|
||
Migration -- der Lizenzserver hat nie eine aeltere Version mit diesen
|
||
Tabellen gehabt (eigene, frische Schema-Zeitlinie ab SCHEMA_VERSION 1),
|
||
die entsprechenden TESM-Altlast-Migrationen waeren hier bloss totes
|
||
Gewicht."""
|
||
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 audit_log (
|
||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||
ts TEXT NOT NULL,
|
||
username TEXT NOT NULL,
|
||
action TEXT NOT NULL,
|
||
target TEXT,
|
||
details TEXT
|
||
)
|
||
""")
|
||
|
||
for _table in ("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 license_customers (
|
||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||
name TEXT NOT NULL,
|
||
contact_email TEXT,
|
||
contact_phone TEXT,
|
||
notes TEXT,
|
||
created_at TEXT NOT NULL
|
||
)
|
||
""")
|
||
conn.execute("""
|
||
CREATE TABLE IF NOT EXISTS licenses (
|
||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||
license_id TEXT UNIQUE NOT NULL,
|
||
customer_id INTEGER NOT NULL,
|
||
type TEXT NOT NULL,
|
||
modules TEXT NOT NULL DEFAULT '[]',
|
||
issued_at TEXT NOT NULL,
|
||
expires_at TEXT NOT NULL,
|
||
license_pubkey TEXT NOT NULL,
|
||
license_file_json TEXT NOT NULL,
|
||
status TEXT NOT NULL DEFAULT 'issued',
|
||
fingerprint TEXT,
|
||
activated_at TEXT,
|
||
deactivated_at TEXT,
|
||
last_heartbeat_at TEXT,
|
||
revoked_at TEXT,
|
||
role TEXT NOT NULL DEFAULT 'customer',
|
||
created_by TEXT,
|
||
created_at TEXT NOT NULL,
|
||
FOREIGN KEY (customer_id) REFERENCES license_customers(id)
|
||
)
|
||
""")
|
||
|
||
_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 _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 die drei
|
||
App-Logs (Live, Auditlog, App) unter TESM_LOG_DIR 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 weder
|
||
Flask noch ein anderer schreibender Prozess ein Log-Reopen-Signal
|
||
(SIGHUP o.ä.) implementiert. "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 {{\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 _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)
|
||
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
|
||
|
||
|
||
# app.py wird nicht nur von gunicorn (tesm.service) gestartet, sondern bei
|
||
# jedem Import (auch als Hilfsmodul) sämtlicher Modul-Level-Code hier erneut
|
||
# ausgeführt. TESM_LICENSE_WEB_PROCESS wird nur von tesm-license.service
|
||
# selbst gesetzt (siehe dessen Unit-Datei) -- dient als Gate dafür, dass
|
||
# Hintergrund-Threads (Lizenz-Heartbeat, Auditlog-Archivierung) nur einmal
|
||
# je echtem Web-Prozess starten, nicht bei jedem Reimport.
|
||
_IS_WEB_PROCESS = os.environ.get("TESM_LICENSE_WEB_PROCESS") == "1"
|
||
|
||
|
||
# ============================================================ Lizenzsystem ==
|
||
# Kryptographie/Protokoll: siehe licensing.py. Hier nur der TESM-seitige
|
||
# Zustand: In-Memory-Cache der aktuell installierten Lizenzdatei -- einmal
|
||
# geladen, NICHT pro Request neu von der Platte gelesen -- plus die zwei zentralen
|
||
# Gate-Funktionen license_active()/module_licensed(), die von
|
||
# _nav_key_visible(), den Routen-Guards und den Templates aus aufgerufen
|
||
# werden. Kleine operative Zusatzangaben (letzter Heartbeat, letzter
|
||
# Fehler, Aktivierungsstatus) leben als gewöhnliche settings-Zeilen
|
||
# (get_setting/set_setting) -- die Lizenzdatei selbst bleibt eine flache
|
||
# Datei neben app.py (LICENSE_PATH), genau wie fernet.key/known_hosts.
|
||
LICENSE_HEARTBEAT_INTERVAL_SECONDS = 6 * 3600
|
||
|
||
_license_state = {"file": None, "valid": False, "status": None, "error": None}
|
||
|
||
|
||
def _load_license():
|
||
"""Liest LICENSE_PATH neu ein und prüft die Master-Signatur. Wird beim
|
||
Modulimport einmal sowie nach jedem Upload/jeder Aktivierung/
|
||
Deaktivierung/erfolgreichem Heartbeat erneut aufgerufen -- niemals
|
||
implizit pro Request (siehe TESM_VERSION weiter oben für dasselbe
|
||
Prinzip: Neuladen ist ein bewusster, seltener Vorgang, kein Read-Pfad)."""
|
||
global _license_state
|
||
try:
|
||
with open(LICENSE_PATH, "r", encoding="utf-8") as f:
|
||
license_file = json.load(f)
|
||
except (OSError, ValueError):
|
||
_license_state = {"file": None, "valid": False, "status": None, "error": None}
|
||
return
|
||
if not licensing.verify_license_file(license_file):
|
||
_license_state = {
|
||
"file": license_file, "valid": False, "status": None,
|
||
"error": "Signatur ungültig -- Lizenzdatei beschädigt oder manipuliert.",
|
||
}
|
||
return
|
||
_license_state = {
|
||
"file": license_file, "valid": True,
|
||
"status": licensing.license_status(license_file), "error": None,
|
||
}
|
||
|
||
|
||
_load_license()
|
||
|
||
|
||
def _write_license_file(license_file):
|
||
"""Schreibt eine neue, signaturgeprüfte Lizenzdatei nach LICENSE_PATH
|
||
und lädt den In-Memory-Zustand sofort neu (kein Dienst-Neustart
|
||
nötig -- anders als z.B. bei der Versionsanzeige, siehe TESM_VERSION)."""
|
||
with open(LICENSE_PATH, "w", encoding="utf-8") as f:
|
||
json.dump(license_file, f, indent=2)
|
||
try:
|
||
os.chmod(LICENSE_PATH, 0o600)
|
||
except OSError:
|
||
pass
|
||
_load_license()
|
||
|
||
|
||
def _delete_license_file():
|
||
"""Löscht die lokale Lizenzdatei unwiderruflich -- Schritt 5 der
|
||
Deaktivierung (siehe licensing.py-Moduldocstring): erst NACH
|
||
erfolgreich verifizierter Deaktivierungs-Bestätigung des Masters
|
||
aufrufen, nie vorher."""
|
||
try:
|
||
os.remove(LICENSE_PATH)
|
||
except OSError:
|
||
pass
|
||
set_setting("license_activation_status", "")
|
||
_load_license()
|
||
|
||
|
||
def license_activated():
|
||
"""True erst NACH einer beim Master erfolgreich abgeschlossenen
|
||
Aktivierung (online oder per Offline-Code) -- reines Hochladen einer
|
||
gültig signierten Lizenzdatei allein reicht laut Protokoll bewusst
|
||
NICHT aus, siehe licensing.py-Moduldocstring Schritt 5."""
|
||
return get_setting("license_activation_status", "") == "active"
|
||
|
||
|
||
def license_active():
|
||
"""Zentrales Gate für alles, was 'sichtbar, aber ohne Lizenz inaktiv'
|
||
ist (LDAP, NGINX, Export). True nur bei: signaturgültiger,
|
||
aktivierter Lizenz, deren Ablaufdatum entweder noch nicht erreicht ist
|
||
oder deren 30-tägige Gnadenfrist danach noch läuft
|
||
(licensing.GRACE_PERIOD_DAYS)."""
|
||
return bool(
|
||
_license_state["valid"]
|
||
and _license_state["status"]
|
||
and _license_state["status"]["modules_active"]
|
||
and license_activated()
|
||
)
|
||
|
||
|
||
def module_licensed(module_key):
|
||
"""Gate für die komplett ausgeblendeten Vollmodule (dhcp/fileshare/
|
||
maintenance) -- zusätzlich zu license_active() muss das Modul auch
|
||
tatsächlich in der Lizenz enthalten sein."""
|
||
return license_active() and module_key in _license_state["status"]["modules"]
|
||
|
||
|
||
def _require_module(module_key):
|
||
"""Serverseitiger Guard für die drei komplett ausgeblendeten Vollmodule
|
||
(dhcp/fileshare/maintenance) -- ein direkter URL-Aufruf ohne Lizenz
|
||
muss ebenso abgewiesen werden wie das reine Fehlen des Nav-Eintrags
|
||
(_nav_key_visible). Rückgabe: eine fertige Flash+Redirect-Response,
|
||
falls das Modul nicht lizenziert ist, sonst None -- Aufrufer prüft
|
||
`if resp := _require_module(...): return resp` nach dem eigentlichen
|
||
Berechtigungs-Check, exakt im gleichen Stil wie die übrigen
|
||
Permission-Guards in diesem File."""
|
||
if module_licensed(module_key):
|
||
return None
|
||
flash("Für dieses Modul ist keine gültige Lizenz vorhanden.", "danger")
|
||
return redirect(url_for("index"))
|
||
|
||
|
||
def license_export_allowed():
|
||
"""Export ist die EINE bewusste Ausnahme: bleibt auch im lizenzlosen
|
||
Zustand nutzbar, wenn dieser Zustand durch eine vom Kunden selbst
|
||
ausgelöste Deaktivierung (Systemwechsel-Fall) entstanden ist, damit
|
||
die eigenen Daten vor dem Umzug noch exportierbar sind. Sobald wieder
|
||
eine Lizenz aktiviert wird, greift ohnehin license_active() normal."""
|
||
return license_active() or get_setting("license_export_grace", "") == "1"
|
||
|
||
|
||
def license_info():
|
||
"""Für Topbar/Settings-Seite: kompletter aktueller Rohzustand."""
|
||
return _license_state
|
||
|
||
|
||
app.jinja_env.globals["license_active"] = license_active
|
||
app.jinja_env.globals["module_licensed"] = module_licensed
|
||
app.jinja_env.globals["license_export_allowed"] = license_export_allowed
|
||
|
||
|
||
def _license_api_post(endpoint, path, payload, timeout=10):
|
||
"""Einfacher JSON-POST gegen den Lizenzserver über die Python-
|
||
Standardbibliothek (urllib) -- bewusst ohne zusätzliche Abhängigkeit
|
||
wie 'requests', da genau drei schmale Aufrufe (activate/deactivate/
|
||
heartbeat) das nicht rechtfertigen. Wirft bei jedem Fehler (Netzwerk,
|
||
Timeout, HTTP-Fehlerstatus, kaputtes JSON) eine Exception -- der
|
||
Aufrufer entscheidet, ob das den Online- oder den Offline-Code-Pfad
|
||
bedeutet."""
|
||
url = endpoint.rstrip("/") + path
|
||
data = json.dumps(payload).encode("utf-8")
|
||
req = urllib.request.Request(url, data=data, headers={"Content-Type": "application/json"}, method="POST")
|
||
with urllib.request.urlopen(req, timeout=timeout) as resp:
|
||
return json.loads(resp.read().decode("utf-8"))
|
||
|
||
|
||
def _send_heartbeat():
|
||
"""Ein einzelner Heartbeat-Versuch gegen den in der Lizenz hinterlegten
|
||
master_endpoint. Bei Erfolg: license_last_heartbeat_at aktualisieren
|
||
und eine ggf. mitgeschickte aktualisierte Lizenz NUR nach eigener
|
||
Signaturprüfung übernehmen. Bei Fehlschlag lediglich den Fehler
|
||
vermerken -- ein ausbleibender Heartbeat schaltet für sich genommen
|
||
nie ein Modul ab (siehe licensing.heartbeat_stale_warning, rein
|
||
informativ in der Topbar)."""
|
||
license_file = _license_state.get("file")
|
||
if not license_file or not _license_state.get("valid") or not license_activated():
|
||
return
|
||
endpoint = license_file.get("master_endpoint", "")
|
||
if not endpoint:
|
||
return
|
||
request_payload = licensing.build_client_request("heartbeat", license_file)
|
||
try:
|
||
response_payload = _license_api_post(endpoint, "/api/heartbeat", request_payload)
|
||
except Exception as exc:
|
||
set_setting("license_last_error", f"Heartbeat fehlgeschlagen: {exc}")
|
||
return
|
||
|
||
if not licensing.verify_master_response(response_payload, license_file.get("master_pubkey", "")):
|
||
set_setting("license_last_error", "Heartbeat-Antwort mit ungültiger Signatur erhalten -- ignoriert.")
|
||
return
|
||
|
||
set_setting("license_last_heartbeat_at", datetime.now().strftime("%Y-%m-%d %H:%M:%S"))
|
||
set_setting("license_last_error", "")
|
||
|
||
if response_payload.get("status") == "revoked":
|
||
log_action_system("license.revoked_remote", license_file.get("license_id"),
|
||
"Lizenz vom Lizenzserver per Heartbeat widerrufen.")
|
||
_delete_license_file()
|
||
return
|
||
|
||
license_update = response_payload.get("license_update")
|
||
if license_update and licensing.verify_license_file(license_update):
|
||
_write_license_file(license_update)
|
||
log_action_system("license.updated_remote", license_update.get("license_id"),
|
||
"Aktualisierte Lizenz per Heartbeat übernommen.")
|
||
|
||
|
||
def _license_heartbeat_loop():
|
||
"""Läuft alle LICENSE_HEARTBEAT_INTERVAL_SECONDS (6h, siehe Plan) und
|
||
direkt einmal beim Start, analog zu _audit_archive_loop weiter oben."""
|
||
while True:
|
||
try:
|
||
_send_heartbeat()
|
||
except Exception:
|
||
app.logger.error("Lizenz-Heartbeat fehlgeschlagen:\n%s", traceback.format_exc())
|
||
time.sleep(LICENSE_HEARTBEAT_INTERVAL_SECONDS)
|
||
|
||
|
||
if _IS_WEB_PROCESS:
|
||
threading.Thread(target=_license_heartbeat_loop, daemon=True).start()
|
||
|
||
|
||
# ==================================================== Master-Lizenzserver ==
|
||
# Ab hier: alles, was NUR der Master braucht -- der Signaturschlüssel zum
|
||
# Ausstellen von Kundenlizenzen, Kunden-/Lizenz-Datenbankzugriff, die
|
||
# Aktivierungs-/Deaktivierungs-/Heartbeat-API für TESM-Clients (online per
|
||
# JSON-POST) sowie deren manuelle Offline-Code-Variante über die GUI. Die
|
||
# Funktionen weiter oben (license_active(), _send_heartbeat() usw.) gelten
|
||
# unverändert auch hier -- sie prüfen die EIGENE Bootstrap-Lizenz dieses
|
||
# Masters (siehe create_master_license.py), komplett unabhängig von den
|
||
# Kundenlizenzen, die der Master unten selbst ausstellt/verwaltet.
|
||
|
||
MASTER_SIGNING_KEY_PATH = os.environ.get(
|
||
"TESM_LICENSE_MASTER_KEY", os.path.join(BASE_DIR, "master_signing_key.json")
|
||
)
|
||
|
||
|
||
def _load_or_create_master_signing_key():
|
||
"""Der EINE Master-Signaturschlüssel dieser Installation -- signiert
|
||
jede ausgestellte Kundenlizenz UND jede Aktivierungs-/Deaktivierungs-/
|
||
Heartbeat-Antwort (siehe licensing.py-Moduldocstring). Wird beim ersten
|
||
Zugriff einmalig erzeugt und danach NIE automatisch rotiert: jede
|
||
Kundenlizenz trägt den zum Ausstellungszeitpunkt aktuellen
|
||
master_pubkey fest eingebettet -- ein späterer Schlüsselwechsel würde
|
||
die Offline-Verifikation künftiger Aktivierungs-/Heartbeat-Antworten
|
||
für ALLE bereits ausgegebenen Lizenzen brechen."""
|
||
try:
|
||
with open(MASTER_SIGNING_KEY_PATH, "r", encoding="utf-8") as f:
|
||
data = json.load(f)
|
||
return data["private"], data["public"]
|
||
except (OSError, ValueError, KeyError):
|
||
priv, pub = licensing.generate_keypair()
|
||
with open(MASTER_SIGNING_KEY_PATH, "w", encoding="utf-8") as f:
|
||
json.dump({"private": priv, "public": pub}, f)
|
||
try:
|
||
os.chmod(MASTER_SIGNING_KEY_PATH, 0o600)
|
||
except OSError:
|
||
pass
|
||
return priv, pub
|
||
|
||
|
||
MASTER_PRIVATE_KEY, MASTER_PUBLIC_KEY = _load_or_create_master_signing_key()
|
||
|
||
LICENSE_TYPE_LABELS = {"trial": "Trial", "standard": "Standard", "custom": "Custom", "enterprise": "Enterprise"}
|
||
MODULE_LABELS = {"dhcp": "DHCP", "fileshare": "Dateifreigaben", "maintenance": "Wartung"}
|
||
|
||
|
||
def _get_vendor_info():
|
||
"""Anbieter-Kontaktdaten -- einmal zentral in den Systemeinstellungen
|
||
gepflegt statt bei jeder Ausstellung erneut eingegeben (steht in jeder
|
||
ausgestellten Lizenz, siehe licensing.issue_license)."""
|
||
return {
|
||
"name": get_setting("vendor_name", ""),
|
||
"phone": get_setting("vendor_phone", ""),
|
||
"email": get_setting("vendor_email", ""),
|
||
"address": get_setting("vendor_address", ""),
|
||
"logo_base64": get_setting("vendor_logo_base64", ""),
|
||
}
|
||
|
||
|
||
def _get_master_endpoint():
|
||
"""Die von aussen erreichbare URL DIESES Masters -- wird in jede
|
||
ausgestellte Lizenz eingebettet, damit der Client weiss, wo er
|
||
aktivieren/heartbeaten soll. Admin-gepflegt statt hart codiert, da
|
||
jede Installation ihre eigene Domain hat."""
|
||
return get_setting("master_endpoint", "")
|
||
|
||
|
||
def _customer_row(conn, customer_id):
|
||
return conn.execute("SELECT * FROM license_customers WHERE id=?", (customer_id,)).fetchone()
|
||
|
||
|
||
def _license_row(conn, license_id):
|
||
return conn.execute("SELECT * FROM licenses WHERE license_id=?", (license_id,)).fetchone()
|
||
|
||
|
||
def _license_dict_for_display(row, now=None):
|
||
"""Reichert eine DB-Zeile aus 'licenses' um abgeleitete Anzeigefelder
|
||
an (Modul-Liste, Resttage) -- teilt sich Dashboard und Detailseite."""
|
||
now = now if now is not None else time.time()
|
||
d = dict(row)
|
||
try:
|
||
d["modules_list"] = json.loads(d.get("modules") or "[]")
|
||
except ValueError:
|
||
d["modules_list"] = []
|
||
try:
|
||
expires_ts = datetime.strptime(d["expires_at"], "%Y-%m-%dT%H:%M:%SZ").timestamp()
|
||
d["days_left"] = int((expires_ts - now) / 86400)
|
||
d["is_expired"] = expires_ts < now
|
||
except (ValueError, TypeError):
|
||
d["days_left"] = None
|
||
d["is_expired"] = False
|
||
return d
|
||
|
||
|
||
def _issue_license_for_customer(conn, customer_id, license_type, modules, valid_days, role="customer"):
|
||
"""Erzeugt + signiert eine neue Lizenz (licensing.issue_license) und
|
||
trägt sie in der Master-DB ein (Status 'issued' -- erst nach
|
||
tatsächlicher Aktivierung durch den Client wird daraus 'active', siehe
|
||
_process_activate weiter unten)."""
|
||
customer = _customer_row(conn, customer_id)
|
||
license_file, license_pubkey = licensing.issue_license(
|
||
customer={"name": customer["name"], "contact_email": customer["contact_email"] or ""},
|
||
license_type=license_type,
|
||
modules=modules,
|
||
valid_days=valid_days,
|
||
master_private_key_b64=MASTER_PRIVATE_KEY,
|
||
master_public_key_b64=MASTER_PUBLIC_KEY,
|
||
master_endpoint=_get_master_endpoint(),
|
||
vendor=_get_vendor_info(),
|
||
)
|
||
now = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
||
conn.execute(
|
||
"INSERT INTO licenses (license_id, customer_id, type, modules, issued_at, expires_at, "
|
||
"license_pubkey, license_file_json, status, role, created_by, created_at) "
|
||
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'issued', ?, ?, ?)",
|
||
(
|
||
license_file["license_id"], customer_id, license_type, json.dumps(sorted(modules)),
|
||
license_file["issued_at"], license_file["expires_at"], license_pubkey,
|
||
json.dumps(license_file), role,
|
||
current_user.username if current_user.is_authenticated else "system",
|
||
now,
|
||
),
|
||
)
|
||
conn.commit()
|
||
log_action("license.issue", customer["name"], f"{LICENSE_TYPE_LABELS.get(license_type, license_type)}, {license_file['expires_at']}")
|
||
return license_file
|
||
|
||
|
||
# --- Aktivierungs-/Deaktivierungs-/Heartbeat-Verarbeitung -------------------
|
||
# Ein Protokoll, zwei Transportwege (siehe licensing.py-Moduldocstring):
|
||
# dieselben drei Funktionen bedienen sowohl die Online-JSON-API
|
||
# (/api/activate|deactivate|heartbeat, von TESM-Clients automatisiert
|
||
# aufgerufen) als auch die manuelle Offline-Code-Seite weiter unten
|
||
# (Admin tippt den vom Kunden erhaltenen Code ab). Rückgabe jeweils
|
||
# (response_dict, http_status) -- bei Erfolg ist response_dict bereits die
|
||
# fertige, signierte Master-Antwort (licensing.build_master_response).
|
||
|
||
def _process_activate(data):
|
||
license_id = data.get("license_id")
|
||
conn = get_db_connection()
|
||
row = _license_row(conn, license_id)
|
||
if not row:
|
||
conn.close()
|
||
return {"error": "unknown_license"}, 404
|
||
if not licensing.verify_client_request(data, row["license_pubkey"]):
|
||
conn.close()
|
||
return {"error": "bad_signature"}, 400
|
||
if data.get("action") != "activate":
|
||
conn.close()
|
||
return {"error": "wrong_action"}, 400
|
||
if row["status"] == "revoked":
|
||
conn.close()
|
||
return {"error": "revoked"}, 409
|
||
fingerprint = data.get("fingerprint", "")
|
||
if row["status"] == "active" and row["fingerprint"] and row["fingerprint"] != fingerprint:
|
||
conn.close()
|
||
return {"error": "already_bound_elsewhere"}, 409
|
||
now = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
||
conn.execute(
|
||
"UPDATE licenses SET status='active', fingerprint=?, activated_at=? WHERE license_id=?",
|
||
(fingerprint, now, license_id),
|
||
)
|
||
conn.commit()
|
||
customer = _customer_row(conn, row["customer_id"])
|
||
conn.close()
|
||
log_action_system("license.activated_remote", customer["name"] if customer else license_id, f"Fingerprint {fingerprint}")
|
||
return licensing.build_master_response("activated", license_id, fingerprint, MASTER_PRIVATE_KEY, status="ok"), 200
|
||
|
||
|
||
def _process_deactivate(data):
|
||
license_id = data.get("license_id")
|
||
conn = get_db_connection()
|
||
row = _license_row(conn, license_id)
|
||
if not row:
|
||
conn.close()
|
||
return {"error": "unknown_license"}, 404
|
||
if not licensing.verify_client_request(data, row["license_pubkey"]):
|
||
conn.close()
|
||
return {"error": "bad_signature"}, 400
|
||
if data.get("action") != "deactivate":
|
||
conn.close()
|
||
return {"error": "wrong_action"}, 400
|
||
fingerprint = data.get("fingerprint", "")
|
||
if row["status"] != "active" or row["fingerprint"] != fingerprint:
|
||
conn.close()
|
||
return {"error": "not_active_on_this_fingerprint"}, 409
|
||
now = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
||
conn.execute(
|
||
"UPDATE licenses SET status='deactivated', deactivated_at=? WHERE license_id=?",
|
||
(now, license_id),
|
||
)
|
||
conn.commit()
|
||
customer = _customer_row(conn, row["customer_id"])
|
||
conn.close()
|
||
log_action_system("license.deactivated_remote", customer["name"] if customer else license_id, f"Fingerprint {fingerprint}")
|
||
return licensing.build_master_response("deactivated", license_id, fingerprint, MASTER_PRIVATE_KEY, status="ok"), 200
|
||
|
||
|
||
def _process_heartbeat(data):
|
||
license_id = data.get("license_id")
|
||
conn = get_db_connection()
|
||
row = _license_row(conn, license_id)
|
||
if not row:
|
||
conn.close()
|
||
return {"error": "unknown_license"}, 404
|
||
if not licensing.verify_client_request(data, row["license_pubkey"]):
|
||
conn.close()
|
||
return {"error": "bad_signature"}, 400
|
||
fingerprint = data.get("fingerprint", "")
|
||
|
||
if row["status"] == "revoked":
|
||
conn.close()
|
||
# Signierte Antwort mit status="revoked" -- der Client (siehe
|
||
# _send_heartbeat oben) löscht seine Lizenzdatei erst NACH
|
||
# erfolgreicher eigener Signaturprüfung dieser Antwort.
|
||
return licensing.build_master_response(
|
||
"heartbeat_ok", license_id, fingerprint, MASTER_PRIVATE_KEY, status="revoked"
|
||
), 200
|
||
|
||
if row["status"] != "active" or row["fingerprint"] != fingerprint:
|
||
conn.close()
|
||
return {"error": "not_active"}, 409
|
||
|
||
now = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
||
conn.execute("UPDATE licenses SET last_heartbeat_at=? WHERE license_id=?", (now, license_id))
|
||
conn.commit()
|
||
license_update = json.loads(row["license_file_json"])
|
||
conn.close()
|
||
return licensing.build_master_response(
|
||
"heartbeat_ok", license_id, fingerprint, MASTER_PRIVATE_KEY, status="ok", license_update=license_update
|
||
), 200
|
||
|
||
|
||
@app.route("/api/activate", methods=["POST"])
|
||
def api_activate():
|
||
"""Online-Aktivierung eines TESM-Clients -- unauthentifiziert per
|
||
Design (die Signatur der Anfrage selbst ist der Berechtigungsnachweis,
|
||
siehe _process_activate)."""
|
||
response, status = _process_activate(request.get_json(silent=True) or {})
|
||
return jsonify(response), status
|
||
|
||
|
||
@app.route("/api/deactivate", methods=["POST"])
|
||
def api_deactivate():
|
||
response, status = _process_deactivate(request.get_json(silent=True) or {})
|
||
return jsonify(response), status
|
||
|
||
|
||
@app.route("/api/heartbeat", methods=["POST"])
|
||
def api_heartbeat():
|
||
response, status = _process_heartbeat(request.get_json(silent=True) or {})
|
||
return jsonify(response), status
|
||
|
||
|
||
@app.route("/licenses/manual-code", methods=["GET", "POST"])
|
||
@login_required
|
||
def license_manual_code():
|
||
"""Offline-Gegenstück zu den drei /api/*-Routen: der Admin erhält von
|
||
einem Kunden ohne Netzwerkzugriff auf diesen Master einen Code
|
||
(telefonisch/per Mail), trägt ihn hier ein und gibt den daraufhin
|
||
erzeugten Antwortcode an den Kunden zurück (siehe settings_license.html
|
||
auf der Client-Seite -- exakt symmetrischer Ablauf)."""
|
||
if not current_user.has_permission("licenses.edit"):
|
||
flash("Keine Berechtigung, Aktivierungscodes zu verarbeiten.", "danger")
|
||
return redirect(url_for("index"))
|
||
result_code = None
|
||
processed_action = None
|
||
if request.method == "POST":
|
||
raw_code = request.form.get("client_code", "").strip()
|
||
try:
|
||
data = licensing.decode_code(raw_code)
|
||
except Exception:
|
||
flash("Code konnte nicht gelesen werden -- bitte vollständig kopieren.", "danger")
|
||
else:
|
||
action = data.get("action")
|
||
processors = {"activate": _process_activate, "deactivate": _process_deactivate, "heartbeat": _process_heartbeat}
|
||
processor = processors.get(action)
|
||
if not processor:
|
||
flash("Unbekannte Aktion im Code.", "danger")
|
||
else:
|
||
response, status = processor(data)
|
||
if status == 200:
|
||
result_code = licensing.encode_code(response)
|
||
processed_action = action
|
||
flash("Verarbeitet -- Antwortcode unten an den Kunden weitergeben.", "success")
|
||
else:
|
||
flash(f"Fehler: {response.get('error', status)}", "danger")
|
||
return render_template("license_manual_code.html", result_code=result_code, processed_action=processed_action)
|
||
|
||
|
||
@app.route("/customers", methods=["GET", "POST"])
|
||
@login_required
|
||
def customers():
|
||
if request.method == "GET" and not current_user.can_manage_customers:
|
||
flash("Keine Berechtigung, Kunden anzusehen.", "danger")
|
||
return redirect(url_for("index"))
|
||
conn = get_db_connection()
|
||
if request.method == "POST":
|
||
if "new_customer" in request.form:
|
||
if not current_user.has_permission("customers.create"):
|
||
flash("Keine Berechtigung, Kunden anzulegen.", "danger")
|
||
else:
|
||
name = request.form.get("name", "").strip()
|
||
if not name:
|
||
flash("Name ist erforderlich.", "danger")
|
||
else:
|
||
conn.execute(
|
||
"INSERT INTO license_customers (name, contact_email, contact_phone, notes, created_at) "
|
||
"VALUES (?, ?, ?, ?, ?)",
|
||
(name, request.form.get("contact_email", "").strip(),
|
||
request.form.get("contact_phone", "").strip(), request.form.get("notes", "").strip(),
|
||
datetime.now().strftime("%Y-%m-%d %H:%M:%S")),
|
||
)
|
||
conn.commit()
|
||
log_action("customer.create", name)
|
||
flash(f"Kunde „{name}“ angelegt.", "success")
|
||
elif "edit_customer" in request.form:
|
||
if not current_user.has_permission("customers.edit"):
|
||
flash("Keine Berechtigung, Kunden zu ändern.", "danger")
|
||
else:
|
||
customer_id = int(request.form["edit_customer"])
|
||
name = request.form.get("name", "").strip()
|
||
conn.execute(
|
||
"UPDATE license_customers SET name=?, contact_email=?, contact_phone=?, notes=? WHERE id=?",
|
||
(name, request.form.get("contact_email", "").strip(),
|
||
request.form.get("contact_phone", "").strip(), request.form.get("notes", "").strip(),
|
||
customer_id),
|
||
)
|
||
conn.commit()
|
||
log_action("customer.update", name)
|
||
flash("Kunde aktualisiert.", "success")
|
||
elif "delete_customer" in request.form:
|
||
if not current_user.has_permission("customers.edit"):
|
||
flash("Keine Berechtigung, Kunden zu löschen.", "danger")
|
||
else:
|
||
customer_id = int(request.form["delete_customer"])
|
||
if conn.execute("SELECT 1 FROM licenses WHERE customer_id=?", (customer_id,)).fetchone():
|
||
flash("Kunde hat noch Lizenzen -- kann nicht gelöscht werden.", "danger")
|
||
else:
|
||
conn.execute("DELETE FROM license_customers WHERE id=?", (customer_id,))
|
||
conn.commit()
|
||
log_action("customer.delete", str(customer_id))
|
||
flash("Kunde gelöscht.", "success")
|
||
conn.close()
|
||
return redirect(url_for("customers"))
|
||
|
||
customers_rows = conn.execute("""
|
||
SELECT license_customers.*, COUNT(licenses.id) AS license_count
|
||
FROM license_customers
|
||
LEFT JOIN licenses ON licenses.customer_id = license_customers.id
|
||
GROUP BY license_customers.id
|
||
ORDER BY license_customers.name COLLATE NOCASE
|
||
""").fetchall()
|
||
conn.close()
|
||
return render_template(
|
||
"customers.html", customers=customers_rows,
|
||
can_create=current_user.has_permission("customers.create"),
|
||
can_edit=current_user.has_permission("customers.edit"),
|
||
)
|
||
|
||
|
||
@app.route("/licenses/issue", methods=["GET", "POST"])
|
||
@login_required
|
||
def license_issue():
|
||
if not current_user.has_permission("licenses.create"):
|
||
flash("Keine Berechtigung, Lizenzen auszustellen.", "danger")
|
||
return redirect(url_for("index"))
|
||
conn = get_db_connection()
|
||
if request.method == "POST":
|
||
if not license_active():
|
||
flash("Der Lizenzserver selbst hat keine gültige Lizenz -- Ausstellen ist deaktiviert.", "danger")
|
||
conn.close()
|
||
return redirect(url_for("license_issue"))
|
||
|
||
customer_id = request.form.get("customer_id", "")
|
||
if customer_id == "__new__":
|
||
new_customer_name = request.form.get("new_customer_name", "").strip()
|
||
if not new_customer_name:
|
||
flash("Bitte einen Namen für den neuen Kunden angeben.", "danger")
|
||
conn.close()
|
||
return redirect(url_for("license_issue"))
|
||
cur = conn.execute(
|
||
"INSERT INTO license_customers (name, contact_email, created_at) VALUES (?, ?, ?)",
|
||
(new_customer_name, request.form.get("new_customer_email", "").strip(),
|
||
datetime.now().strftime("%Y-%m-%d %H:%M:%S")),
|
||
)
|
||
conn.commit()
|
||
customer_id = cur.lastrowid
|
||
log_action("customer.create", new_customer_name, "beim Ausstellen einer Lizenz neu angelegt")
|
||
else:
|
||
try:
|
||
customer_id = int(customer_id)
|
||
except ValueError:
|
||
flash("Ungültiger Kunde.", "danger")
|
||
conn.close()
|
||
return redirect(url_for("license_issue"))
|
||
|
||
license_type = request.form.get("license_type", "standard")
|
||
if license_type not in licensing.LICENSE_TYPES:
|
||
flash("Ungültiger Lizenztyp.", "danger")
|
||
conn.close()
|
||
return redirect(url_for("license_issue"))
|
||
if license_type == "enterprise":
|
||
modules = list(licensing.ALL_MODULES)
|
||
elif license_type == "custom":
|
||
modules = [m for m in request.form.getlist("modules") if m in licensing.ALL_MODULES]
|
||
else:
|
||
modules = []
|
||
try:
|
||
valid_days = max(1, int(request.form.get("valid_days", "365")))
|
||
except ValueError:
|
||
valid_days = 365
|
||
|
||
license_file = _issue_license_for_customer(conn, customer_id, license_type, modules, valid_days)
|
||
conn.close()
|
||
flash("Lizenz ausgestellt.", "success")
|
||
return redirect(url_for("license_detail", license_id=license_file["license_id"]))
|
||
|
||
customers_rows = conn.execute("SELECT * FROM license_customers ORDER BY name COLLATE NOCASE").fetchall()
|
||
conn.close()
|
||
return render_template(
|
||
"license_issue.html", customers=customers_rows, license_types=licensing.LICENSE_TYPES,
|
||
all_modules=licensing.ALL_MODULES, module_labels=MODULE_LABELS, type_labels=LICENSE_TYPE_LABELS,
|
||
master_licensed=license_active(),
|
||
preselect_customer_id=request.args.get("customer_id", type=int),
|
||
)
|
||
|
||
|
||
@app.route("/licenses/<license_id>")
|
||
@login_required
|
||
def license_detail(license_id):
|
||
if not current_user.can_manage_licenses:
|
||
flash("Keine Berechtigung.", "danger")
|
||
return redirect(url_for("index"))
|
||
conn = get_db_connection()
|
||
row = _license_row(conn, license_id)
|
||
if not row:
|
||
conn.close()
|
||
flash("Lizenz nicht gefunden.", "danger")
|
||
return redirect(url_for("index"))
|
||
customer = _customer_row(conn, row["customer_id"])
|
||
conn.close()
|
||
return render_template(
|
||
"license_detail.html", license=_license_dict_for_display(row), customer=customer,
|
||
type_labels=LICENSE_TYPE_LABELS, module_labels=MODULE_LABELS,
|
||
can_edit=current_user.has_permission("licenses.edit"),
|
||
)
|
||
|
||
|
||
@app.route("/licenses/<license_id>/download")
|
||
@login_required
|
||
def license_download(license_id):
|
||
if not current_user.can_manage_licenses:
|
||
flash("Keine Berechtigung.", "danger")
|
||
return redirect(url_for("index"))
|
||
conn = get_db_connection()
|
||
row = _license_row(conn, license_id)
|
||
conn.close()
|
||
if not row:
|
||
flash("Lizenz nicht gefunden.", "danger")
|
||
return redirect(url_for("index"))
|
||
content = row["license_file_json"].encode("utf-8")
|
||
return app.response_class(
|
||
content, mimetype="application/json",
|
||
headers={"Content-Disposition": f'attachment; filename="license-{license_id}.json"'},
|
||
)
|
||
|
||
|
||
GRAPH_TOKEN_URL = "https://login.microsoftonline.com/{tenant}/oauth2/v2.0/token"
|
||
GRAPH_SEND_MAIL_URL = "https://graph.microsoft.com/v1.0/users/{sender}/sendMail"
|
||
|
||
|
||
def _graph_settings():
|
||
return {
|
||
"tenant_id": get_setting("graph_tenant_id", ""),
|
||
"client_id": get_setting("graph_client_id", ""),
|
||
"client_secret": get_setting("graph_client_secret", ""),
|
||
"sender_mailbox": get_setting("graph_sender_mailbox", ""),
|
||
}
|
||
|
||
|
||
def _graph_get_token(cfg):
|
||
"""Client-Credentials-Flow (App-Berechtigung, kein Benutzer-Login
|
||
nötig -- deshalb funktioniert das auch mit MFA-pflichtigen Tenants,
|
||
siehe Anleitung auf der Einstellungen-Seite). Reine Standardbibliothek
|
||
(urllib), keine zusätzliche Abhängigkeit wie 'msal'."""
|
||
url = GRAPH_TOKEN_URL.format(tenant=cfg["tenant_id"])
|
||
data = urllib.parse.urlencode({
|
||
"client_id": cfg["client_id"],
|
||
"client_secret": cfg["client_secret"],
|
||
"scope": "https://graph.microsoft.com/.default",
|
||
"grant_type": "client_credentials",
|
||
}).encode("utf-8")
|
||
req = urllib.request.Request(url, data=data, headers={"Content-Type": "application/x-www-form-urlencoded"}, method="POST")
|
||
with urllib.request.urlopen(req, timeout=15) as resp:
|
||
payload = json.loads(resp.read().decode("utf-8"))
|
||
return payload["access_token"]
|
||
|
||
|
||
def _graph_send_mail(to_email, subject, body_text, attachment_bytes=None, attachment_name=None):
|
||
cfg = _graph_settings()
|
||
if not all([cfg["tenant_id"], cfg["client_id"], cfg["client_secret"], cfg["sender_mailbox"]]):
|
||
raise RuntimeError("Microsoft-Graph-Zugangsdaten unvollständig -- siehe Systemeinstellungen.")
|
||
token = _graph_get_token(cfg)
|
||
message = {
|
||
"message": {
|
||
"subject": subject,
|
||
"body": {"contentType": "Text", "content": body_text},
|
||
"toRecipients": [{"emailAddress": {"address": to_email}}],
|
||
}
|
||
}
|
||
if attachment_bytes:
|
||
message["message"]["attachments"] = [{
|
||
"@odata.type": "#microsoft.graph.fileAttachment",
|
||
"name": attachment_name or "license.json",
|
||
"contentBytes": base64.b64encode(attachment_bytes).decode("ascii"),
|
||
}]
|
||
url = GRAPH_SEND_MAIL_URL.format(sender=cfg["sender_mailbox"])
|
||
req = urllib.request.Request(
|
||
url, data=json.dumps(message).encode("utf-8"),
|
||
headers={"Content-Type": "application/json", "Authorization": f"Bearer {token}"},
|
||
method="POST",
|
||
)
|
||
with urllib.request.urlopen(req, timeout=20):
|
||
pass
|
||
|
||
|
||
@app.route("/settings/email/test", methods=["POST"])
|
||
@login_required
|
||
def settings_email_test():
|
||
if not current_user.has_permission("settings_system.edit"):
|
||
flash("Keine Berechtigung.", "danger")
|
||
return redirect(url_for("settings"))
|
||
try:
|
||
cfg = _graph_settings()
|
||
if not all([cfg["tenant_id"], cfg["client_id"], cfg["client_secret"], cfg["sender_mailbox"]]):
|
||
raise RuntimeError("Bitte zuerst alle vier Felder ausfüllen und speichern.")
|
||
_graph_get_token(cfg)
|
||
flash("Verbindung erfolgreich -- Token konnte abgerufen werden.", "success")
|
||
except Exception as exc:
|
||
flash(f"Verbindung fehlgeschlagen: {exc}", "danger")
|
||
return redirect(url_for("settings"))
|
||
|
||
|
||
@app.route("/licenses/<license_id>/send-email", methods=["POST"])
|
||
@login_required
|
||
def license_send_email(license_id):
|
||
if not current_user.has_permission("licenses.edit"):
|
||
flash("Keine Berechtigung.", "danger")
|
||
return redirect(url_for("index"))
|
||
conn = get_db_connection()
|
||
row = _license_row(conn, license_id)
|
||
customer = _customer_row(conn, row["customer_id"]) if row else None
|
||
conn.close()
|
||
if not row or not customer:
|
||
flash("Lizenz nicht gefunden.", "danger")
|
||
return redirect(url_for("index"))
|
||
if not customer["contact_email"]:
|
||
flash("Für diesen Kunden ist keine E-Mail-Adresse hinterlegt.", "danger")
|
||
return redirect(url_for("license_detail", license_id=license_id))
|
||
try:
|
||
_graph_send_mail(
|
||
customer["contact_email"],
|
||
f"Ihre TESM-Lizenz ({LICENSE_TYPE_LABELS.get(row['type'], row['type'])})",
|
||
f"Anbei Ihre Lizenzdatei. Bitte auf der Zielinstanz unter Einstellungen → Lizenz hochladen und aktivieren.\n\n"
|
||
f"Lizenz-ID: {license_id}\nTyp: {LICENSE_TYPE_LABELS.get(row['type'], row['type'])}\nGültig bis: {row['expires_at']}\n",
|
||
attachment_bytes=row["license_file_json"].encode("utf-8"),
|
||
attachment_name=f"license-{license_id}.json",
|
||
)
|
||
log_action("license.email", customer["name"], customer["contact_email"])
|
||
flash(f"Lizenz per E-Mail an {customer['contact_email']} gesendet.", "success")
|
||
except Exception as exc:
|
||
flash(f"Versand fehlgeschlagen: {exc}", "danger")
|
||
return redirect(url_for("license_detail", license_id=license_id))
|
||
|
||
|
||
@app.route("/licenses/<license_id>/revoke", methods=["POST"])
|
||
@login_required
|
||
def license_revoke(license_id):
|
||
if not current_user.has_permission("licenses.edit"):
|
||
flash("Keine Berechtigung.", "danger")
|
||
return redirect(url_for("index"))
|
||
conn = get_db_connection()
|
||
row = _license_row(conn, license_id)
|
||
if row:
|
||
conn.execute(
|
||
"UPDATE licenses SET status='revoked', revoked_at=? WHERE license_id=?",
|
||
(datetime.now().strftime("%Y-%m-%d %H:%M:%S"), license_id),
|
||
)
|
||
conn.commit()
|
||
customer = _customer_row(conn, row["customer_id"])
|
||
log_action("license.revoke", customer["name"] if customer else license_id,
|
||
"Wird beim nächsten Heartbeat automatisch delizensiert.")
|
||
flash("Lizenz widerrufen.", "success")
|
||
else:
|
||
flash("Lizenz nicht gefunden.", "danger")
|
||
conn.close()
|
||
return redirect(url_for("license_detail", license_id=license_id))
|
||
|
||
|
||
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()
|
||
|
||
|
||
@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)
|
||
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)
|
||
return redirect(url_for("index"))
|
||
except sqlite3.IntegrityError:
|
||
conn.close()
|
||
flash("Ungültiger Benutzername oder Passwort", "danger")
|
||
else:
|
||
conn.close()
|
||
flash("Ungültiger Benutzername oder Passwort", "danger")
|
||
return render_template("login.html")
|
||
|
||
|
||
@app.route("/logout")
|
||
@login_required
|
||
def logout():
|
||
logout_user()
|
||
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
|
||
|
||
|
||
@app.context_processor
|
||
def inject_license_topbar():
|
||
"""Lizenz-Ampel neben dem Prüfintervall-Countdown -- für jeden
|
||
angemeldeten Benutzer sichtbar, unabhängig von Rechten (genau wie der
|
||
Countdown selbst). Prioritätsreihenfolge exakt wie im Lizenz-Plan
|
||
festgelegt: fehlt > abgelaufen > läuft in ≤30 Tagen ab > Heartbeat seit
|
||
≥14 Tagen ausgeblieben (rein informativ) > alles in Ordnung (keine
|
||
Anzeige)."""
|
||
if not current_user.is_authenticated:
|
||
return {"license_topbar": None}
|
||
|
||
state = _license_state
|
||
if not state["valid"] or not license_activated():
|
||
return {"license_topbar": {"level": "danger", "blink": True, "text": "Lizenz fehlt"}}
|
||
|
||
status = state["status"]
|
||
if status["expired"]:
|
||
try:
|
||
expiry_str = datetime.strptime(state["file"]["expires_at"], "%Y-%m-%dT%H:%M:%SZ").strftime("%d.%m.")
|
||
except ValueError:
|
||
expiry_str = "?"
|
||
return {"license_topbar": {"level": "danger", "blink": True, "text": f"Lizenz abgelaufen (seit {expiry_str})"}}
|
||
|
||
if status["expiring_soon"]:
|
||
days_left = max(1, math.ceil(status["days_left"]))
|
||
plural = "en" if days_left != 1 else ""
|
||
return {"license_topbar": {"level": "warning", "blink": True, "text": f"Lizenz läuft in {days_left} Tag{plural} ab"}}
|
||
|
||
last_heartbeat = get_setting("license_last_heartbeat_at")
|
||
if last_heartbeat:
|
||
try:
|
||
last_ts = datetime.strptime(last_heartbeat, "%Y-%m-%d %H:%M:%S").timestamp()
|
||
except ValueError:
|
||
last_ts = None
|
||
if last_ts and licensing.heartbeat_stale_warning(last_ts):
|
||
days = int((time.time() - last_ts) / 86400)
|
||
return {"license_topbar": {
|
||
"level": "warning", "blink": False,
|
||
"text": f"Lizenzserver seit {days} Tag{'en' if days != 1 else ''} nicht erreichbar",
|
||
}}
|
||
|
||
return {"license_topbar": None}
|
||
|
||
|
||
@app.route("/")
|
||
def index():
|
||
"""Kunden-/Lizenzübersicht -- das Dashboard des Master-Lizenzservers.
|
||
Ersetzt die geräte-/PoE-spezifische Kachelansicht von TESM (siehe
|
||
Modul-Docstring oben) komplett."""
|
||
if not current_user.is_authenticated or not current_user.has_permission("licenses.view"):
|
||
return render_template("index.html", licenses=None)
|
||
conn = get_db_connection()
|
||
rows = conn.execute("""
|
||
SELECT licenses.*, license_customers.name AS customer_name
|
||
FROM licenses
|
||
JOIN license_customers ON license_customers.id = licenses.customer_id
|
||
ORDER BY licenses.created_at DESC
|
||
""").fetchall()
|
||
conn.close()
|
||
now = time.time()
|
||
licenses_view = [_license_dict_for_display(r, now) for r in rows]
|
||
return render_template(
|
||
"index.html", licenses=licenses_view, type_labels=LICENSE_TYPE_LABELS, module_labels=MODULE_LABELS,
|
||
can_create=current_user.has_permission("licenses.create"),
|
||
)
|
||
|
||
|
||
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)
|
||
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"))
|
||
|
||
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 "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."
|
||
)
|
||
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_vendor_settings" in request.form:
|
||
master_endpoint = request.form.get("master_endpoint", "").strip().rstrip("/")
|
||
set_setting("master_endpoint", master_endpoint)
|
||
set_setting("vendor_name", request.form.get("vendor_name", "").strip())
|
||
set_setting("vendor_phone", request.form.get("vendor_phone", "").strip())
|
||
set_setting("vendor_email", request.form.get("vendor_email", "").strip())
|
||
set_setting("vendor_address", request.form.get("vendor_address", "").strip())
|
||
logo_file = request.files.get("vendor_logo")
|
||
if logo_file and logo_file.filename:
|
||
logo_bytes = logo_file.read()
|
||
if len(logo_bytes) > 200 * 1024:
|
||
flash("Logo gespeichert -- ABER zu groß (max. 200 KB), bitte ein kleineres Bild wählen.", "danger")
|
||
else:
|
||
ext = os.path.splitext(logo_file.filename)[1].lstrip(".").lower() or "png"
|
||
mime = {"png": "image/png", "jpg": "image/jpeg", "jpeg": "image/jpeg", "svg": "image/svg+xml"}.get(ext, "image/png")
|
||
set_setting("vendor_logo_base64", f"data:{mime};base64,{base64.b64encode(logo_bytes).decode('ascii')}")
|
||
log_action("settings.update", "Anbieter & Lizenzserver")
|
||
flash("Anbieter- und Lizenzserver-Einstellungen gespeichert.", "success")
|
||
|
||
elif "save_graph_settings" in request.form:
|
||
set_setting("graph_tenant_id", request.form.get("graph_tenant_id", "").strip())
|
||
set_setting("graph_client_id", request.form.get("graph_client_id", "").strip())
|
||
set_setting("graph_client_secret", request.form.get("graph_client_secret", "").strip())
|
||
set_setting("graph_sender_mailbox", request.form.get("graph_sender_mailbox", "").strip())
|
||
log_action("settings.update", "E-Mail-Versand (Microsoft Graph)")
|
||
flash("E-Mail-Einstellungen gespeichert.", "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",
|
||
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,
|
||
},
|
||
current_timezone=_current_timezone(),
|
||
timezones=_list_timezones(),
|
||
full_nav_items=_ordered_nav_items() if current_user.is_admin else [],
|
||
vendor=_get_vendor_info(),
|
||
master_endpoint=_get_master_endpoint(),
|
||
graph=_graph_settings(),
|
||
)
|
||
|
||
|
||
@app.route("/settings/license")
|
||
@login_required
|
||
def settings_license():
|
||
if not current_user.can_view_settings_system:
|
||
flash("Keine Berechtigung, die Lizenz anzusehen.", "danger")
|
||
return redirect(url_for("index"))
|
||
pending_raw = get_setting("license_pending_request", "")
|
||
pending = json.loads(pending_raw) if pending_raw else None
|
||
return render_template(
|
||
"settings_license.html",
|
||
license_file=_license_state["file"],
|
||
license_valid=_license_state["valid"],
|
||
license_error=_license_state["error"],
|
||
license_status=_license_state["status"],
|
||
license_is_activated=license_activated(),
|
||
license_last_heartbeat_at=get_setting("license_last_heartbeat_at", ""),
|
||
license_last_error=get_setting("license_last_error", ""),
|
||
license_fingerprint=licensing.system_fingerprint(),
|
||
pending_action=pending,
|
||
pending_code=licensing.encode_code(pending) if pending else None,
|
||
)
|
||
|
||
|
||
@app.route("/settings/license/upload", methods=["POST"])
|
||
@login_required
|
||
def license_upload():
|
||
if not current_user.has_permission("settings_system.edit"):
|
||
flash("Keine Berechtigung, die Lizenz zu ändern.", "danger")
|
||
return redirect(url_for("settings_license"))
|
||
uploaded = request.files.get("license_file")
|
||
if not uploaded or not uploaded.filename:
|
||
flash("Bitte eine Lizenzdatei auswählen.", "danger")
|
||
return redirect(url_for("settings_license"))
|
||
try:
|
||
license_file = json.loads(uploaded.read().decode("utf-8"))
|
||
except (ValueError, UnicodeDecodeError):
|
||
flash("Datei ist keine gültige Lizenzdatei (kein lesbares JSON).", "danger")
|
||
return redirect(url_for("settings_license"))
|
||
if not licensing.verify_license_file(license_file):
|
||
flash("Lizenzdatei-Signatur ungültig -- Datei beschädigt, manipuliert oder von einem anderen Lizenzserver.", "danger")
|
||
return redirect(url_for("settings_license"))
|
||
_write_license_file(license_file)
|
||
set_setting("license_activation_status", "")
|
||
set_setting("license_pending_request", "")
|
||
customer_name = license_file.get("customer", {}).get("name", "?")
|
||
log_action("license.upload", license_file.get("license_id", ""), f"Lizenz für {customer_name} hochgeladen -- muss noch aktiviert werden.")
|
||
flash("Lizenzdatei eingespielt. Bitte jetzt aktivieren.", "success")
|
||
return redirect(url_for("settings_license"))
|
||
|
||
|
||
def _license_handshake(action, endpoint_path, success_setting_fn, success_log_action, success_flash):
|
||
"""Gemeinsame Logik für Aktivierung/Deaktivierung: EIN Protokoll, zwei
|
||
Transportwege (siehe licensing.py-Moduldocstring). Versucht zuerst
|
||
automatisch online; schlägt das fehl (keine Netzwerkverbindung zum
|
||
Lizenzserver o.ä.), wird die Anfrage als Code hinterlegt, den der
|
||
Admin manuell beim Lizenzserver eingeben kann -- die Bestätigung
|
||
kommt dann über *_confirm() unten zurück."""
|
||
license_file = _license_state.get("file")
|
||
request_payload = licensing.build_client_request(action, license_file)
|
||
endpoint = license_file.get("master_endpoint", "")
|
||
try:
|
||
if not endpoint:
|
||
raise RuntimeError("Kein Lizenzserver in der Lizenzdatei hinterlegt.")
|
||
response_payload = _license_api_post(endpoint, endpoint_path, request_payload)
|
||
if not licensing.verify_master_response(response_payload, license_file.get("master_pubkey", "")):
|
||
raise RuntimeError("Antwort mit ungültiger Signatur erhalten.")
|
||
if response_payload.get("status") != "ok":
|
||
raise RuntimeError(response_payload.get("status", "abgelehnt"))
|
||
success_setting_fn()
|
||
set_setting("license_pending_request", "")
|
||
log_action(success_log_action, license_file.get("license_id", ""), "Online beim Lizenzserver bestätigt.")
|
||
flash(success_flash, "success")
|
||
except Exception as exc:
|
||
set_setting("license_pending_request", json.dumps(request_payload))
|
||
set_setting("license_last_error", f"Online-{action} fehlgeschlagen: {exc}")
|
||
flash(
|
||
"Online-Verbindung zum Lizenzserver nicht möglich. Bitte den unten angezeigten Code beim "
|
||
"Lizenzserver-Administrator eingeben und den dort erhaltenen Bestätigungscode unten eintragen.",
|
||
"warning",
|
||
)
|
||
return redirect(url_for("settings_license"))
|
||
|
||
|
||
def _license_handshake_confirm(action, success_setting_fn, success_log_action, success_flash):
|
||
"""Gegenstück zu _license_handshake() für den Offline-Zweig: der Admin
|
||
hat den vom Master erzeugten Bestätigungscode erhalten und trägt ihn
|
||
hier ein."""
|
||
license_file = _license_state.get("file")
|
||
pending_raw = get_setting("license_pending_request", "")
|
||
if not license_file or not pending_raw:
|
||
flash("Keine offene Anfrage vorhanden.", "danger")
|
||
return redirect(url_for("settings_license"))
|
||
code = request.form.get("confirmation_code", "").strip()
|
||
try:
|
||
response_payload = licensing.decode_code(code)
|
||
except Exception:
|
||
flash("Code konnte nicht gelesen werden -- bitte vollständig kopieren.", "danger")
|
||
return redirect(url_for("settings_license"))
|
||
if not licensing.verify_master_response(response_payload, license_file.get("master_pubkey", "")):
|
||
flash("Code hat eine ungültige Signatur -- bitte erneut vom Lizenzserver kopieren.", "danger")
|
||
return redirect(url_for("settings_license"))
|
||
pending = json.loads(pending_raw)
|
||
if (response_payload.get("license_id") != pending.get("license_id")
|
||
or response_payload.get("fingerprint") != pending.get("fingerprint")):
|
||
flash("Code passt nicht zur offenen Anfrage.", "danger")
|
||
return redirect(url_for("settings_license"))
|
||
if response_payload.get("status") != "ok":
|
||
flash(f"Lizenzserver hat die Anfrage abgelehnt ({response_payload.get('status')}).", "danger")
|
||
return redirect(url_for("settings_license"))
|
||
success_setting_fn()
|
||
set_setting("license_pending_request", "")
|
||
log_action(success_log_action, license_file.get("license_id", ""), "Offline per Code bestätigt.")
|
||
flash(success_flash, "success")
|
||
return redirect(url_for("settings_license"))
|
||
|
||
|
||
@app.route("/settings/license/activate", methods=["POST"])
|
||
@login_required
|
||
def license_activate():
|
||
if not current_user.has_permission("settings_system.edit"):
|
||
flash("Keine Berechtigung, die Lizenz zu aktivieren.", "danger")
|
||
return redirect(url_for("settings_license"))
|
||
if not _license_state.get("file") or not _license_state.get("valid"):
|
||
flash("Keine gültige Lizenzdatei vorhanden.", "danger")
|
||
return redirect(url_for("settings_license"))
|
||
|
||
def on_success():
|
||
set_setting("license_activation_status", "active")
|
||
set_setting("license_export_grace", "")
|
||
|
||
return _license_handshake("activate", "/api/activate", on_success, "license.activated", "Lizenz erfolgreich aktiviert.")
|
||
|
||
|
||
@app.route("/settings/license/activate/confirm", methods=["POST"])
|
||
@login_required
|
||
def license_activate_confirm():
|
||
if not current_user.has_permission("settings_system.edit"):
|
||
flash("Keine Berechtigung, die Lizenz zu aktivieren.", "danger")
|
||
return redirect(url_for("settings_license"))
|
||
|
||
def on_success():
|
||
set_setting("license_activation_status", "active")
|
||
set_setting("license_export_grace", "")
|
||
|
||
return _license_handshake_confirm("activate", on_success, "license.activated", "Lizenz erfolgreich aktiviert.")
|
||
|
||
|
||
@app.route("/settings/license/deactivate", methods=["POST"])
|
||
@login_required
|
||
def license_deactivate():
|
||
if not current_user.has_permission("settings_system.edit"):
|
||
flash("Keine Berechtigung, die Lizenz zu deaktivieren.", "danger")
|
||
return redirect(url_for("settings_license"))
|
||
if not _license_state.get("file") or not license_activated():
|
||
flash("Keine aktive Lizenz zum Deaktivieren vorhanden.", "danger")
|
||
return redirect(url_for("settings_license"))
|
||
|
||
def on_success():
|
||
set_setting("license_export_grace", "1")
|
||
_delete_license_file()
|
||
|
||
return _license_handshake(
|
||
"deactivate", "/api/deactivate", on_success, "license.deactivated",
|
||
"Lizenz deaktiviert. Das System ist jetzt lizenzlos -- Export bleibt bis zur nächsten Aktivierung möglich.",
|
||
)
|
||
|
||
|
||
@app.route("/settings/license/deactivate/confirm", methods=["POST"])
|
||
@login_required
|
||
def license_deactivate_confirm():
|
||
if not current_user.has_permission("settings_system.edit"):
|
||
flash("Keine Berechtigung, die Lizenz zu deaktivieren.", "danger")
|
||
return redirect(url_for("settings_license"))
|
||
|
||
def on_success():
|
||
set_setting("license_export_grace", "1")
|
||
_delete_license_file()
|
||
|
||
return _license_handshake_confirm(
|
||
"deactivate", on_success, "license.deactivated",
|
||
"Lizenz deaktiviert. Das System ist jetzt lizenzlos -- Export bleibt bis zur nächsten Aktivierung möglich.",
|
||
)
|
||
|
||
|
||
@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,
|
||
export_section_hints=EXPORT_SECTION_HINTS,
|
||
)
|
||
|
||
|
||
|
||
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_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
|
||
|
||
|
||
@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")
|
||
|
||
return redirect(url_for("settings_ldap"))
|
||
|
||
return render_template(
|
||
"settings_ldap.html",
|
||
ldap=_ldap_settings(),
|
||
ldap_groups=_ldap_groups_for_dropdown(),
|
||
mappings=_ldap_group_mappings(),
|
||
)
|
||
|
||
|
||
@app.route("/settings/ldap/ad-groups")
|
||
@login_required
|
||
def settings_ldap_ad_groups():
|
||
"""JSON-Endpunkt für den 'AD-Gruppen laden'-Button in settings_ldap.html
|
||
-- fragt live per Bind-Konto alle Gruppen aus AD ab, damit die
|
||
Zuordnungstabelle nicht von Hand mit DNs gefüttert werden muss."""
|
||
if not current_user.has_permission("settings_ldap.edit"):
|
||
return jsonify({"error": "Keine Berechtigung."}), 403
|
||
return jsonify(_ldap_list_groups())
|
||
|
||
|
||
@app.route("/settings/nav-order", methods=["POST"])
|
||
@login_required
|
||
def save_nav_order():
|
||
if not current_user.is_admin:
|
||
flash("Nur Admins dürfen die Navbar-Reihenfolge ändern!", "danger")
|
||
return redirect(url_for("index"))
|
||
|
||
order = [k for k in request.form.getlist("nav_order") if k in NAV_ITEMS_BY_KEY]
|
||
order += [k for k in DEFAULT_NAV_ORDER if k not in order]
|
||
set_setting("nav_order", json.dumps(order))
|
||
|
||
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"))
|
||
|
||
|
||
|
||
EXPORT_SECTIONS = [
|
||
("users", "Benutzer"),
|
||
("groups", "Gruppen"),
|
||
("ldap", "LDAP/AD"),
|
||
("nginx", "NGINX"),
|
||
("logs", "Auditlog"),
|
||
]
|
||
EXPORT_SECTION_LABELS = dict(EXPORT_SECTIONS)
|
||
|
||
# Zusätzlicher Hinweistext je Kategorie fürs "i"-Icon neben dem Label
|
||
# (siehe _hint_icon.html) -- nur für Kategorien mit erklärungsbedürftigem
|
||
# Zusatzverhalten, das über den reinen Namen hinausgeht.
|
||
EXPORT_SECTION_HINTS = {
|
||
"users": "Nur lokale Konten. AD/LDAP-Benutzer werden NICHT gesichert — sie werden über Active Directory verwaltet, nicht über diese App, und legen sich beim nächsten Login automatisch wieder an.",
|
||
"ldap": "Gesichert werden Server-/Bind-Einstellungen und die automatischen AD-Gruppenzuordnungen sowie für bereits bekannte AD-Benutzer deren Sperrstatus und individuelle Gruppenzuweisungen — damit ein gesperrtes AD-Konto auf dem Zielsystem gesperrt bleibt und manuell vergebene Rechte erhalten bleiben, statt beim nächsten Login verloren zu gehen.",
|
||
"nginx": "Enthält Domain, Ports und SSL/HSTS-Schalter sowie — falls vorhanden — das aktuell hinterlegte TLS-Zertifikat und den privaten Schlüssel selbst.",
|
||
"logs": "Alle aktuell in der Datenbank vorhandenen Einträge (bereits archivierte Auditlog-Tage liegen als eigene Dateien unter Verlauf und sind kein Teil dieses Exports).",
|
||
}
|
||
|
||
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_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, _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.
|
||
|
||
ad_user_states: Sperrstatus + aktuelle Gruppenzugehörigkeit (inkl.
|
||
Admin-Sentinel) je bereits bekanntem AD-Benutzer -- bewusst hier bei
|
||
LDAP statt bei "users" (das AD-Konten komplett ausschließt, siehe
|
||
_export_users()), da es explizit um AD-Konten geht: ein gesperrtes
|
||
AD-Konto soll auf dem Zielsystem gesperrt bleiben (nicht durch die
|
||
Migration wieder hereinkommen), und individuell/manuell vergebene
|
||
Rechte sollen erhalten bleiben statt beim nächsten Login verloren zu
|
||
gehen."""
|
||
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": [], "ad_user_states": []}
|
||
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()]
|
||
ad_users = conn.execute(
|
||
"SELECT id, username, is_admin, is_locked FROM users WHERE auth_source='ldap' AND deleted_at IS NULL"
|
||
).fetchall()
|
||
for u in ad_users:
|
||
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", (u["id"],)
|
||
).fetchall()]
|
||
if u["is_admin"]:
|
||
groups.append("Admin")
|
||
data["ad_user_states"].append({"username": u["username"], "is_locked": u["is_locked"], "groups": groups})
|
||
if not (settings_data or "bind_username" in data or data["group_mappings"]
|
||
or data["ad_user_states"]):
|
||
return None
|
||
return data
|
||
|
||
|
||
NGINX_SETTING_KEYS = ["nginx_server_name", "nginx_http_port", "nginx_https_port", "ssl_enabled", "hsts_enabled"]
|
||
|
||
|
||
def _export_nginx(conn):
|
||
"""Exportiert Domain/Ports/SSL-HSTS-Schalter sowie, falls vorhanden,
|
||
das aktuell hinterlegte TLS-Zertifikat+Schlüssel selbst (Base64, da
|
||
JSON keine Binärdaten kann) -- anders als bei avatar_filename ist das
|
||
hier sinnvoll: Zertifikat/Schlüssel liegen bereits vollständig auf
|
||
diesem Host vor und lassen sich 1:1 auf ein Zielsystem übertragen,
|
||
genau wie andere Geheimnisse (Zugangsdaten-Passwörter, LDAP-Bind-
|
||
Passwort), die ebenfalls im Klartext in den -- als Ganzes
|
||
passphrasenverschlüsselten -- Export wandern. Bewusst AUSGESCHLOSSEN
|
||
bleibt trotzdem jeglicher Let's-Encrypt-/certbot-Zustand (ssl_source,
|
||
ACME-Konto, Renewal-Timer): das ist an genau diesen Host und dessen
|
||
Domain-Registrierung gebunden und kann nicht mitziehen -- ein Import
|
||
setzt ssl_source deshalb immer auf "upload", siehe _import_nginx()."""
|
||
settings_data = {k: get_setting(k) for k in NGINX_SETTING_KEYS if get_setting(k) not in (None, "")}
|
||
data = {"settings": settings_data}
|
||
try:
|
||
if os.path.isfile(TESM_SSL_CERT_PATH) and os.path.isfile(TESM_SSL_KEY_PATH):
|
||
with open(TESM_SSL_CERT_PATH, "rb") as f:
|
||
data["cert_pem"] = base64.b64encode(f.read()).decode("ascii")
|
||
with open(TESM_SSL_KEY_PATH, "rb") as f:
|
||
data["key_pem"] = base64.b64encode(f.read()).decode("ascii")
|
||
except OSError:
|
||
pass
|
||
if not (settings_data or "cert_pem" in data):
|
||
return None
|
||
return 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 = {
|
||
"users": _export_users, "groups": _export_groups, "ldap": _export_ldap,
|
||
"nginx": _export_nginx, "logs": _export_logs,
|
||
}
|
||
|
||
|
||
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 u in data.get("ad_user_states", []):
|
||
groups = u.get("groups", [])
|
||
is_admin = 1 if "Admin" in groups else 0
|
||
existing = conn.execute(
|
||
"SELECT id FROM users WHERE username=? AND auth_source='ldap'", (u["username"],)
|
||
).fetchone()
|
||
if existing:
|
||
user_id = existing["id"]
|
||
conn.execute(
|
||
"UPDATE users SET is_locked=?, is_admin=? WHERE id=?",
|
||
(u.get("is_locked", 0), is_admin, user_id),
|
||
)
|
||
else:
|
||
# Konto hat sich auf dem Zielsystem noch nie eingeloggt -- ein
|
||
# Platzhalter-Passwort-Hash wird nie geprüft (der Login-Zweig
|
||
# für auth_source='ldap' authentifiziert immer gegen AD, siehe
|
||
# login()), er füllt nur das NOT-NULL-Feld. Wichtig ist einzig,
|
||
# dass is_locked schon VOR dem ersten Login greift.
|
||
placeholder_hash = bcrypt.generate_password_hash(secrets.token_hex(32)).decode("utf-8")
|
||
cur = conn.execute(
|
||
"INSERT INTO users (username, password, is_admin, auth_source, is_locked) VALUES (?, ?, ?, 'ldap', ?)",
|
||
(u["username"], placeholder_hash, is_admin, u.get("is_locked", 0)),
|
||
)
|
||
user_id = cur.lastrowid
|
||
for gname in groups:
|
||
if gname == "Admin":
|
||
continue
|
||
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_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
|
||
if data.get("cert_pem") and data.get("key_pem"):
|
||
try:
|
||
cert_bytes = base64.b64decode(data["cert_pem"])
|
||
key_bytes = base64.b64decode(data["key_pem"])
|
||
ok, _msg, _info = _validate_cert_key_pair(cert_bytes, key_bytes)
|
||
if ok:
|
||
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)
|
||
# ACME-/certbot-Zustand kommt bewusst nie mit (siehe
|
||
# _export_nginx) -- nach dem Import ist es technisch nur
|
||
# noch ein hochgeladenes Zertifikat, keine automatische
|
||
# Verlängerung mehr, auch wenn es ursprünglich per Let's
|
||
# Encrypt ausgestellt wurde.
|
||
conn.execute(
|
||
"INSERT INTO settings (key, value) VALUES ('ssl_source', 'upload') "
|
||
"ON CONFLICT(key) DO UPDATE SET value=excluded.value"
|
||
)
|
||
n += 1
|
||
except (OSError, ValueError, TypeError):
|
||
pass
|
||
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 = {
|
||
"users": _import_users, "groups": _import_groups, "ldap": _import_ldap,
|
||
"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"))
|
||
if not license_export_allowed():
|
||
flash("Für den Export ist keine gültige Lizenz vorhanden.", "danger")
|
||
return redirect(url_for("settings_import_export"))
|
||
|
||
passphrase = request.form.get("export_passphrase", "")
|
||
sections = [s for s in request.form.getlist("export_sections") if s in EXPORT_BUILDERS]
|
||
if not passphrase:
|
||
flash("Bitte eine Passphrase für den Export angeben.", "danger")
|
||
return redirect(url_for("settings_import_export"))
|
||
if not sections:
|
||
flash("Bitte mindestens eine Kategorie zum Exportieren auswählen.", "danger")
|
||
return redirect(url_for("settings_import_export"))
|
||
if not current_user.is_admin and (set(sections) & IMPORT_EXPORT_ADMIN_ONLY_SECTIONS):
|
||
flash("Benutzer, Custom-Gruppen und LDAP-Einstellungen dürfen nur Admins exportieren.", "danger")
|
||
return redirect(url_for("settings_import_export"))
|
||
|
||
conn = get_db_connection()
|
||
payload, counts = {}, {}
|
||
for key in sections:
|
||
value = EXPORT_BUILDERS[key](conn)
|
||
if value:
|
||
payload[key] = value
|
||
counts[key] = len(value) if isinstance(value, list) else 1
|
||
conn.close()
|
||
|
||
if not payload:
|
||
flash("Keine Daten in den ausgewählten Kategorien vorhanden — nichts exportiert.", "danger")
|
||
return redirect(url_for("settings_import_export"))
|
||
|
||
salt = secrets.token_bytes(16)
|
||
export_fernet = _derive_export_fernet(passphrase, salt)
|
||
encrypted_payload = export_fernet.encrypt(json.dumps(payload).encode("utf-8"))
|
||
|
||
envelope = {
|
||
"app": "tesm",
|
||
"version": 2,
|
||
"exported_at": datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
|
||
"salt": base64.urlsafe_b64encode(salt).decode("utf-8"),
|
||
"payload": encrypted_payload.decode("utf-8"),
|
||
}
|
||
|
||
summary = ", ".join(f"{EXPORT_SECTION_LABELS[k]}: {counts[k]}" for k in payload if k in EXPORT_SECTION_LABELS)
|
||
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,
|
||
export_section_hints=EXPORT_SECTION_HINTS,
|
||
)
|
||
|
||
|
||
@app.route("/settings/import/apply", methods=["POST"])
|
||
@login_required
|
||
def import_apply():
|
||
if not current_user.has_permission("settings_importexport.edit"):
|
||
flash("Keine Berechtigung, Daten zu importieren.", "danger")
|
||
return redirect(url_for("index"))
|
||
|
||
token = request.form.get("import_token", "")
|
||
entry = _pending_imports.pop(token, None)
|
||
if not entry:
|
||
flash("Die Import-Vorschau ist abgelaufen oder ungültig — bitte die Datei erneut hochladen.", "danger")
|
||
return redirect(url_for("settings_import_export"))
|
||
|
||
payload = entry["payload"]
|
||
selected = [s for s in request.form.getlist("import_sections") if s in IMPORT_APPLIERS and payload.get(s)]
|
||
if not selected:
|
||
flash("Bitte mindestens eine Kategorie zum Importieren auswählen.", "danger")
|
||
return redirect(url_for("settings_import_export"))
|
||
if not current_user.is_admin and (set(selected) & IMPORT_EXPORT_ADMIN_ONLY_SECTIONS):
|
||
flash("Benutzer, Custom-Gruppen und LDAP-Einstellungen dürfen nur Admins importieren.", "danger")
|
||
return redirect(url_for("settings_import_export"))
|
||
|
||
conn = get_db_connection()
|
||
results = {key: IMPORT_APPLIERS[key](conn, payload[key]) for key in selected}
|
||
conn.commit()
|
||
conn.close()
|
||
|
||
summary = ", ".join(f"{EXPORT_SECTION_LABELS.get(k, k)}: {results[k]}" for k in selected)
|
||
log_action("data.import", ", ".join(EXPORT_SECTION_LABELS.get(k, k) for k in selected), summary)
|
||
flash(f"Import abgeschlossen: {summary}.", "success")
|
||
return redirect(url_for("settings_import_export"))
|
||
|
||
|
||
@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,
|
||
)
|
||
|
||
|
||
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("/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!", "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"))
|
||
|
||
# Kein Papierkorb mehr in diesem Fork (siehe Entfernung des
|
||
# Features) -- Benutzer werden direkt endgültig per DELETE
|
||
# entfernt, abgesichert durch die data-confirm-Bestätigung im
|
||
# Frontend (siehe users.html). Ein AD/LDAP-Konto legt sich bei
|
||
# erneutem gültigem Login ohnehin automatisch wieder an (siehe
|
||
# login()), ein lokales Konto ist damit unwiderruflich weg --
|
||
# bewusst in Kauf genommen, da die einzige Alternative (Soft-
|
||
# Delete per deleted_at) ohne Papierkorb-UI niemanden mehr
|
||
# etwas nützt.
|
||
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")
|
||
flash("AD/LDAP-Konto gelöscht (legt sich bei erneutem gültigen Login automatisch neu an).", "success")
|
||
else:
|
||
conn.execute("DELETE FROM user_groups WHERE user_id=?", (user_id,))
|
||
conn.execute("DELETE FROM users WHERE id=? AND deleted_at IS NULL", (user_id,))
|
||
conn.commit()
|
||
log_action("user.delete", target["username"] if target else user_id)
|
||
flash("Benutzer endgültig gelöscht.", "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.", "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!", "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:
|
||
# Kein Papierkorb mehr in diesem Fork -- Gruppen werden
|
||
# direkt endgültig per DELETE entfernt (Bestätigung dazu
|
||
# per data-confirm im Frontend, siehe groups.html), analog
|
||
# zur Benutzer-Löschung oben.
|
||
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 NULL", (group_id,))
|
||
conn.commit()
|
||
log_action("group.delete", target["name"] if target else group_id)
|
||
flash("Gruppe endgültig gelöscht.", "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)
|