Files
tesm-license/srv/tesm-license/app.py
T
alientimandClaude Sonnet 5 2d920f581f Eigenstaendige Lizenzen-Seite, entschachtelte Ticket-Ansicht, zeitlich begrenzter Kundenportal-Zugang (v1.2.2)
Reaktion auf Feedback: 'Tickets und Lizenzen je Kunden war sehr sehr
unintuitiv dargestellt... Kunde -> Ticket -> Lizenz aber alles auf extra
Seiten, im Menü einzeln anwählbar ohne diese riesen Verschachtelung.'

## Lizenzen als eigene, gleichberechtigte Ebene (neu: /licenses)

Bisher war eine Lizenz nur ueber Kunde -> gefilterte Tickets -> Ticket-
Detail -> eingebettete Historie-Tabelle erreichbar. Jetzt: Kunden,
Tickets UND Lizenzen sind drei eigene, direkt in der Navigation
erreichbare Seiten (Reihenfolge im Menü spiegelt bewusst die fachliche
Hierarchie). Kunden- und Ticket-Seite verlinken mit Filter (?customer_id=/
?ticket_id=) dorthin, statt Lizenzdaten selbst einzubetten.

Ticket-Detailseite dadurch massiv entschlackt: statt einer vollen
Lizenz-Historie-Tabelle nur noch eine kompakte 'Aktuelle Lizenz'-Zeile
mit Link auf die (ggf. gefilterte) Lizenzen-Seite fuer die volle Historie.

## Kundenportal: kein dauerhafter Link mehr, sondern zeitlich begrenzter Zugang

Vorheriger Ansatz (v1.2.0): ein dauerhafter, unbefristeter Token pro
Kunde, von Admins verteilt. Auf Nachfrage ('wie bekommt der Kunde seinen
Magic-Link, wenn er ihn verloren hat?') kam eine oeffentliche
'Zugang anfordern'-Seite dazu (E-Mail eingeben -> Link zugeschickt) --
und direkt im Anschluss die berechtigte Sicherheitsfrage, ob so ein
oeffentlich auf blossen Zuruf ausloesbarer Link nicht zeitlich begrenzt
sein sollte. Ergebnis: der dauerhafte Link wurde komplett entfernt.

Neuer, einziger Weg ins Kundenportal:
1. /portal/request -- Kunde gibt seine E-Mail ein (oeffentlich, keine
   Anmeldung noetig). Immer dieselbe generische Erfolgsmeldung,
   unabhaengig davon ob die Adresse bekannt ist (keine Enumeration).
2. Bei bekannter Adresse: ein frischer, einmalig nutzbarer Zugangs-Token
   (portal_access_tokens-Tabelle) wird per E-Mail verschickt, gueltig nur
   PORTAL_ACCESS_LINK_MINUTES (30) ab Anforderung.
3. /portal/access/<token> prueft Gueltigkeit + Einmalnutzung, markiert
   den Token als benutzt und setzt eine Session, die ihrerseits nach
   PORTAL_SESSION_MINUTES (60) automatisch ablaeuft.
4. /portal/me zeigt das eigentliche Portal (alle Tickets/Lizenzen,
   Kontaktdaten pflegen) -- ausschliesslich session-basiert erreichbar.

license_customers.portal_token bleibt in der DB bestehen (keine riskante
Schema-Ruecknahme), wird fuer die Zugriffsentscheidung aber nicht mehr
herangezogen -- alle Stellen, die vorher den dauerhaften Portal-Link
eingebettet haben (Ticket-Seite, Lizenz-per-E-Mail, Self-Service-Seite),
verweisen jetzt stattdessen auf /portal/request.

Verifiziert: bestehende Funktionstests erneut komplett durchlaufen (neue
Lizenzen-Seite, entschachtelte Ticket-Seite, entfernter dauerhafter
Zugang liefert 404), plus 11 neue Tests fuer den kompletten Magic-Link-
Fluss (Token-Erzeugung, Einmalnutzung, Ablehnen bei Wiederverwendung/
unbekanntem/abgelaufenem Token, Session bleibt nach Erstnutzung gueltig,
/portal/me ohne Session leitet zur Anfrage-Seite um) -- alle bestanden.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-23 17:03:55 +02:00

6425 lines
288 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env python3
"""
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, session
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, timedelta
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"},
# Reihenfolge spiegelt bewusst die fachliche Hierarchie Kunde -> Ticket
# -> Lizenz wider -- jede Ebene eine eigene, direkt ueber die Navigation
# erreichbare Seite statt ineinander verschachtelter Detailansichten.
{"key": "customers", "label": "Kunden", "icon": "users", "endpoint": "customers"},
{"key": "tickets", "label": "Tickets", "icon": "key", "endpoint": "tickets_list"},
{"key": "licenses", "label": "Lizenzen", "icon": "shield", "endpoint": "licenses_list"},
{"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 == "tickets":
return user.has_permission("licenses.view")
if key == "licenses":
return user.has_permission("licenses.view")
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():
# timeout=15 statt Python-Default 5s: reine Verteidigungslinie gegen
# kurze, legitime Ueberschneidungen zweier Schreibzugriffe (SQLite
# erlaubt nur EINEN Schreiber gleichzeitig) -- ersetzt NICHT die
# eigentliche Regel, niemals eine zweite Verbindung waehrend einer noch
# offenen Schreibtransaktion auf derselben zu committen (siehe
# _process_activate für ein Beispiel, wo genau das den Fehler
# "database is locked" ausgeloest hat).
conn = sqlite3.connect(DB_PATH, timeout=15)
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,
contact_person TEXT,
street TEXT,
house_number TEXT,
postal_code TEXT,
city TEXT,
portal_token TEXT,
notes TEXT,
created_at TEXT NOT NULL
)
""")
customer_cols = {row["name"] for row in conn.execute("PRAGMA table_info(license_customers)").fetchall()}
# contact_person/street/house_number/postal_code/city/portal_token kamen
# nachtraeglich dazu (vorher nur Name+E-Mail+Telefon+Notizen) -- siehe
# Kachel-Dashboard/Kundenportal weiter unten, die eine vollstaendige
# Anschrift bzw. je Kunde einen eigenen Magic-Link-Token brauchen.
for _col, _ddl in (
("contact_person", "ALTER TABLE license_customers ADD COLUMN contact_person TEXT"),
("street", "ALTER TABLE license_customers ADD COLUMN street TEXT"),
("house_number", "ALTER TABLE license_customers ADD COLUMN house_number TEXT"),
("postal_code", "ALTER TABLE license_customers ADD COLUMN postal_code TEXT"),
("city", "ALTER TABLE license_customers ADD COLUMN city TEXT"),
("portal_token", "ALTER TABLE license_customers ADD COLUMN portal_token TEXT"),
):
if _col not in customer_cols:
conn.execute(_ddl)
# Historisch: ein dauerhafter Portal-Token pro Kunde (vorher Basis
# eines UNBEFRISTETEN Magic-Links). Dieser direkte, permanente Zugang
# wurde bewusst wieder entfernt -- der einzige Weg ins Kundenportal
# ist jetzt der zeitlich begrenzte Ablauf über portal_request() /
# portal_access() (siehe dort). Die Spalte samt Backfill bleibt
# bestehen (keine riskante Schema-Ruecknahme fuer reine Aufraeum-
# zwecke), wird aber fuer die Zugriffsentscheidung selbst nicht mehr
# herangezogen.
for _row in conn.execute("SELECT id FROM license_customers WHERE portal_token IS NULL OR portal_token=''").fetchall():
conn.execute(
"UPDATE license_customers SET portal_token=? WHERE id=?",
(secrets.token_urlsafe(24), _row["id"]),
)
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,
hostname 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,
ticket_id TEXT,
lifetime INTEGER NOT NULL DEFAULT 0,
FOREIGN KEY (customer_id) REFERENCES license_customers(id)
)
""")
license_cols = {row["name"] for row in conn.execute("PRAGMA table_info(licenses)").fetchall()}
if "ticket_id" not in license_cols:
conn.execute("ALTER TABLE licenses ADD COLUMN ticket_id TEXT")
if "hostname" not in license_cols:
conn.execute("ALTER TABLE licenses ADD COLUMN hostname TEXT")
if "lifetime" not in license_cols:
conn.execute("ALTER TABLE licenses ADD COLUMN lifetime INTEGER NOT NULL DEFAULT 0")
# Tickets: die dauerhafte, dem Kunden gehoerende Berechtigung "eine
# Lizenz mit diesen Eckdaten (Typ/Module/Laufzeit) zu halten" --
# unabhaengig von der konkreten ausgestellten Lizenzdatei darunter.
# Admin ODER Kunde (per Self-Service-Link) koennen aus einem offenen
# Ticket jederzeit eine neue Lizenz erzeugen, z.B. nachdem die vorige
# widerrufen/deaktiviert wurde oder eine nie aktivierte Lizenz vom
# Admin geloescht wurde -- das Ticket selbst bleibt davon unberuehrt.
conn.execute("""
CREATE TABLE IF NOT EXISTS license_tickets (
id TEXT PRIMARY KEY,
customer_id INTEGER NOT NULL,
type TEXT NOT NULL,
modules TEXT NOT NULL DEFAULT '[]',
valid_days INTEGER NOT NULL,
lifetime INTEGER NOT NULL DEFAULT 0,
status TEXT NOT NULL DEFAULT 'open',
created_by TEXT,
created_at TEXT NOT NULL,
FOREIGN KEY (customer_id) REFERENCES license_customers(id)
)
""")
ticket_cols = {row["name"] for row in conn.execute("PRAGMA table_info(license_tickets)").fetchall()}
if "lifetime" not in ticket_cols:
conn.execute("ALTER TABLE license_tickets ADD COLUMN lifetime INTEGER NOT NULL DEFAULT 0")
# DB-seitiger Schutz gegen die Doppel-Ausstellung eines Tickets: ohne
# dieses Backstop koennten zwei nahezu gleichzeitige Anfragen (z.B. ein
# Doppelklick auf den Self-Service-"Lizenz erstellen"-Button, zwei
# offene Tabs) beide den vorherigen _ticket_has_open_license()-Check
# noch als "frei" sehen, bevor die jeweils andere ihre INSERT committet
# -- ein klassisches Time-of-check/time-of-use-Problem, das reine
# Python-Logik (_ticket_has_open_license) allein nicht zuverlaessig
# verhindern kann. Der partielle Unique-Index erzwingt "hoechstens eine
# offene (issued/active) Lizenz pro Ticket" direkt auf DB-Ebene; die
# zweite, verlierende INSERT schlaegt mit IntegrityError fehl, was
# _issue_license_from_ticket() abfaengt (siehe dort).
# Bewusst defensiv: sollte eine bestehende Installation (aus welchem
# Grund auch immer) bereits Bestandsdaten haben, die diese Regel
# verletzen, soll das den Dienst nicht am Start hindern -- lieber ein
# geloggter Fehler als ein kompletter Ausfall wegen eines Datenrands,
# den diese Migration selbst gerade erst einfuehrt.
try:
conn.execute(
"CREATE UNIQUE INDEX IF NOT EXISTS idx_one_open_license_per_ticket "
"ON licenses(ticket_id) WHERE status IN ('issued','active')"
)
except sqlite3.IntegrityError:
app.logger.error(
"Konnte idx_one_open_license_per_ticket nicht anlegen -- vermutlich hat mindestens ein "
"Ticket bereits mehr als eine offene Lizenz. Bitte manuell bereinigen (siehe licenses-Tabelle)."
)
# Historisch (siehe Kommentar beim Backfill oben) -- portal_token wird
# fuer die eigentliche Zugriffsentscheidung nicht mehr genutzt, der
# Unique-Index bleibt trotzdem als harmlose Datenintegritaets-
# Absicherung bestehen.
try:
conn.execute(
"CREATE UNIQUE INDEX IF NOT EXISTS idx_customers_portal_token "
"ON license_customers(portal_token) WHERE portal_token IS NOT NULL AND portal_token != ''"
)
except sqlite3.IntegrityError:
app.logger.error(
"Konnte idx_customers_portal_token nicht anlegen -- vermutlich haben zwei Kunden denselben "
"portal_token. Bitte manuell bereinigen (siehe license_customers-Tabelle)."
)
# Kurzlebige, EINMAL nutzbare Zugangs-Tokens fuer /portal/request (siehe
# dortiger Docstring): anders als der dauerhafte portal_token (oben,
# von Admins ueber die Ticket-/Kundenseite verteilt, bewusst
# unbefristet) ist DIESER Weg oeffentlich von JEDEM auf blossen
# Zuruf einer E-Mail-Adresse ausloesbar -- ein zusaetzliches
# zeitliches Sicherheitsnetz ist hier angemessen, waehrend die
# bereits etablierten, admin-verteilten Links (Ticket-Seite,
# Lizenz-per-E-Mail) unveraendert dauerhaft bleiben.
conn.execute("""
CREATE TABLE IF NOT EXISTS portal_access_tokens (
token TEXT PRIMARY KEY,
customer_id INTEGER NOT NULL,
created_at TEXT NOT NULL,
expires_at TEXT NOT NULL,
used_at TEXT,
FOREIGN KEY (customer_id) REFERENCES license_customers(id)
)
""")
# Rückwirkend ein Ticket für jede Lizenz nachbilden, die vor Einführung
# des Ticket-Konzepts direkt ausgestellt wurde (ticket_id noch NULL) --
# sonst würden genau diese Lizenzen (auf einer echten Installation z.B.
# eine bereits produktiv aktivierte Kundenlizenz!) im neuen, rein
# ticket-basierten Dashboard schlicht nicht mehr auftauchen. Eckdaten
# des Tickets werden 1:1 aus der jeweiligen Lizenz übernommen
# (Laufzeit aus issued_at/expires_at zurückgerechnet).
_migration_key_tickets = "_backfilled_tickets_for_legacy_licenses_v1"
if not conn.execute("SELECT 1 FROM settings WHERE key=?", (_migration_key_tickets,)).fetchone():
legacy_licenses = conn.execute("SELECT * FROM licenses WHERE ticket_id IS NULL").fetchall()
for lic in legacy_licenses:
try:
issued_ts = datetime.strptime(lic["issued_at"], "%Y-%m-%dT%H:%M:%SZ")
expires_ts = datetime.strptime(lic["expires_at"], "%Y-%m-%dT%H:%M:%SZ")
valid_days = max(1, (expires_ts - issued_ts).days)
except (ValueError, TypeError):
valid_days = 365
ticket_id = secrets.token_urlsafe(24)
conn.execute(
"INSERT INTO license_tickets (id, customer_id, type, modules, valid_days, status, created_by, created_at) "
"VALUES (?, ?, ?, ?, ?, 'open', ?, ?)",
(ticket_id, lic["customer_id"], lic["type"], lic["modules"], valid_days,
lic["created_by"] or "system", lic["created_at"]),
)
conn.execute("UPDATE licenses SET ticket_id=? WHERE license_id=?", (ticket_id, lic["license_id"]))
conn.execute("INSERT OR IGNORE INTO settings (key, value) VALUES (?, '1')", (_migration_key_tickets,))
_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 zwei eigene, per
logrotate rotierte Dateien -- die audit_log-Tabelle bleibt die Quelle
für die durchsuchbare/sortierbare Seite unter Logs → Auditlog, wächst
aber unbegrenzt weiter.
Anders als bei TESM selbst (wo live.log kontinuierlich von poe.sh mit
Geräte-Erreichbarkeit gefüllt wird) gibt es hier keinen Hintergrund-
prozess, der live.log von sich aus beschreibt -- ohne diese Zeile blieb
die Datei auf einem frischen Lizenzserver schlicht leer/nicht
vorhanden, wodurch Logs → Live dauerhaft "Keine Logfiles gefunden"
zeigte. Jede Ticket-/Lizenzaktion (Ausstellung, Aktivierung, Widerruf,
Heartbeat-Fehler, ...) landet daher jetzt sowohl in changes.log
(unverändert) als auch in live.log, damit Live sowie die davon
abgeleitete Verlauf-Seite (rotierte live.log.N-Stände) tatsächlich
Inhalt haben. Ein Schreibfehler hier darf den eigentlichen, DB-
basierten Audit-Trail nicht gefährden — daher bewusst best-effort mit
breitem except je Datei."""
line = f"{ts} [{who}] {action}"
if target:
line += f" — {target}"
if details:
line += f": {details}"
for path in (TESM_CHANGES_LOG_PATH, TESM_LIVE_LOG_PATH):
try:
with open(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, Ampelfarbe) -- teilt sich Dashboard,
Ticket-/Lizenz-Detailseite und Self-Service.
Lifetime-Lizenzen (row['lifetime']) haben zwar ein konkretes, weit in
der Zukunft liegendes expires_at (siehe licensing.LIFETIME_EXPIRES_AT),
zeigen aber bewusst weder eine Resttage-Zahl noch einen Ablaufhinweis
an -- days_left bleibt None, is_expired bleibt False, color ist immer
'green'."""
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"] = []
is_lifetime = bool(d.get("lifetime"))
d["is_lifetime"] = is_lifetime
if is_lifetime:
d["days_left"] = None
d["is_expired"] = False
d["color"] = "green"
return d
try:
expires_ts = datetime.strptime(d["expires_at"], "%Y-%m-%dT%H:%M:%SZ").timestamp()
days_left = int((expires_ts - now) / 86400)
d["days_left"] = days_left
d["is_expired"] = expires_ts < now
# Ampelfarbe fuer Dashboard-Kacheln und Tabellen (siehe
# index()-Dashboard): > 90 Tage gruen, 30..90 Tage orange, < 30 Tage
# (inkl. bereits abgelaufen) rot -- exakt die vom Nutzer
# vorgegebenen Schwellen (90 selbst zaehlt noch als orange, erst
# 91+ ist gruen).
if days_left < 30:
d["color"] = "red"
elif days_left <= 90:
d["color"] = "orange"
else:
d["color"] = "green"
except (ValueError, TypeError):
d["days_left"] = None
d["is_expired"] = False
d["color"] = "red"
return d
TRIAL_DEFAULT_DAYS = 30
def _ticket_row(conn, ticket_id):
return conn.execute("SELECT * FROM license_tickets WHERE id=?", (ticket_id,)).fetchone()
def _tickets_for_customer(conn, customer_id):
return conn.execute(
"SELECT * FROM license_tickets WHERE customer_id=? ORDER BY created_at DESC", (customer_id,)
).fetchall()
def _latest_license_for_ticket(conn, ticket_id):
"""Die 'aktuelle' Lizenz eines Tickets -- ein Ticket darf laut Konzept
immer nur eine aktive/ausstehende Lizenz gleichzeitig haben, daher
reicht die zuletzt erstellte Zeile."""
return conn.execute(
"SELECT * FROM licenses WHERE ticket_id=? ORDER BY created_at DESC LIMIT 1", (ticket_id,)
).fetchone()
def _ticket_has_open_license(conn, ticket_id):
"""True, wenn das Ticket gerade eine aktive ODER ausgestellte (noch
nicht aktivierte) Lizenz haelt -- in dem Fall darf weder Admin noch
Kunde per Self-Service eine weitere daraus erzeugen, bevor die
bestehende widerrufen/deaktiviert wurde."""
current = _latest_license_for_ticket(conn, ticket_id)
return bool(current and current["status"] in ("issued", "active"))
def _create_ticket(conn, customer_id, license_type, modules, valid_days, actor, lifetime=False):
"""Legt ein neues Ticket an -- die dauerhafte, dem Kunden gehoerende
Berechtigung auf eine Lizenz mit diesen Eckdaten. Erzeugt für sich
genommen noch KEINE Lizenzdatei, siehe _issue_license_from_ticket.
lifetime=True macht valid_days irrelevant fuer jede REGULAERE Lizenz
aus diesem Ticket (Trial-Ausstellungen bleiben davon unberuehrt, siehe
_issue_license_from_ticket) -- der Wert wird trotzdem gespeichert,
damit ein spaeteres Zurückschalten auf eine normale Laufzeit nicht bei
0/NULL anfangen muss."""
ticket_id = secrets.token_urlsafe(24)
now = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
conn.execute(
"INSERT INTO license_tickets (id, customer_id, type, modules, valid_days, lifetime, status, created_by, created_at) "
"VALUES (?, ?, ?, ?, ?, ?, 'open', ?, ?)",
(ticket_id, customer_id, license_type, json.dumps(sorted(modules)), valid_days, int(bool(lifetime)), actor, now),
)
conn.commit()
customer = _customer_row(conn, customer_id)
duration_label = "Lifetime" if lifetime else f"{valid_days} Tage"
log_action_system(
"ticket.create", customer["name"] if customer else str(customer_id),
f"{LICENSE_TYPE_LABELS.get(license_type, license_type)}, {duration_label} (Akteur: {actor})",
)
return ticket_id
def _issue_license_from_ticket(conn, ticket_id, actor, as_trial=False):
"""Erzeugt + signiert eine neue Lizenz aus einem Ticket (Ed25519, siehe
licensing.issue_license) und trägt sie in der Master-DB ein -- von
Admin UND Kunde (Self-Service) gleichermaßen genutzt, daher 'actor'
als Klartext-Beschreibung statt current_user (im Self-Service-Pfad
gibt es keinen eingeloggten Benutzer). as_trial=True erzeugt
unabhängig vom eigentlichen Ticket-Typ eine zeitlich kurze
Trial-Lizenz mit vollem Modulumfang -- das Ticket selbst (Typ/Module/
Laufzeit) bleibt dabei unverändert, nur DIESE eine Ausstellung nutzt
Trial-Konditionen."""
ticket = _ticket_row(conn, ticket_id)
if as_trial:
# Trial ist immer ein zeitlich kurzer Override, unabhaengig davon
# ob das Ticket selbst als Lifetime konfiguriert ist -- siehe
# Modul-Docstring oben.
license_type, modules, valid_days, lifetime = "trial", list(licensing.ALL_MODULES), TRIAL_DEFAULT_DAYS, False
else:
license_type = ticket["type"]
modules = json.loads(ticket["modules"] or "[]")
valid_days = ticket["valid_days"]
# "Trial" als Ticket-GRUNDTYP + Lifetime waere eine dauerhafte,
# nie ablaufende Lizenz mit -- da Trial kein "custom"/"enterprise"
# ist -- null Modulen: ein unsinniger, so nie beabsichtigter
# Zustand. Lifetime gilt daher nur fuer "echte" Lizenztypen; bei
# Trial als Grundtyp wird es hier still ignoriert (zusaetzlich in
# ticket_new()/ticket_edit() bereits serverseitig unterbunden,
# diese Zeile ist die letzte Absicherung direkt vor der Ausstellung).
lifetime = bool(ticket["lifetime"]) and license_type != "trial"
customer = _customer_row(conn, ticket["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(),
lifetime=lifetime,
)
now = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
try:
conn.execute(
"INSERT INTO licenses (license_id, customer_id, type, modules, issued_at, expires_at, "
"license_pubkey, license_file_json, status, role, ticket_id, lifetime, created_by, created_at) "
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'issued', 'customer', ?, ?, ?, ?)",
(
license_file["license_id"], ticket["customer_id"], license_type, json.dumps(sorted(modules)),
license_file["issued_at"], license_file["expires_at"], license_pubkey,
json.dumps(license_file), ticket_id, int(lifetime), actor, now,
),
)
conn.commit()
except sqlite3.IntegrityError:
# idx_one_open_license_per_ticket (siehe _ensure_schema) hat
# zugeschlagen: zwischen dem _ticket_has_open_license()-Check des
# Aufrufers und diesem INSERT hat eine ZWEITE, nahezu gleichzeitige
# Anfrage (z.B. Doppelklick auf den Self-Service-Button, zwei
# offene Tabs) bereits eine Lizenz fuer dieses Ticket angelegt.
# Statt zwei gleichzeitig gueltige Lizenzen aus einem Ticket zu
# erlauben, wird dieser zweite Versuch sauber abgebrochen -- der
# Aufrufer bekommt die inzwischen tatsaechlich vorhandene Lizenz
# zurueck, als waere er selbst nicht der Ausstellende gewesen.
conn.rollback()
existing = _latest_license_for_ticket(conn, ticket_id)
if existing:
return _license_dict_for_display(existing)
raise
expiry_label = "Lifetime" if lifetime else f"bis {license_file['expires_at']}"
log_action_system(
"license.issue", customer["name"] if customer else "?",
f"{LICENSE_TYPE_LABELS.get(license_type, license_type)}, {expiry_label} (Ticket, Akteur: {actor})",
)
return license_file
def _revoke_license_row(conn, license_row, actor):
"""Widerruft eine Lizenz -- von Admin ODER Kunde (Self-Service)
nutzbar. Das zugehörige Ticket bleibt bewusst 'open': Widerrufen ist
hier eine normale Housekeeping-Aktion (Systemwechsel, Neuaufsetzen),
kein Bann -- wer den Kunden wirklich aussperren will, sperrt das
Ticket separat und explizit (siehe ticket_block())."""
now = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
conn.execute(
"UPDATE licenses SET status='revoked', revoked_at=? WHERE license_id=?",
(now, license_row["license_id"]),
)
conn.commit()
customer = _customer_row(conn, license_row["customer_id"])
log_action_system(
"license.revoke", customer["name"] if customer else license_row["license_id"],
f"Akteur: {actor}",
)
# --- 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
# hostname ist rein informativ (Dashboard-Kacheln, siehe index()) und
# war frueher gar nicht Teil der Anfrage -- data.get(...) liefert bei
# aelteren, noch nicht aktualisierten Clients einfach "", das UPDATE
# ueberschreibt dann bewusst NICHT einen ggf. schon bekannten Hostnamen
# (siehe COALESCE unten).
hostname = data.get("hostname", "")
now = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
conn.execute(
"UPDATE licenses SET status='active', fingerprint=?, hostname=COALESCE(NULLIF(?, ''), hostname), activated_at=? WHERE license_id=?",
(fingerprint, hostname, now, license_id),
)
# Ein physisches System (= Fingerprint) kann immer nur GENAU eine
# Lizenzdatei gleichzeitig halten -- wird hier eine neue aktiviert
# (z.B. Trial durch Enterprise ersetzt), muss jede ANDERE, für
# denselben Fingerprint noch als 'active' vermerkte Lizenz automatisch
# veralten, sonst zeigt die Übersicht fälschlich zwei gleichzeitig
# aktive Lizenzen für ein- und dasselbe System. Bewusst nach
# Fingerprint gefiltert, nicht nach Kunde -- ein Kunde darf mehrere
# Systeme mit je eigener Lizenz betreiben, ohne dass die sich
# gegenseitig deaktivieren.
superseded = conn.execute(
"SELECT license_id, customer_id FROM licenses WHERE fingerprint=? AND status='active' AND license_id!=?",
(fingerprint, license_id),
).fetchall()
# log_action_system() oeffnet INTERN eine EIGENE, zweite Verbindung und
# committet sofort -- solange `conn` hier noch eine offene Schreib-
# transaktion haelt (erstes UPDATE oben, noch nicht committet), wuerde
# dieser zweite Commit-Versuch auf sich selbst warten (SQLite erlaubt
# nur EINEN Schreiber gleichzeitig) und nach Ablauf des Busy-Timeouts
# mit "database is locked" fehlschlagen -- live reproduziert beim
# Aktivieren einer Lizenz, die eine andere fuer denselben Fingerprint
# ersetzt. Deshalb werden die Log-Eintraege hier nur VORGEMERKT (reine
# Lesezugriffe auf `conn` sind unproblematisch, nur der schreibende
# log_action_system()-Aufruf selbst muss warten) und dann als allerletzter
# Schritt nach `conn.commit()`/`conn.close()` unten nachgeholt.
superseded_logs = []
for old in superseded:
conn.execute(
"UPDATE licenses SET status='deactivated', deactivated_at=? WHERE license_id=?",
(now, old["license_id"]),
)
old_customer = _customer_row(conn, old["customer_id"])
superseded_logs.append((
old_customer["name"] if old_customer else old["license_id"],
f"Automatisch deaktiviert -- Fingerprint {fingerprint} hat jetzt Lizenz {license_id} aktiviert.",
))
conn.commit()
customer = _customer_row(conn, row["customer_id"])
conn.close()
for target, details in superseded_logs:
log_action_system("license.superseded", target, details)
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
hostname = data.get("hostname", "")
now = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
conn.execute(
"UPDATE licenses SET last_heartbeat_at=?, hostname=COALESCE(NULLIF(?, ''), hostname) WHERE license_id=?",
(now, hostname, 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)
_EMAIL_RE = re.compile(r"^[^@\s]+@[^@\s]+\.[^@\s]+$")
def _customer_form_fields(form):
"""Liest die Kunden-Formularfelder einheitlich aus (Anlegen UND
Bearbeiten nutzen dieselben Feldnamen) -- Anschrift ist bewusst in
Einzelfelder aufgeteilt (Straße/Hausnummer/PLZ/Ort statt eines
Freitextfelds), damit sie z.B. für Rechnungen sauber weiterverwendet
werden kann."""
return {
"name": form.get("name", "").strip(),
"contact_email": form.get("contact_email", "").strip(),
"contact_phone": form.get("contact_phone", "").strip(),
"contact_person": form.get("contact_person", "").strip(),
"street": form.get("street", "").strip(),
"house_number": form.get("house_number", "").strip(),
"postal_code": form.get("postal_code", "").strip(),
"city": form.get("city", "").strip(),
"notes": form.get("notes", "").strip(),
}
@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:
f = _customer_form_fields(request.form)
if not f["name"]:
flash("Firma/Name ist erforderlich.", "danger")
elif not _EMAIL_RE.match(f["contact_email"]):
flash("Eine gültige E-Mail-Adresse ist erforderlich.", "danger")
else:
conn.execute(
"INSERT INTO license_customers (name, contact_email, contact_phone, contact_person, "
"street, house_number, postal_code, city, portal_token, notes, created_at) "
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
(f["name"], f["contact_email"], f["contact_phone"], f["contact_person"],
f["street"], f["house_number"], f["postal_code"], f["city"],
secrets.token_urlsafe(24), f["notes"],
datetime.now().strftime("%Y-%m-%d %H:%M:%S")),
)
conn.commit()
log_action("customer.create", f["name"])
flash(f"Kunde „{f['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"])
f = _customer_form_fields(request.form)
# Anders als beim Anlegen wird eine LEERE E-Mail beim
# Bearbeiten toleriert (nur ein nicht-leerer, aber
# ungueltiger Wert wird abgelehnt) -- sonst koennte ein
# Admin bei den Bestandskunden von vor dieser Umstellung
# (leere contact_email, damals noch kein Pflichtfeld) nicht
# einmal mehr Telefonnummer/Anschrift/Notizen aktualisieren,
# ohne zuerst zwingend eine E-Mail-Adresse nachzutragen.
if not f["name"]:
flash("Firma/Name ist erforderlich.", "danger")
elif f["contact_email"] and not _EMAIL_RE.match(f["contact_email"]):
flash("Die E-Mail-Adresse ist ungültig.", "danger")
else:
conn.execute(
"UPDATE license_customers SET name=?, contact_email=?, contact_phone=?, contact_person=?, "
"street=?, house_number=?, postal_code=?, city=?, notes=? WHERE id=?",
(f["name"], f["contact_email"], f["contact_phone"], f["contact_person"],
f["street"], f["house_number"], f["postal_code"], f["city"], f["notes"], customer_id),
)
conn.commit()
log_action("customer.update", f["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 license_tickets WHERE customer_id=?", (customer_id,)).fetchone():
flash("Kunde hat noch Tickets -- 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(DISTINCT license_tickets.id) AS ticket_count,
COUNT(DISTINCT licenses.id) AS license_count
FROM license_customers
LEFT JOIN license_tickets ON license_tickets.customer_id = license_customers.id
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("/tickets/new", methods=["GET", "POST"])
@login_required
def ticket_new():
"""Legt ein neues Ticket an -- die Berechtigung des Kunden auf eine
Lizenz mit bestimmten Eckdaten. Erstellt bewusst noch KEINE Lizenz;
das übernimmt ein separater Schritt auf der Ticket-Detailseite (durch
Admin ODER Kunde per Self-Service)."""
if not current_user.has_permission("licenses.create"):
flash("Keine Berechtigung, Tickets anzulegen.", "danger")
return redirect(url_for("index"))
conn = get_db_connection()
if request.method == "POST":
customer_id = request.form.get("customer_id", "")
if customer_id == "__new__":
new_customer_name = request.form.get("new_customer_name", "").strip()
new_customer_email = request.form.get("new_customer_email", "").strip()
if not new_customer_name:
flash("Bitte einen Namen für den neuen Kunden angeben.", "danger")
conn.close()
return redirect(url_for("ticket_new"))
if not _EMAIL_RE.match(new_customer_email):
flash("Bitte eine gültige E-Mail-Adresse für den neuen Kunden angeben.", "danger")
conn.close()
return redirect(url_for("ticket_new"))
cur = conn.execute(
"INSERT INTO license_customers (name, contact_email, portal_token, created_at) VALUES (?, ?, ?, ?)",
(new_customer_name, new_customer_email, secrets.token_urlsafe(24),
datetime.now().strftime("%Y-%m-%d %H:%M:%S")),
)
conn.commit()
customer_id = cur.lastrowid
log_action("customer.create", new_customer_name, "beim Anlegen eines Tickets neu angelegt")
else:
try:
customer_id = int(customer_id)
except ValueError:
flash("Ungültiger Kunde.", "danger")
conn.close()
return redirect(url_for("ticket_new"))
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("ticket_new"))
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 = []
# Trial + Lifetime waere eine dauerhaft nie ablaufende Lizenz ohne
# jedes Modul (Trial ist nicht "custom"/"enterprise") -- ein
# Zustand, den niemand absichtlich will. Serverseitig unterbunden,
# nicht nur per JS ausgegraut (siehe ticket_new.html).
lifetime = request.form.get("lifetime") == "1" and license_type != "trial"
try:
valid_days = max(1, int(request.form.get("valid_days", "365")))
except ValueError:
valid_days = 365
ticket_id = _create_ticket(conn, customer_id, license_type, modules, valid_days, current_user.username, lifetime=lifetime)
conn.close()
flash("Ticket angelegt.", "success")
return redirect(url_for("ticket_detail", ticket_id=ticket_id))
customers_rows = conn.execute("SELECT * FROM license_customers ORDER BY name COLLATE NOCASE").fetchall()
conn.close()
return render_template(
"ticket_new.html", customers=customers_rows, license_types=licensing.LICENSE_TYPES,
all_modules=licensing.ALL_MODULES, module_labels=MODULE_LABELS, type_labels=LICENSE_TYPE_LABELS,
preselect_customer_id=request.args.get("customer_id", type=int),
)
@app.route("/tickets/<ticket_id>", methods=["GET", "POST"])
@login_required
def ticket_detail(ticket_id):
if not current_user.can_manage_licenses:
flash("Keine Berechtigung.", "danger")
return redirect(url_for("index"))
conn = get_db_connection()
ticket = _ticket_row(conn, ticket_id)
if not ticket:
conn.close()
flash("Ticket nicht gefunden.", "danger")
return redirect(url_for("index"))
if request.method == "POST":
if not current_user.has_permission("licenses.edit"):
flash("Keine Berechtigung.", "danger")
elif "issue_license" in request.form:
if not license_active():
flash("Der Lizenzserver selbst hat keine gültige Lizenz -- Ausstellen ist deaktiviert.", "danger")
elif ticket["status"] != "open":
flash("Ticket ist gesperrt.", "danger")
elif _ticket_has_open_license(conn, ticket_id):
flash("Dieses Ticket hat bereits eine aktive oder ausgestellte Lizenz.", "danger")
else:
as_trial = request.form.get("issue_license") == "trial"
license_file = _issue_license_from_ticket(conn, ticket_id, current_user.username, as_trial=as_trial)
conn.close()
flash("Lizenz ausgestellt.", "success")
return redirect(url_for("license_detail", license_id=license_file["license_id"]))
elif "toggle_block" in request.form:
new_status = "blocked" if ticket["status"] == "open" else "open"
conn.execute("UPDATE license_tickets SET status=? WHERE id=?", (new_status, ticket_id))
conn.commit()
customer = _customer_row(conn, ticket["customer_id"])
log_action(
"ticket.block" if new_status == "blocked" else "ticket.unblock",
customer["name"] if customer else ticket_id,
)
flash("Ticket gesperrt." if new_status == "blocked" else "Ticket entsperrt.", "success")
conn.close()
return redirect(url_for("ticket_detail", ticket_id=ticket_id))
customer = _customer_row(conn, ticket["customer_id"])
licenses_rows = conn.execute(
"SELECT * FROM licenses WHERE ticket_id=? ORDER BY created_at DESC", (ticket_id,)
).fetchall()
conn.close()
return render_template(
"ticket_detail.html", ticket=dict(ticket), customer=customer,
modules_list=json.loads(ticket["modules"] or "[]"),
licenses=[_license_dict_for_display(r) for r in licenses_rows],
has_open_license=any(l["status"] in ("issued", "active") for l in licenses_rows),
can_delete_ticket=not any(l["status"] in ("issued", "active") for l in licenses_rows),
license_types=licensing.LICENSE_TYPES, all_modules=licensing.ALL_MODULES,
type_labels=LICENSE_TYPE_LABELS, module_labels=MODULE_LABELS,
master_licensed=license_active(),
self_service_url=url_for("self_service", ticket_id=ticket_id, _external=True),
portal_request_url=url_for("portal_request", _external=True),
can_edit=current_user.has_permission("licenses.edit"),
)
@app.route("/tickets/<ticket_id>/edit", methods=["POST"])
@login_required
def ticket_edit(ticket_id):
"""Aendert die Eckdaten eines bestehenden Tickets (Typ/Module/
Laufzeit/Lifetime) -- WIRKT SICH NICHT auf bereits ausgestellte
Lizenzen aus (deren Typ/Module/Ablaufdatum wurden bei Ausstellung fest
in die jeweilige Zeile kopiert, siehe _issue_license_from_ticket),
sondern nur auf die naechste Lizenz, die aus diesem Ticket erzeugt
wird. Der Kunde selbst bleibt unveraendert -- eine Neuzuordnung ist
hier bewusst nicht vorgesehen (das Self-Service-Ticket ist an genau
diesen Kunden gebunden, u.a. weil er ueber dessen Portal-Token
erreichbar ist)."""
if not current_user.has_permission("licenses.edit"):
flash("Keine Berechtigung, Tickets zu bearbeiten.", "danger")
return redirect(url_for("tickets_list"))
conn = get_db_connection()
ticket = _ticket_row(conn, ticket_id)
if not ticket:
conn.close()
flash("Ticket nicht gefunden.", "danger")
return redirect(url_for("tickets_list"))
license_type = request.form.get("license_type", ticket["type"])
if license_type not in licensing.LICENSE_TYPES:
conn.close()
flash("Ungültiger Lizenztyp.", "danger")
return redirect(url_for("ticket_detail", ticket_id=ticket_id))
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 = []
# Siehe ticket_new() -- Trial + Lifetime ist kein sinnvoller Zustand.
lifetime = request.form.get("lifetime") == "1" and license_type != "trial"
try:
valid_days = max(1, int(request.form.get("valid_days", ticket["valid_days"])))
except ValueError:
valid_days = ticket["valid_days"]
conn.execute(
"UPDATE license_tickets SET type=?, modules=?, valid_days=?, lifetime=? WHERE id=?",
(license_type, json.dumps(sorted(modules)), valid_days, int(lifetime), ticket_id),
)
conn.commit()
customer = _customer_row(conn, ticket["customer_id"])
conn.close()
duration_label = "Lifetime" if lifetime else f"{valid_days} Tage"
log_action(
"ticket.update", customer["name"] if customer else ticket_id,
f"{LICENSE_TYPE_LABELS.get(license_type, license_type)}, {duration_label}",
)
flash("Ticket aktualisiert -- gilt für die nächste daraus erstellte Lizenz.", "success")
return redirect(url_for("ticket_detail", ticket_id=ticket_id))
@app.route("/tickets/<ticket_id>/delete", methods=["POST"])
@login_required
def ticket_delete(ticket_id):
"""Loescht ein Ticket vollstaendig -- nur erlaubt, wenn es aktuell
KEINE aktive oder ausgestellte (noch nicht aktivierte) Lizenz haelt
(sonst wuerde der Self-Service-/Admin-Zugriff auf ein gerade genutztes
System verschwinden). Etwaige bereits widerrufene/deaktivierte
Lizenzen darunter sind reine Historie und werden mitgeloescht, sonst
blieben verwaiste Zeilen mit einer toten ticket_id zurueck."""
if not current_user.has_permission("licenses.edit"):
flash("Keine Berechtigung, Tickets zu löschen.", "danger")
return redirect(url_for("tickets_list"))
conn = get_db_connection()
ticket = _ticket_row(conn, ticket_id)
if not ticket:
conn.close()
flash("Ticket nicht gefunden.", "danger")
return redirect(url_for("tickets_list"))
if _ticket_has_open_license(conn, ticket_id):
conn.close()
flash("Ticket hat noch eine aktive oder ausgestellte Lizenz -- erst widerrufen, dann löschen.", "danger")
return redirect(url_for("ticket_detail", ticket_id=ticket_id))
customer = _customer_row(conn, ticket["customer_id"])
# Bewusst in Kauf genommenes Restrisiko: zwischen dem Check oben und
# dem DELETE hier koennte -- nur ueber den separaten Self-Service-/
# Portal-Link des Kunden, nicht ueber diese Admin-Oberflaeche selbst --
# in einem extrem engen Zeitfenster eine neue Lizenz fuer dieses Ticket
# entstehen, die dann mitgeloescht wuerde. Ein vollstaendiger Schutz
# bräuchte eine explizite BEGIN IMMEDIATE-Transaktion um beide
# Operationen; angesichts des sehr seltenen Zusammentreffens (Admin
# loescht genau in dem Moment, in dem der Kunde unabhaengig davon
# eine neue Lizenz erstellt) und des insgesamt seltenen Admin-Traffics
# dieser internen Anwendung wird das hier nicht weiter verhaertet --
# die deutlich wahrscheinlichere Doppel-Ausstellung (Doppelklick auf
# denselben Button) ist bereits ueber idx_one_open_license_per_ticket
# auf DB-Ebene ausgeschlossen (siehe _ensure_schema).
conn.execute("DELETE FROM licenses WHERE ticket_id=?", (ticket_id,))
conn.execute("DELETE FROM license_tickets WHERE id=?", (ticket_id,))
conn.commit()
conn.close()
log_action("ticket.delete", customer["name"] if customer else ticket_id)
flash("Ticket gelöscht.", "success")
return redirect(url_for("tickets_list"))
@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))
self_service_note = ""
if row["ticket_id"]:
self_service_url = url_for("self_service", ticket_id=row["ticket_id"], _external=True)
self_service_note = (
f"\nÜber diesen Link können Sie Ihre Lizenz jederzeit selbst herunterladen, "
f"widerrufen oder (z.B. nach einem Systemwechsel) neu erstellen:\n{self_service_url}\n"
)
portal_request_url = url_for("portal_request", _external=True)
self_service_note += (
f"\nEine Übersicht all Ihrer Tickets/Lizenzen können Sie sich jederzeit unter folgendem Link per "
f"E-Mail zuschicken lassen:\n{portal_request_url}\n"
)
valid_until = "unbegrenzt (Lifetime)" if row["lifetime"] else row["expires_at"]
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: {valid_until}\n"
f"{self_service_note}",
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:
_revoke_license_row(conn, row, current_user.username)
flash("Lizenz widerrufen -- der Kunde wird beim nächsten Heartbeat automatisch delizensiert. "
"Das Ticket bleibt offen, eine neue Lizenz kann jederzeit daraus erstellt werden.", "success")
else:
flash("Lizenz nicht gefunden.", "danger")
conn.close()
return redirect(url_for("license_detail", license_id=license_id))
@app.route("/licenses/<license_id>/delete", methods=["POST"])
@login_required
def license_delete(license_id):
"""Reine Aufräum-Aktion: eine widerrufene oder nie aktivierte Lizenz
aus der Übersicht entfernen. Das zugehörige Ticket bleibt bestehen --
der Kunde kann sich darüber (Self-Service) oder der Admin (Ticket-
Seite) jederzeit eine neue mit denselben Eckdaten erzeugen."""
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 not row:
conn.close()
flash("Lizenz nicht gefunden.", "danger")
return redirect(url_for("index"))
if row["status"] not in ("revoked", "issued"):
conn.close()
flash("Nur widerrufene oder nicht aktivierte Lizenzen können gelöscht werden.", "danger")
return redirect(url_for("license_detail", license_id=license_id))
ticket_id = row["ticket_id"]
customer = _customer_row(conn, row["customer_id"])
conn.execute("DELETE FROM licenses WHERE license_id=?", (license_id,))
conn.commit()
conn.close()
log_action("license.delete", customer["name"] if customer else license_id, f"Status war: {row['status']}")
flash("Lizenz gelöscht.", "success")
if ticket_id:
return redirect(url_for("ticket_detail", ticket_id=ticket_id))
return redirect(url_for("index"))
# ============================================================ Self-Service =
# Oeffentlich erreichbar (bewusst KEIN @login_required) -- Autorisierung
# laeuft ueber den Besitz der unerratbaren Ticket-ID selbst (192 Bit
# Zufall, siehe secrets.token_urlsafe in _create_ticket), analog zu einem
# "Magic Link" in einer Bestell-/Abo-Verwaltungsmail. Erlaubt dem Kunden
# GENAU das, was in der Konzept-Vorgabe steht: eigene Lizenz herunterladen,
# widerrufen, und (mit den Ticket-Eckdaten) neu erstellen -- ohne dafuer
# ein Admin-Login zu brauchen oder auf den Anbieter warten zu muessen.
@app.route("/self-service/<ticket_id>")
def self_service(ticket_id):
conn = get_db_connection()
ticket = _ticket_row(conn, ticket_id)
if not ticket:
conn.close()
abort(404)
customer = _customer_row(conn, ticket["customer_id"])
current = _latest_license_for_ticket(conn, ticket_id)
conn.close()
can_issue_new = ticket["status"] == "open" and not (current and current["status"] in ("issued", "active"))
return render_template(
"self_service.html", ticket=dict(ticket), customer=customer,
modules_list=json.loads(ticket["modules"] or "[]"),
license=_license_dict_for_display(current) if current else None,
can_issue_new=can_issue_new,
type_labels=LICENSE_TYPE_LABELS, module_labels=MODULE_LABELS,
portal_request_url=url_for("portal_request"),
)
@app.route("/self-service/<ticket_id>/download")
def self_service_download(ticket_id):
conn = get_db_connection()
ticket = _ticket_row(conn, ticket_id)
if not ticket:
conn.close()
abort(404)
current = _latest_license_for_ticket(conn, ticket_id)
conn.close()
if not current:
abort(404)
content = current["license_file_json"].encode("utf-8")
return app.response_class(
content, mimetype="application/json",
headers={"Content-Disposition": f'attachment; filename="license-{current["license_id"]}.json"'},
)
@app.route("/self-service/<ticket_id>/revoke", methods=["POST"])
def self_service_revoke(ticket_id):
conn = get_db_connection()
ticket = _ticket_row(conn, ticket_id)
if not ticket:
conn.close()
abort(404)
current = _latest_license_for_ticket(conn, ticket_id)
if not current or current["status"] not in ("issued", "active"):
conn.close()
flash("Keine aktive Lizenz zum Widerrufen vorhanden.", "danger")
return redirect(url_for("self_service", ticket_id=ticket_id))
_revoke_license_row(conn, current, "Kunde (Self-Service)")
conn.close()
flash("Lizenz widerrufen. Sie können jetzt eine neue erstellen.", "success")
return redirect(url_for("self_service", ticket_id=ticket_id))
@app.route("/self-service/<ticket_id>/issue", methods=["POST"])
def self_service_issue(ticket_id):
conn = get_db_connection()
ticket = _ticket_row(conn, ticket_id)
if not ticket:
conn.close()
abort(404)
if not license_active():
conn.close()
flash("Der Lizenzserver ist derzeit nicht verfügbar -- bitte später erneut versuchen.", "danger")
return redirect(url_for("self_service", ticket_id=ticket_id))
if ticket["status"] != "open":
conn.close()
flash("Dieses Ticket ist gesperrt -- bitte an den Anbieter wenden.", "danger")
return redirect(url_for("self_service", ticket_id=ticket_id))
if _ticket_has_open_license(conn, ticket_id):
conn.close()
flash("Es gibt bereits eine aktive oder ausgestellte Lizenz zu diesem Ticket.", "danger")
return redirect(url_for("self_service", ticket_id=ticket_id))
as_trial = request.form.get("issue_license") == "trial"
_issue_license_from_ticket(conn, ticket_id, "Kunde (Self-Service)", as_trial=as_trial)
conn.close()
flash("Neue Lizenz erstellt -- bitte herunterladen und auf dem Zielsystem aktivieren.", "success")
return redirect(url_for("self_service", ticket_id=ticket_id))
@app.route("/portal/request", methods=["GET", "POST"])
def portal_request():
"""Oeffentlich erreichbar (kein Login): fuer einen Kunden, der seinen
Magic-Link verloren hat oder nie einen bekommen hat (z.B. weil er nur
telefonisch beim Anbieter bekannt ist), gibt es sonst KEINEN Weg
zurueck ins Kundenportal ausser einen Admin zu kontaktieren -- diese
Seite schliesst die Luecke: E-Mail-Adresse eingeben, Link wird (falls
hinterlegt) zugeschickt. Werkzeug routet die literale Regel
/portal/request bevorzugt vor der dynamischen /portal/<token> weiter
unten -- keine Kollisionsgefahr mit einem Kunden, dessen Token
zufaellig 'request' laeutet waere ohnehin praktisch ausgeschlossen
(192 Bit Zufall).
Zeigt IMMER dieselbe generische Erfolgsmeldung, unabhaengig davon, ob
die eingegebene Adresse tatsaechlich zu einem Kunden gehoert --
verhindert, dass sich ueber die Antwort erraten laesst, welche
E-Mail-Adressen im System hinterlegt sind.
Bewusst OHNE dauerhaften Portal-Link (der wurde entfernt, genau weil
dieser Weg OEFFENTLICH von jedem auf blossen Zuruf einer E-Mail-
Adresse ausloesbar ist): stattdessen ein frischer, einmalig nutzbarer,
nur PORTAL_ACCESS_LINK_MINUTES gueltiger Zugangs-Token (siehe
portal_access())."""
if request.method == "POST":
email = request.form.get("email", "").strip()
if email:
conn = get_db_connection()
customer = conn.execute(
"SELECT * FROM license_customers WHERE contact_email IS NOT NULL AND lower(contact_email) = lower(?)",
(email,),
).fetchone()
if customer:
now = datetime.now()
grant_token = secrets.token_urlsafe(32)
conn.execute(
"INSERT INTO portal_access_tokens (token, customer_id, created_at, expires_at) VALUES (?, ?, ?, ?)",
(grant_token, customer["id"], now.strftime("%Y-%m-%d %H:%M:%S"),
(now + timedelta(minutes=PORTAL_ACCESS_LINK_MINUTES)).strftime("%Y-%m-%d %H:%M:%S")),
)
conn.commit()
access_url = url_for("portal_access", token=grant_token, _external=True)
try:
_graph_send_mail(
customer["contact_email"],
"Ihr Zugang zum TESM-Kundenportal",
"Hallo,\n\n"
"über folgenden Link erreichen Sie für kurze Zeit eine Übersicht Ihrer Tickets und Lizenzen "
"und können Ihre Kontaktdaten selbst pflegen:\n"
f"{access_url}\n\n"
f"Dieser Link ist nur {PORTAL_ACCESS_LINK_MINUTES} Minuten lang gültig und danach nicht "
"mehr nutzbar -- falls er abläuft, fordern Sie bitte einfach einen neuen an. "
"Bitte nicht an Dritte weitergeben.",
)
log_action_system("customer.portal_link_requested", customer["name"])
except Exception:
# Absichtlich NICHT an den Nutzer durchgereicht (siehe Docstring,
# Anti-Enumeration) -- aber fuer den Admin sichtbar loggen, sonst
# merkt niemand, dass z.B. Microsoft Graph nicht konfiguriert ist.
app.logger.error("Versand des Kundenportal-Links fehlgeschlagen:\n%s", traceback.format_exc())
conn.close()
flash(
"Falls diese E-Mail-Adresse bei uns hinterlegt ist, wurde soeben eine Nachricht mit einem "
f"{PORTAL_ACCESS_LINK_MINUTES} Minuten gültigen Zugangslink verschickt.",
"success",
)
return redirect(url_for("portal_request"))
return render_template("portal_request.html")
def _render_customer_portal(customer):
"""GET/POST-Logik des Kundenportals -- Zugriff ausschliesslich ueber
die zeitlich begrenzte Session nach einem frisch angeforderten
Magic-Link (siehe portal_request()/portal_access()/
customer_portal_session()). Es gibt bewusst KEINEN dauerhaften,
direkt aufrufbaren Portal-Link mehr -- `customer` ist hier bereits
als authentisch geprueft (Aufrufer hat eine gueltige Session
verifiziert), diese Funktion selbst prueft nichts mehr davon."""
conn = get_db_connection()
if request.method == "POST":
contact_email = request.form.get("contact_email", "").strip()
if not _EMAIL_RE.match(contact_email):
flash("Bitte eine gültige E-Mail-Adresse angeben.", "danger")
else:
conn.execute(
"UPDATE license_customers SET contact_person=?, contact_phone=?, contact_email=?, "
"street=?, house_number=?, postal_code=?, city=? WHERE id=?",
(request.form.get("contact_person", "").strip(), request.form.get("contact_phone", "").strip(),
contact_email, request.form.get("street", "").strip(), request.form.get("house_number", "").strip(),
request.form.get("postal_code", "").strip(), request.form.get("city", "").strip(), customer["id"]),
)
conn.commit()
log_action_system("customer.update", customer["name"], "Kunde hat eigene Kontaktdaten im Portal aktualisiert")
flash("Ihre Daten wurden aktualisiert.", "success")
conn.close()
return redirect(url_for("customer_portal_session"))
now = time.time()
tickets_view = []
for t in _tickets_for_customer(conn, customer["id"]):
d = dict(t)
d["modules_list"] = json.loads(d.get("modules") or "[]")
current = _latest_license_for_ticket(conn, t["id"])
d["license"] = _license_dict_for_display(current, now) if current else None
tickets_view.append(d)
conn.close()
return render_template(
"customer_portal.html", customer=dict(customer), tickets=tickets_view,
type_labels=LICENSE_TYPE_LABELS, module_labels=MODULE_LABELS,
)
PORTAL_ACCESS_LINK_MINUTES = 30
PORTAL_SESSION_MINUTES = 60
@app.route("/portal/access/<token>")
def portal_access(token):
"""Loest den kurzlebigen, EINMAL nutzbaren Link aus einer per
portal_request() angeforderten E-Mail ein -- gueltig fuer
PORTAL_ACCESS_LINK_MINUTES ab Anforderung, danach oder nach dem
ersten Aufruf nicht mehr nutzbar. Bei Erfolg wird eine Session
gesetzt, die ihrerseits nach PORTAL_SESSION_MINUTES ohne neue
Anforderung automatisch ablaeuft (siehe customer_portal_session()) --
zwei getrennte Zeitfenster: eines fuer den Link selbst (muss zeitnah
angeklickt werden), eines fuer die resultierende Sitzung (muss danach
zeitnah genutzt werden)."""
conn = get_db_connection()
grant = conn.execute("SELECT * FROM portal_access_tokens WHERE token=?", (token,)).fetchone()
valid = bool(grant) and not grant["used_at"]
if valid:
try:
valid = datetime.now() < datetime.strptime(grant["expires_at"], "%Y-%m-%d %H:%M:%S")
except ValueError:
valid = False
if not valid:
conn.close()
flash("Dieser Link ist ungültig oder abgelaufen. Bitte fordern Sie einen neuen an.", "danger")
return redirect(url_for("portal_request"))
conn.execute(
"UPDATE portal_access_tokens SET used_at=? WHERE token=?",
(datetime.now().strftime("%Y-%m-%d %H:%M:%S"), token),
)
conn.commit()
customer = _customer_row(conn, grant["customer_id"])
conn.close()
if not customer:
flash("Dieser Zugang ist nicht mehr gültig.", "danger")
return redirect(url_for("portal_request"))
session["portal_customer_id"] = customer["id"]
session["portal_session_expires_at"] = (
datetime.now() + timedelta(minutes=PORTAL_SESSION_MINUTES)
).strftime("%Y-%m-%d %H:%M:%S")
log_action_system("customer.portal_link_used", customer["name"])
return redirect(url_for("customer_portal_session"))
@app.route("/portal/me", methods=["GET", "POST"])
def customer_portal_session():
"""Session-basierter Kundenportal-Zugang nach einem frisch
angeforderten Magic-Link (portal_request() -> portal_access()) --
laeuft PORTAL_SESSION_MINUTES nach dem Anklicken des Links automatisch
ab, im Unterschied zum dauerhaften /portal/<token> (siehe dortiger
Docstring)."""
customer_id = session.get("portal_customer_id")
expires_at_str = session.get("portal_session_expires_at")
valid = False
if customer_id and expires_at_str:
try:
valid = datetime.now() < datetime.strptime(expires_at_str, "%Y-%m-%d %H:%M:%S")
except ValueError:
valid = False
if not valid:
session.pop("portal_customer_id", None)
session.pop("portal_session_expires_at", None)
flash("Ihre Sitzung ist abgelaufen -- bitte fordern Sie einen neuen Zugangslink an.", "danger")
return redirect(url_for("portal_request"))
conn = get_db_connection()
customer = _customer_row(conn, customer_id)
conn.close()
if not customer:
session.pop("portal_customer_id", None)
session.pop("portal_session_expires_at", None)
abort(404)
return _render_customer_portal(customer)
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():
"""Dashboard des Master-Lizenzservers -- eine rein informative Kachel-
uebersicht der gerade AKTIVEN Lizenzen (analog zur Geraete-Kachel-
ansicht auf dem TESM-Dashboard selbst, siehe _dashboard_tiles.html
dort), gruppiert nach Ablauf-Ampel (rot < 30 Tage/abgelaufen, orange
30-90 Tage, gruen > 90 Tage oder Lifetime). Zeigt bewusst NUR Lizenzen
mit status='active' -- alles andere (Tickets anlegen/bearbeiten/
loeschen, Lizenzen ausstellen/widerrufen/loeschen) ist Verwaltung und
lebt seit der Kachel-Umstellung auf der eigenen Tickets-Seite
(tickets_list())."""
if not current_user.is_authenticated or not current_user.has_permission("licenses.view"):
return render_template("index.html", buckets=None)
conn = get_db_connection()
active_rows = conn.execute("""
SELECT licenses.*, license_customers.name AS customer_name
FROM licenses
JOIN license_customers ON license_customers.id = licenses.customer_id
WHERE licenses.status = 'active'
ORDER BY licenses.expires_at ASC
""").fetchall()
now = time.time()
buckets = {"red": [], "orange": [], "green": []}
for row in active_rows:
d = _license_dict_for_display(row, now)
d["customer_name"] = row["customer_name"]
buckets[d["color"]].append(d)
stats = {
"red": len(buckets["red"]), "orange": len(buckets["orange"]),
"green": len(buckets["green"]), "total": len(active_rows),
}
conn.close()
return render_template(
"index.html", buckets=buckets, stats=stats,
type_labels=LICENSE_TYPE_LABELS, module_labels=MODULE_LABELS,
)
@app.route("/tickets")
@login_required
def tickets_list():
"""Ticketverwaltung -- Anlegen/Bearbeiten/Loeschen von Tickets sowie
Einstieg in die Lizenzausstellung/-verwaltung je Ticket. Das war bis
v1.1.0 der Inhalt des Dashboards selbst; seit der Umstellung des
Dashboards auf eine rein informative Kacheluebersicht (siehe index())
lebt die eigentliche Verwaltung hier, eigenstaendig ueber die
Navigation erreichbar."""
if not current_user.has_permission("licenses.view"):
flash("Keine Berechtigung, Tickets anzusehen.", "danger")
return redirect(url_for("index"))
conn = get_db_connection()
filter_customer_id = request.args.get("customer_id", type=int)
if filter_customer_id:
ticket_rows = conn.execute("""
SELECT license_tickets.*, license_customers.name AS customer_name
FROM license_tickets
JOIN license_customers ON license_customers.id = license_tickets.customer_id
WHERE license_tickets.customer_id = ?
ORDER BY license_tickets.created_at DESC
""", (filter_customer_id,)).fetchall()
else:
ticket_rows = conn.execute("""
SELECT license_tickets.*, license_customers.name AS customer_name
FROM license_tickets
JOIN license_customers ON license_customers.id = license_tickets.customer_id
ORDER BY license_tickets.created_at DESC
""").fetchall()
now = time.time()
tickets_view = []
for t in ticket_rows:
d = dict(t)
d["modules_list"] = json.loads(d.get("modules") or "[]")
current = _latest_license_for_ticket(conn, t["id"])
d["license"] = _license_dict_for_display(current, now) if current else None
d["can_delete"] = not (current and current["status"] in ("issued", "active"))
tickets_view.append(d)
conn.close()
return render_template(
"tickets.html", tickets=tickets_view, type_labels=LICENSE_TYPE_LABELS, module_labels=MODULE_LABELS,
all_modules=licensing.ALL_MODULES, license_types=licensing.LICENSE_TYPES,
can_create=current_user.has_permission("licenses.create"),
can_edit=current_user.has_permission("licenses.edit"),
)
@app.route("/licenses")
@login_required
def licenses_list():
"""Eigenstaendige Lizenzuebersicht -- zeigt ALLE ausgestellten Lizenzen
flach in einer Tabelle (statt nur ueber die Historie eines einzelnen
Tickets erreichbar zu sein). Ergaenzt Kunden/Tickets/Lizenzen zu drei
gleichberechtigten, direkt ueber die Navigation erreichbaren Ebenen --
vorher war eine Lizenz nur durch Kunde -> gefilterte Tickets -> Ticket-
Detail -> eingebettete Historie auffindbar. Optional gefiltert nach
?ticket_id=... oder ?customer_id=... (von Ticket- bzw. Kundenseite aus
verlinkt)."""
if not current_user.has_permission("licenses.view"):
flash("Keine Berechtigung, Lizenzen anzusehen.", "danger")
return redirect(url_for("index"))
conn = get_db_connection()
filter_ticket_id = request.args.get("ticket_id")
filter_customer_id = request.args.get("customer_id", type=int)
query = """
SELECT licenses.*, license_customers.name AS customer_name
FROM licenses
JOIN license_customers ON license_customers.id = licenses.customer_id
WHERE 1=1
"""
params = []
if filter_ticket_id:
query += " AND licenses.ticket_id = ?"
params.append(filter_ticket_id)
if filter_customer_id:
query += " AND licenses.customer_id = ?"
params.append(filter_customer_id)
query += " ORDER BY licenses.created_at DESC"
rows = conn.execute(query, params).fetchall()
now = time.time()
licenses_view = []
for row in rows:
d = _license_dict_for_display(row, now)
d["customer_name"] = row["customer_name"]
licenses_view.append(d)
filter_ticket = _ticket_row(conn, filter_ticket_id) if filter_ticket_id else None
filter_customer = _customer_row(conn, filter_customer_id) if filter_customer_id else None
conn.close()
return render_template(
"licenses.html", licenses=licenses_view, type_labels=LICENSE_TYPE_LABELS,
filter_ticket=filter_ticket, filter_customer=filter_customer,
can_edit=current_user.has_permission("licenses.edit"),
)
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)