Lizenz-/Modulsystem Phase 1: TESM-seitiges Gating, Aktivierung, Topbar (v1.2.0)

Baut auf licensing.py (Phase 0) auf und schaltet TESM erstmals auf ein
lizenziertes Modulmodell um. Standard-Funktionen (Dashboard, Clients,
Switche, Zugangsdaten, Benutzer, Gruppen, Auditlog, Live-Log, Verlauf,
Import) bleiben immer frei nutzbar.

- license_active()/module_licensed()-Gate-Funktionen + In-Memory-
  Lizenzcache (_load_license, _write_license_file, _delete_license_file),
  Jinja-Globals fuer Templates.
- Vollstaendige Module (DHCP inkl. Kea-Log, Fileshare, Wartung) werden ohne
  passendes Modul in der Lizenz komplett aus der Navigation ausgeblendet
  (_nav_key_visible) UND serverseitig auf Routenebene abgewiesen
  (_require_module) -- ein direkter URL-Aufruf hilft nicht.
- 'Sichtbar, aber ohne Lizenz inaktiv': LDAP- und NGINX-Einstellungen,
  Papierkorb-Bearbeiten, Export sowie das Pruefintervall-Feld (aus dem
  gemeinsamen Systemeinstellungen-Gate herausgeloest, Hostname/Zeitzone/
  Netzwerk/Logs bleiben frei) zeigen ohne Lizenz einen Hinweis statt der
  Aktion.
- Export bleibt als bewusste Ausnahme auch im lizenzlosen Zustand nutzbar,
  wenn dieser durch eine selbst ausgeloeste Deaktivierung (Systemwechsel)
  entstanden ist (license_export_grace).
- Automatischer PoE-Neustart bei Ausfall wird in poe.sh (nicht in Python)
  gegatet -- einmal pro Schleifendurchlauf gelesen, an check_device()
  durchgereicht; der manuelle Neustart-Button (devices.restart) bleibt
  komplett unangetastet.
- Topbar-Lizenzanzeige (Prioritaet: fehlt > abgelaufen > laeuft in <=30
  Tagen ab > Heartbeat seit >=14 Tagen ausgeblieben (rein informativ) >
  alles ok), eigene CSS-Blink-Varianten fuer Rot/Orange (die vorhandene
  pulse-dot-Animation war fest auf Gruen verdrahtet).
- Neue Settings-Unterseite 'Lizenz': Status, Lizenzdatei-Upload, Online-
  Aktivierung/-Deaktivierung mit automatischem Fallback auf einen manuell
  auszutauschenden Code (ein Protokoll, zwei Transportwege, siehe
  licensing.py), Anbieter-Kontaktkarte.
- 6-stuendiger Heartbeat-Hintergrundthread (_license_heartbeat_loop, exakt
  nach dem Vorbild von _audit_archive_loop).

Isoliert auf dem Testsystem (192.168.82.51) verifiziert: alle fuenf
Topbar-/Sichtbarkeitszustaende (keine/gueltige/bald ablaufende/abgelaufene/
heartbeat-stale Lizenz) per Playwright durchgespielt, kompletter Online-
Fallback-auf-Offline-Code-Austausch fuer Aktivierung UND Deaktivierung
gegen einen simulierten Master erfolgreich durchlaufen (inkl. Export-
Grace-Ausnahme nach Deaktivierung).

Bewusst NICHT nach main gemergt/getaggt/ausgerollt: ohne den in Phase 2
noch zu bauenden Master-Lizenzserver kann POETEST noch keine echte Lizenz
beziehen -- ein Rollout jetzt wuerde dort DHCP/Fileshare/Wartung/LDAP/
NGINX/Papierkorb/Export/automatischen Neustart sofort abschalten. Bleibt
auf dev, bis Phase 2 (Master) + Phase 3 (POETEST-Enterprise-Lizenz) so
weit sind, dass beides zusammen ausgerollt werden kann.

Teil des Lizenz-/Modulsystems, siehe Plan toasty-twirling-hickey.md
(Phase 1 von 3).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-23 00:45:35 +02:00
co-authored by Claude Sonnet 5
parent b70ebefb17
commit c9ac0bf69b
11 changed files with 763 additions and 39 deletions
+1 -1
View File
@@ -1 +1 @@
1.1.11
1.2.0
+481 -16
View File
@@ -19,7 +19,7 @@ from cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2HMAC
from cryptography.hazmat.primitives import hashes, serialization
from cryptography import x509
from datetime import datetime, timedelta
import base64, csv, io, ipaddress, logging, shlex, shutil, socket, sqlite3, glob, json, os, re, secrets, stat, subprocess, threading, time, traceback, zipfile
import base64, csv, io, ipaddress, logging, math, shlex, shutil, socket, sqlite3, glob, json, os, re, secrets, stat, subprocess, threading, time, traceback, zipfile
import paramiko
import yaml
import ssl
@@ -32,6 +32,9 @@ from ldap3.core.exceptions import (
LDAPResponseTimeoutError,
)
from ldap3.utils.conv import escape_filter_chars
import urllib.request
import urllib.error
import licensing
LDAP_MATCHING_RULE_IN_CHAIN = "1.2.840.113556.1.4.1941"
@@ -86,6 +89,7 @@ POE_SCRIPT = os.environ.get("POE_SCRIPT", "/usr/local/bin/custom/poe.sh")
FERNET_KEY_PATH = os.environ.get("TESM_FERNET_KEY", os.path.join(BASE_DIR, "fernet.key"))
SSH_KNOWN_HOSTS_PATH = os.environ.get("TESM_KNOWN_HOSTS", os.path.join(BASE_DIR, "known_hosts"))
SECRET_KEY_PATH = os.environ.get("TESM_SECRET_KEY_FILE", os.path.join(BASE_DIR, "secret.key"))
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")
TESM_LOG_DIR = os.environ.get("TESM_LOG_DIR", "/var/log/tesm")
@@ -438,6 +442,7 @@ NAV_ITEMS = [
{"key": "fileshare", "label": "Dateifreigaben", "icon": "folder", "endpoint": "fileshare"},
{"key": "settings_group", "label": "Einstellungen", "icon": "sliders", "children": [
{"key": "settings_system", "label": "Systemeinstellungen", "icon": "sliders", "endpoint": "settings"},
{"key": "settings_license", "label": "Lizenz", "icon": "key", "endpoint": "settings_license"},
{"key": "settings_ldap", "label": "LDAP", "icon": "users", "endpoint": "settings_ldap"},
{"key": "settings_dhcp", "label": "DHCP", "icon": "network", "endpoint": "settings_dhcp"},
{"key": "settings_importexport", "label": "Im-/Export", "icon": "transfer", "endpoint": "settings_import_export"},
@@ -465,7 +470,7 @@ def _nav_key_visible(key, user):
if key == "credentials":
return user.can_manage_credentials
if key == "maintenance":
return user.can_view_maintenance
return user.can_view_maintenance and module_licensed("maintenance")
if key == "papierkorb":
return user.can_view_papierkorb
if key == "users":
@@ -473,13 +478,22 @@ def _nav_key_visible(key, user):
if key == "groups":
return user.can_manage_groups
if key == "fileshare":
return user.has_permission("fileshare.view") and bool(_current_fileshare_mounts())
return (
user.has_permission("fileshare.view")
and bool(_current_fileshare_mounts())
and module_licensed("fileshare")
)
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_dhcp":
return user.can_view_settings_dhcp
return user.can_view_settings_dhcp and module_licensed("dhcp")
if key == "settings_ldap":
return user.can_view_settings_ldap
if key == "settings_nginx":
@@ -491,7 +505,7 @@ def _nav_key_visible(key, user):
if key == "logs_activity":
return user.can_view_activity_log
if key == "logs_kea":
return user.can_view_kea_log
return user.can_view_kea_log and module_licensed("dhcp")
return False
@@ -2100,6 +2114,211 @@ if _IS_WEB_PROCESS:
_fileshare_cleanup_all_on_startup()
# ============================================================ Lizenzsystem ==
# Kryptographie/Protokoll: siehe licensing.py. Hier nur der TESM-seitige
# Zustand: In-Memory-Cache der aktuell installierten Lizenzdatei (nach dem
# gleichen Prinzip wie _active_fileshare_mounts oben -- 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, Papierkorb-Bearbeiten, Export, Prüfintervall,
automatischer PoE-Neustart). 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()
def _resolve_host_preferring_ipv4(hostname):
"""Löst EINEN Hostnamen auf -- IPv4-Adresse wenn vorhanden, sonst
IPv6, sonst None. IPv4 wird bevorzugt, weil sie in der Praxis
@@ -2375,6 +2594,8 @@ def fileshare():
if not current_user.has_permission("fileshare.view"):
flash("Keine Berechtigung, Dateifreigaben einzusehen.", "danger")
return redirect(url_for("index"))
if resp := _require_module("fileshare"):
return resp
mounts = _current_fileshare_mounts()
if not mounts:
flash(
@@ -3087,7 +3308,11 @@ def inject_dhcp_topbar_status():
für Benutzer mit mindestens Leserecht auf DHCP, und nur ein einzelner,
kurzer systemctl-Aufruf (nicht die volle Status-Erkennung von
_detect_dhcp_status), damit das nicht jede Seite spürbar verlangsamt."""
if not current_user.is_authenticated or not current_user.has_permission("settings_dhcp.view"):
if (
not current_user.is_authenticated
or not current_user.has_permission("settings_dhcp.view")
or not module_licensed("dhcp")
):
return {"dhcp_topbar_active": None}
try:
result = subprocess.run(["systemctl", "is-active", DHCP_SERVICE], capture_output=True, text=True, timeout=2)
@@ -3097,6 +3322,50 @@ def inject_dhcp_topbar_status():
return {"dhcp_topbar_active": active}
@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}
def get_device_status(devices):
"""
Ermittelt aus dem aktuellsten Logfile in einem Durchlauf je Gerät:
@@ -4037,6 +4306,9 @@ def settings():
return redirect(url_for("settings"))
if "interval" in request.form:
if not license_active():
flash("Das Prüfintervall kann nur mit gültiger Lizenz geändert werden.", "danger")
return redirect(url_for("settings"))
new_interval = int(request.form["interval"])
set_setting("interval", new_interval)
set_setting("check_interval", new_interval * 60)
@@ -4194,6 +4466,190 @@ def 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():
@@ -4782,6 +5238,8 @@ def settings_dhcp():
if request.method == "GET" and not current_user.can_view_settings_dhcp:
flash("Keine Berechtigung, die DHCP-Einstellungen anzusehen.", "danger")
return redirect(url_for("index"))
if resp := _require_module("dhcp"):
return resp
conn = get_db_connection()
@@ -5974,6 +6432,9 @@ 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]
@@ -6734,7 +7195,7 @@ def _purge_expired_trash():
@app.route("/devices/restore/<mac>", methods=["POST"])
@login_required
def restore_device(mac):
if not current_user.has_permission("papierkorb.edit"):
if not current_user.has_permission("papierkorb.edit") or not license_active():
flash("Keine Berechtigung, Geräte wiederherzustellen.", "danger")
return redirect(url_for("papierkorb"))
conn = get_db_connection()
@@ -6750,7 +7211,7 @@ def restore_device(mac):
@app.route("/devices/purge/<mac>", methods=["POST"])
@login_required
def purge_device(mac):
if not current_user.has_permission("papierkorb.edit"):
if not current_user.has_permission("papierkorb.edit") or not license_active():
flash("Keine Berechtigung, Geräte endgültig zu löschen.", "danger")
return redirect(url_for("papierkorb"))
conn = get_db_connection()
@@ -6766,7 +7227,7 @@ def purge_device(mac):
@app.route("/switches/restore/<hostname>", methods=["POST"])
@login_required
def restore_switch(hostname):
if not current_user.has_permission("papierkorb.edit"):
if not current_user.has_permission("papierkorb.edit") or not license_active():
flash("Keine Berechtigung, Switche wiederherzustellen.", "danger")
return redirect(url_for("papierkorb"))
conn = get_db_connection()
@@ -6781,7 +7242,7 @@ def restore_switch(hostname):
@app.route("/switches/purge/<hostname>", methods=["POST"])
@login_required
def purge_switch(hostname):
if not current_user.has_permission("papierkorb.edit"):
if not current_user.has_permission("papierkorb.edit") or not license_active():
flash("Keine Berechtigung, Switche endgültig zu löschen.", "danger")
return redirect(url_for("papierkorb"))
conn = get_db_connection()
@@ -6796,7 +7257,7 @@ def purge_switch(hostname):
@app.route("/credentials/restore/<int:cred_id>", methods=["POST"])
@login_required
def restore_credential(cred_id):
if not current_user.has_permission("papierkorb.edit"):
if not current_user.has_permission("papierkorb.edit") or not license_active():
flash("Keine Berechtigung, Zugangsdaten wiederherzustellen.", "danger")
return redirect(url_for("papierkorb"))
conn = get_db_connection()
@@ -6812,7 +7273,7 @@ def restore_credential(cred_id):
@app.route("/credentials/purge/<int:cred_id>", methods=["POST"])
@login_required
def purge_credential(cred_id):
if not current_user.has_permission("papierkorb.edit"):
if not current_user.has_permission("papierkorb.edit") or not license_active():
flash("Keine Berechtigung, Zugangsdaten endgültig zu löschen.", "danger")
return redirect(url_for("papierkorb"))
conn = get_db_connection()
@@ -6828,7 +7289,7 @@ def purge_credential(cred_id):
@app.route("/users/restore/<int:user_id>", methods=["POST"])
@login_required
def restore_user(user_id):
if not current_user.has_permission("papierkorb.edit"):
if not current_user.has_permission("papierkorb.edit") or not license_active():
flash("Keine Berechtigung, Benutzer wiederherzustellen.", "danger")
return redirect(url_for("papierkorb"))
conn = get_db_connection()
@@ -6850,7 +7311,7 @@ def restore_user(user_id):
@app.route("/users/purge/<int:user_id>", methods=["POST"])
@login_required
def purge_user(user_id):
if not current_user.has_permission("papierkorb.edit"):
if not current_user.has_permission("papierkorb.edit") or not license_active():
flash("Keine Berechtigung, Benutzer endgültig zu löschen.", "danger")
return redirect(url_for("papierkorb"))
conn = get_db_connection()
@@ -6872,7 +7333,7 @@ def purge_user(user_id):
@app.route("/groups/restore/<int:group_id>", methods=["POST"])
@login_required
def restore_group(group_id):
if not current_user.has_permission("papierkorb.edit"):
if not current_user.has_permission("papierkorb.edit") or not license_active():
flash("Keine Berechtigung, Gruppen wiederherzustellen.", "danger")
return redirect(url_for("papierkorb"))
conn = get_db_connection()
@@ -6888,7 +7349,7 @@ def restore_group(group_id):
@app.route("/groups/purge/<int:group_id>", methods=["POST"])
@login_required
def purge_group(group_id):
if not current_user.has_permission("papierkorb.edit"):
if not current_user.has_permission("papierkorb.edit") or not license_active():
flash("Keine Berechtigung, Gruppen endgültig zu löschen.", "danger")
return redirect(url_for("papierkorb"))
conn = get_db_connection()
@@ -6907,6 +7368,8 @@ def maintenance():
if not current_user.can_view_maintenance:
flash("Keine Berechtigung für die Wartungsseite.", "danger")
return redirect(url_for("index"))
if resp := _require_module("maintenance"):
return resp
conn = get_db_connection()
devices_rows = _maintenance_devices(conn)
conn.close()
@@ -7940,6 +8403,8 @@ def kea_log():
if not current_user.can_view_kea_log:
flash("Keine Berechtigung, das Kea-DHCP-Log einzusehen.", "danger")
return redirect(url_for("index"))
if resp := _require_module("dhcp"):
return resp
try:
with open(TESM_KEA_LOG_PATH, "r", encoding="utf-8", errors="replace") as f:
log_content = f.read()
+16
View File
@@ -574,6 +574,22 @@ button { font-family: inherit; }
70% { box-shadow: 0 0 0 6px rgba(47,208,122,0); }
100% { box-shadow: 0 0 0 0 rgba(47,208,122,0); }
}
/* Lizenz-Topbar-Pill: pulse-dot ist fest auf Grün (Erfolg) verdrahtet --
eigene Varianten für die rot/orange blinkenden Lizenz-Zustände, sonst
würde die Halo-Animation weiter grün pulsieren, egal welche
Hintergrundfarbe der Punkt selbst per Inline-Style bekommt. */
.timer-pill .dot.dot--blink-danger { animation: pulse-dot-danger 1.4s infinite; }
.timer-pill .dot.dot--blink-warning { animation: pulse-dot-warning 1.4s infinite; }
@keyframes pulse-dot-danger {
0% { box-shadow: 0 0 0 0 rgba(240,71,92,0.5); }
70% { box-shadow: 0 0 0 6px rgba(240,71,92,0); }
100% { box-shadow: 0 0 0 0 rgba(240,71,92,0); }
}
@keyframes pulse-dot-warning {
0% { box-shadow: 0 0 0 0 rgba(245,166,35,0.5); }
70% { box-shadow: 0 0 0 6px rgba(245,166,35,0); }
100% { box-shadow: 0 0 0 0 rgba(245,166,35,0); }
}
.timer-pill-refresh {
display: inline-flex; align-items: center; justify-content: center;
+6
View File
@@ -129,6 +129,12 @@
</button>
{% endif %}
</span>
{% if license_topbar %}
<a href="{{ url_for('settings_license') }}" class="timer-pill" title="{{ license_topbar.text }}" style="text-decoration:none;">
<span class="dot {% if license_topbar.blink %}dot--blink-{{ license_topbar.level }}{% endif %}"
style="{% if not license_topbar.blink %}animation:none; box-shadow:none;{% endif %} background:{{ 'var(--danger)' if license_topbar.level == 'danger' else 'var(--warning)' }};"></span>{{ license_topbar.text }}
</a>
{% endif %}
{% if dhcp_topbar_active is not none %}
<span class="timer-pill" title="DHCP-Dienst: {{ 'aktiv' if dhcp_topbar_active else 'inaktiv' }}">
<span class="dot" style="animation:none; background:{{ 'var(--success)' if dhcp_topbar_active else 'var(--danger)' }}; box-shadow:none;"></span>DHCP
+12 -5
View File
@@ -17,6 +17,13 @@
</div>
</div>
{% if not license_active() %}
<div class="notice-banner notice-banner--warning" style="margin-bottom:14px;">
Wiederherstellen und endgültiges Löschen sind nur mit gültiger Lizenz verfügbar — siehe
<a href="{{ url_for('settings_license') }}">Lizenz</a>. Ansehen ist weiterhin uneingeschränkt möglich.
</div>
{% endif %}
<div style="display:flex; flex-direction:column; gap:16px;">
<div class="card card-pad">
@@ -44,7 +51,7 @@
<td class="mono">{{ d['ip'] }}</td>
<td class="text-faint" style="font-size:12px;">{{ d['deleted_at'] }}</td>
<td>
{% if current_user.has_permission('papierkorb.edit') %}
{% if current_user.has_permission('papierkorb.edit') and license_active() %}
<div class="row-actions">
<form method="post" action="{{ url_for('restore_device', mac=d['mac']) }}">
<button type="submit" class="btn btn-sm btn-secondary">Wiederherstellen</button>
@@ -92,7 +99,7 @@
<td class="mono">{{ s['ip'] }}</td>
<td class="text-faint" style="font-size:12px;">{{ s['deleted_at'] }}</td>
<td>
{% if current_user.has_permission('papierkorb.edit') %}
{% if current_user.has_permission('papierkorb.edit') and license_active() %}
<div class="row-actions">
<form method="post" action="{{ url_for('restore_switch', hostname=s['hostname']) }}">
<button type="submit" class="btn btn-sm btn-secondary">Wiederherstellen</button>
@@ -140,7 +147,7 @@
<td class="mono">{{ c['username'] }}</td>
<td class="text-faint" style="font-size:12px;">{{ c['deleted_at'] }}</td>
<td>
{% if current_user.has_permission('papierkorb.edit') %}
{% if current_user.has_permission('papierkorb.edit') and license_active() %}
<div class="row-actions">
<form method="post" action="{{ url_for('restore_credential', cred_id=c['id']) }}">
<button type="submit" class="btn btn-sm btn-secondary">Wiederherstellen</button>
@@ -189,7 +196,7 @@
<td class="text-dim">{{ full_name or '—' }}</td>
<td class="text-faint" style="font-size:12px;">{{ u['deleted_at'] }}</td>
<td>
{% if current_user.has_permission('papierkorb.edit') %}
{% if current_user.has_permission('papierkorb.edit') and license_active() %}
<div class="row-actions">
<form method="post" action="{{ url_for('restore_user', user_id=u['id']) }}">
<button type="submit" class="btn btn-sm btn-secondary">Wiederherstellen</button>
@@ -235,7 +242,7 @@
<td class="cell-name">{{ g['name'] }}</td>
<td class="text-faint" style="font-size:12px;">{{ g['deleted_at'] }}</td>
<td>
{% if current_user.has_permission('papierkorb.edit') %}
{% if current_user.has_permission('papierkorb.edit') and license_active() %}
<div class="row-actions">
<form method="post" action="{{ url_for('restore_group', group_id=g['id']) }}">
<button type="submit" class="btn btn-sm btn-secondary">Wiederherstellen</button>
+20 -10
View File
@@ -14,7 +14,8 @@
<div class="hint">Name und Zeitzone dieses Hosts.</div>
</div>
</div>
{% if current_user.has_permission('settings_system.edit') %}
{% set can_edit_host = current_user.has_permission('settings_system.edit') %}
{% if can_edit_host %}
<form method="post">
<div class="field">
<label for="hostname">Hostname</label>
@@ -40,7 +41,18 @@
Zeitzone setzen
</button>
</form>
{% else %}
<div class="field">
<label>Hostname</label>
<input type="text" value="{{ current_hostname or '' }}" disabled>
</div>
<div class="field">
<label>Zeitzone</label>
<input type="text" value="{{ current_timezone or '' }}" disabled>
</div>
{% endif %}
<hr style="border:none; border-top:1px solid var(--border-soft); margin:18px 0;">
{% if can_edit_host and license_active() %}
<form method="post">
<div class="field">
<label for="interval">Prüfintervall (Minuten) {{ hi.hint_icon("Wie oft Geräte auf Erreichbarkeit geprüft werden. Der Hintergrund-Dienst (tesm-check.service) wird nach dem Speichern automatisch neu gestartet.", "Prüfintervall (Minuten)") }}</label>
@@ -52,18 +64,16 @@
</button>
</form>
{% else %}
<div class="field">
<label>Hostname</label>
<input type="text" value="{{ current_hostname or '' }}" disabled>
</div>
<div class="field">
<label>Zeitzone</label>
<input type="text" value="{{ current_timezone or '' }}" disabled>
</div>
<div class="field">
<label>Prüfintervall (Minuten)</label>
<input type="number" value="{{ interval }}" disabled>
<div class="field-hint">Nur Lesezugriff — für Änderungen fehlt das Recht „Systemeinstellungen ändern“.</div>
<div class="field-hint">
{% if not can_edit_host %}
Nur Lesezugriff — für Änderungen fehlt das Recht „Systemeinstellungen ändern“.
{% else %}
Nur mit gültiger Lizenz änderbar — siehe <a href="{{ url_for('settings_license') }}">Lizenz</a>.
{% endif %}
</div>
</div>
{% endif %}
</div>
@@ -71,7 +71,7 @@
<div class="hint">Ausgewählte Kategorien verschlüsselt sichern.</div>
</div>
</div>
{% if current_user.has_permission('settings_importexport.export') %}
{% if current_user.has_permission('settings_importexport.export') and license_export_allowed() %}
<form method="post" action="{{ url_for('export_data') }}">
<div class="field">
<label>Was exportieren?</label>
@@ -96,6 +96,8 @@
Export herunterladen
</button>
</form>
{% elif current_user.has_permission('settings_importexport.export') %}
<p class="text-faint" style="font-size:12.5px;">Für den Export ist keine gültige Lizenz vorhanden — siehe <a href="{{ url_for('settings_license') }}">Lizenz</a>.</p>
{% else %}
<p class="text-faint" style="font-size:12.5px;">Für den Export fehlt das Recht „Daten exportieren“.</p>
{% endif %}
+8 -2
View File
@@ -1,6 +1,6 @@
{% extends "base.html" %}
{% set active_page = "settings_ldap" %}
{% set can_edit = current_user.has_permission('settings_ldap.edit') %}
{% set can_edit = current_user.has_permission('settings_ldap.edit') and license_active() %}
{% block page_title %}LDAP / Active Directory{% endblock %}
{% block page_sub %}<div class="topbar-sub">Anmeldung mit dem Domänen-Passwort, zusätzlich zu lokalen Konten</div>{% endblock %}
@@ -96,7 +96,13 @@
</form>
{% endif %}
{% else %}
<p class="text-faint" style="font-size:12.5px;">Für Änderungen fehlt das Recht „LDAP/AD-Konfiguration speichern“.</p>
<p class="text-faint" style="font-size:12.5px;">
{% if current_user.has_permission('settings_ldap.edit') %}
Nur mit Lizenz verfügbar — siehe <a href="{{ url_for('settings_license') }}">Lizenz</a>.
{% else %}
Für Änderungen fehlt das Recht „LDAP/AD-Konfiguration speichern“.
{% endif %}
</p>
{% endif %}
</div>
+179
View File
@@ -0,0 +1,179 @@
{% extends "base.html" %}
{% set active_page = "settings_license" %}
{% set can_edit = current_user.has_permission('settings_system.edit') %}
{% block page_title %}Lizenz{% endblock %}
{% block page_sub %}<div class="topbar-sub">Lizenzstatus, Module und Aktivierung dieses Systems</div>{% endblock %}
{% block content %}
{% import "_hint_icon.html" as hi %}
<div class="settings-grid">
<div class="card card-pad">
<div class="section-head" style="margin-bottom:16px;">
<div>
<h2 style="font-size:16px;">Status</h2>
<div class="hint">Aktuell installierte Lizenz und ihr Zustand.</div>
</div>
</div>
{% if license_error %}
<div class="notice-banner notice-banner--warning" style="margin-bottom:14px;">{{ license_error }}</div>
{% endif %}
{% if not license_file %}
<p class="text-faint" style="font-size:12.5px;">Keine Lizenzdatei vorhanden — bitte unten eine Lizenzdatei hochladen.</p>
{% else %}
<div class="field">
<label>Kunde</label>
<input type="text" value="{{ license_file.customer.name or '' }}" disabled>
</div>
<div class="field">
<label>Lizenztyp</label>
<input type="text" value="{{ {'trial': 'Trial', 'standard': 'Standard', 'custom': 'Custom', 'enterprise': 'Enterprise'}.get(license_file.type, license_file.type) }}" disabled>
</div>
<div class="field">
<label>Module {{ hi.hint_icon("Zusätzlich zu den immer verfügbaren Standard-Funktionen freigeschaltete Vollmodule.", "Module") }}</label>
<input type="text" value="{{ (license_file.modules or [])|join(', ') if license_file.modules else 'keine' }}" disabled>
</div>
<div class="field">
<label>Ausgestellt am</label>
<input type="text" value="{{ license_file.issued_at or '' }}" disabled>
</div>
<div class="field">
<label>Läuft ab am</label>
<input type="text" value="{{ license_file.expires_at or '' }}" disabled>
</div>
<div class="field">
<label>Aktivierungsstatus</label>
<input type="text" value="{{ 'Aktiviert' if license_is_activated else 'Nicht aktiviert' }}" disabled>
</div>
<div class="field">
<label>Letzter Heartbeat</label>
<input type="text" value="{{ license_last_heartbeat_at or 'noch nie' }}" disabled>
</div>
{% if license_last_error %}
<div class="field">
<label>Letzter Fehler</label>
<input type="text" value="{{ license_last_error }}" disabled>
</div>
{% endif %}
<div class="field">
<label>System-Fingerabdruck {{ hi.hint_icon("Eindeutiger Identifikator dieses Systems, an den die Lizenz bei der Aktivierung gebunden wird.", "System-Fingerabdruck") }}</label>
<input type="text" class="mono" value="{{ license_fingerprint }}" disabled>
</div>
{% endif %}
</div>
{% if license_file and license_valid %}
<div class="card card-pad">
<div class="section-head" style="margin-bottom:16px;">
<div>
<h2 style="font-size:16px;">Aktivierung</h2>
<div class="hint">
Ein Protokoll, zwei Wege: automatisch online, oder — falls kein Netzwerkzugriff auf den
Lizenzserver besteht — per Code manuell mit dem Lizenzserver-Administrator ausgetauscht.
</div>
</div>
</div>
{% if not can_edit %}
<p class="text-faint" style="font-size:12.5px;">Für Aktivierung/Deaktivierung fehlt das Recht „Systemeinstellungen ändern“.</p>
{% elif pending_action %}
<p class="text-faint" style="font-size:12.5px; margin-bottom:10px;">
Offene {{ 'Aktivierungs' if pending_action.action == 'activate' else 'Deaktivierungs' }}-Anfrage — folgenden Code
beim Lizenzserver-Administrator eingeben:
</p>
<div class="field">
<textarea class="mono" rows="4" readonly onclick="this.select()" style="width:100%; resize:vertical;">{{ pending_code }}</textarea>
</div>
<form method="post" action="{{ url_for('license_activate_confirm' if pending_action.action == 'activate' else 'license_deactivate_confirm') }}">
<div class="field">
<label for="confirmation_code">Bestätigungscode vom Lizenzserver</label>
<textarea class="mono" name="confirmation_code" id="confirmation_code" rows="4" style="width:100%; resize:vertical;" required></textarea>
</div>
<button type="submit" class="btn btn-primary btn-block">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20 6L9 17l-5-5"/></svg>
Code bestätigen
</button>
</form>
<form method="post" action="{{ url_for('license_activate' if pending_action.action == 'activate' else 'license_deactivate') }}" style="margin-top:10px;">
<button type="submit" class="btn btn-secondary btn-block">Erneut online versuchen</button>
</form>
{% elif license_is_activated %}
<p class="text-faint" style="font-size:12.5px; margin-bottom:10px;">
Lizenz ist aktiviert. Bei einem Systemwechsel zuerst hier deaktivieren — danach kann der Kunde sich beim
Anbieter selbst eine neue Lizenz für das neue System ausstellen lassen.
</p>
<form method="post" action="{{ url_for('license_deactivate') }}"
data-confirm="Lizenz wirklich deaktivieren? Alle lizenzpflichtigen Module/Funktionen werden danach sofort inaktiv (Export bleibt bis zur nächsten Aktivierung möglich).">
<button type="submit" class="btn btn-danger btn-block">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><path d="M15 9l-6 6M9 9l6 6"/></svg>
Lizenz deaktivieren (Systemwechsel)
</button>
</form>
{% else %}
<form method="post" action="{{ url_for('license_activate') }}">
<button type="submit" class="btn btn-primary btn-block">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20 6L9 17l-5-5"/></svg>
Jetzt aktivieren
</button>
</form>
{% endif %}
</div>
{% endif %}
<div class="card card-pad">
<div class="section-head" style="margin-bottom:16px;">
<div>
<h2 style="font-size:16px;">Lizenzdatei hochladen</h2>
<div class="hint">Vom Anbieter erhaltene Lizenzdatei einspielen — muss danach noch aktiviert werden.</div>
</div>
</div>
{% if can_edit %}
<form method="post" action="{{ url_for('license_upload') }}" enctype="multipart/form-data">
<div class="field">
<label for="license_file">Lizenzdatei</label>
<input type="file" name="license_file" id="license_file" accept=".json,.lic" required>
</div>
<button type="submit" class="btn btn-secondary btn-block">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4"/><path d="M17 8l-5-5-5 5"/><path d="M12 3v12"/></svg>
Hochladen
</button>
</form>
{% else %}
<p class="text-faint" style="font-size:12.5px;">Für den Upload fehlt das Recht „Systemeinstellungen ändern“.</p>
{% endif %}
</div>
{% if license_file and license_file.vendor %}
<div class="card card-pad">
<div class="section-head" style="margin-bottom:16px;">
<div>
<h2 style="font-size:16px;">Anbieter</h2>
<div class="hint">Kontakt für Rückfragen zu dieser Lizenz.</div>
</div>
</div>
<div class="field">
<label>Firma</label>
<input type="text" value="{{ license_file.vendor.name or '' }}" disabled>
</div>
<div class="field">
<label>Telefon</label>
<input type="text" value="{{ license_file.vendor.phone or '' }}" disabled>
</div>
<div class="field">
<label>E-Mail</label>
<input type="text" value="{{ license_file.vendor.email or '' }}" disabled>
</div>
<div class="field">
<label>Anschrift</label>
<input type="text" value="{{ license_file.vendor.address or '' }}" disabled>
</div>
</div>
{% endif %}
</div>
{% endblock %}
+15 -3
View File
@@ -1,6 +1,6 @@
{% extends "base.html" %}
{% set active_page = "settings_nginx" %}
{% set can_edit = current_user.has_permission('settings_nginx.edit') %}
{% set can_edit = current_user.has_permission('settings_nginx.edit') and license_active() %}
{% block page_title %}NGINX{% endblock %}
{% block page_sub %}<div class="topbar-sub">Reverse-Proxy: Domain, Ports, SSL/HSTS und Zertifikat</div>{% endblock %}
@@ -73,7 +73,13 @@
</button>
</form>
{% else %}
<p class="text-faint" style="font-size:12.5px;">Für Änderungen fehlt das Recht „NGINX ändern“.</p>
<p class="text-faint" style="font-size:12.5px;">
{% if current_user.has_permission('settings_nginx.edit') %}
Nur mit Lizenz verfügbar — siehe <a href="{{ url_for('settings_license') }}">Lizenz</a>.
{% else %}
Für Änderungen fehlt das Recht „NGINX ändern“.
{% endif %}
</p>
{% endif %}
</div>
@@ -177,7 +183,13 @@
<p class="text-faint" style="font-size:11.5px;">SSL muss zuerst deaktiviert werden, bevor das Zertifikat entfernt werden kann.</p>
{% endif %}
{% else %}
<p class="text-faint" style="font-size:12.5px;">Für Anfordern/Upload/Entfernen fehlt das Recht „NGINX ändern“.</p>
<p class="text-faint" style="font-size:12.5px;">
{% if current_user.has_permission('settings_nginx.edit') %}
Nur mit Lizenz verfügbar — siehe <a href="{{ url_for('settings_license') }}">Lizenz</a>.
{% else %}
Für Anfordern/Upload/Entfernen fehlt das Recht „NGINX ändern“.
{% endif %}
</p>
{% endif %}
</div>
+22 -1
View File
@@ -80,6 +80,7 @@ EOF
function check_device() {
local rpi_ip=$1 dev_name=$2 switch_ip=$3 switch_ssh_port=$4
local switch_hostname=$5 switch_port=$6 switch_user=$7 switch_pass=$8
local license_active=$9
if ping -c 1 -W 2 "$rpi_ip" &> /dev/null; then
echo "$(date '+%Y-%m-%d %H:%M:%S') $dev_name ist erreichbar!" >> "$LOGFILE"
@@ -92,6 +93,15 @@ function check_device() {
return
fi
# Automatischer PoE-Neustart bei Ausfall ist ein lizenzpflichtiges
# Feature (siehe Lizenzsystem) -- der manuelle Neustart-Button im
# Dashboard (manual_restart() weiter unten) ruft disable_poe/enable_poe
# dagegen IMMER direkt auf und bleibt davon bewusst unberührt.
if [ "$license_active" != "1" ]; then
echo "$(date '+%Y-%m-%d %H:%M:%S') $dev_name: automatischer PoE-Neustart übersprungen -- keine gültige Lizenz." >> "$LOGFILE"
return
fi
if ! disable_poe "$switch_ip" "$switch_port" "$switch_user" "$switch_pass" "$switch_ssh_port"; then
echo "$(date '+%Y-%m-%d %H:%M:%S') $dev_name: Switch $switch_hostname nicht erreichbar oder Kommando abgelehnt — PoE-Deaktivierung fehlgeschlagen, kein Neustart durchgeführt." >> "$LOGFILE"
return
@@ -146,8 +156,19 @@ fi
while true; do
echo "--------------------------------------------------------------------" >> "$LOGFILE"
# Einmal pro Schleifendurchlauf gelesen (wie SLEEP oben beim Skriptstart),
# nicht pro Gerät -- reicht als Shell-Variable an check_device() durch.
LICENSE_ACTIVE=$(python3 - <<'END'
import sys
sys.path.insert(0, "/srv/tesm")
from app import license_active
print("1" if license_active() else "0")
END
)
LICENSE_ACTIVE=${LICENSE_ACTIVE:-0}
while IFS='|' read -r rpi_ip dev_name switch_ip switch_ssh_port switch_hostname switch_port switch_user switch_pass mac; do
check_device "$rpi_ip" "$dev_name" "$switch_ip" "$switch_ssh_port" "$switch_hostname" "$switch_port" "$switch_user" "$switch_pass" &
check_device "$rpi_ip" "$dev_name" "$switch_ip" "$switch_ssh_port" "$switch_hostname" "$switch_port" "$switch_user" "$switch_pass" "$LICENSE_ACTIVE" &
done < <(python3 /srv/tesm/generate_ips.py)
wait