Compare commits

..
8 Commits
Author SHA1 Message Date
alientimandClaude Sonnet 5 36816c57ef Version 1.2.1: update.sh-Fix fuer 'latest'-Aufloesung
Kein App-Code geaendert (app.py unveraendert) -- reiner Versions-/Release-
Bump, damit 'latest' auch als heruntergeladenes Paket das bereits
korrigierte update.sh enthaelt (siehe vorheriger Commit).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-23 10:49:40 +02:00
alientimandClaude Sonnet 5 4a77a3ba32 update.sh: 'latest' zuverlaessig ueber die Gitea-API aufloesen
Bug (live gefunden): /releases/download/latest/tesm-latest.tar.gz liefert
HTTP 200 -- aber nicht das erwartete Release-Asset, sondern (weil kein
Asset exakt so heisst) Giteas automatisch generiertes Quellcode-Archiv
des AKTUELLEN Default-Branch-Stands, ohne jede Fehlermeldung. Ein
einfaches 'sudo ./update.sh' ohne explizites TESM_RELEASE_TAG haette also
lautlos einen unversionierten Zwischenstand installiert statt des
tatsaechlich neuesten Releases.

Fix: 'latest' wird jetzt zuerst per 'GET /releases/latest' (Gitea-API,
das funktioniert bereits zuverlaessig) auf den echten Tag-Namen aufgeloest,
BEVOR die Download-URL gebaut wird -- der eigentliche Download zielt damit
immer auf ein konkretes, korrekt benanntes Release-Asset. Zusaetzliche
Absicherung: nach dem Entpacken wird explizit geprueft, dass install.sh
im Paket vorhanden ist, statt erst beim Aufruf mit einer kryptischen
'No such file'-Meldung zu scheitern.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-23 10:47:50 +02:00
alientimandClaude Sonnet 5 7a1f2b164e Master-Lizenzserver in eigenes Repo ausgelagert (tesm-license)
srv/tesm-license/, install-license.sh, update-license.sh sowie die
zugehoerigen systemd-/nginx-Vorlagen leben ab sofort im eigenstaendigen
Repo https://gitea.int.eertmoed.net/alientim/tesm-license (komplette
bisherige Historie per git-filter-repo mit uebernommen, dort als v1.0.0
getaggt/released). Grund: eigener Release-/Versionszyklus getrennt von
TESM selbst, sauberere Trennung zwischen Kundenprodukt und interner
Lizenzierungs-Infrastruktur.

Betrifft nur dieses Repo -- die bereits laufende Installation auf
192.168.82.51 (TESM-Lizenzserver) ist unveraendert, zukuenftige Updates
dort laufen ueber das neue Repo/update.sh.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-23 10:40:21 +02:00
alientimandClaude Sonnet 5 682e9b4420 Lizenzserver-Deployment: install-license.sh/update-license.sh + systemd/nginx-Vorlagen
Fork von install.sh/update.sh/tesm.service/nginx-Site (TESM selbst),
beschraenkt auf /srv/tesm-license -- deployt ausschliesslich den in Phase 2
gebauten Master-Lizenzserver, laesst eine evtl. auf demselben Host
vorhandene TESM-Installation komplett unberuehrt (eigener Pfad-/Env-Var-
Namespace, siehe app.py). Gleiches Backup+Health-Check+Rueckroll-
Sicherheitsnetz wie bei TESMs eigenem Installer.

Teil von Phase 3 (Infrastruktur), siehe Plan toasty-twirling-hickey.md.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-23 10:15:02 +02:00
alientimandClaude Sonnet 5 90e07aa310 Master-Lizenzserver Phase 2: neue Schwester-Anwendung srv/tesm-license (v1.0.0)
Fork von TESM (srv/tesm), auf Kunden-/Lizenzverwaltung reduziert statt
Geraete-/PoE-Management. Wiederverwendet unveraendert: Login/Session/
Benutzer- und Gruppenverwaltung, LDAP/AD, NGINX- und Systemeinstellungen,
Live-Log/Verlauf/Auditlog, Im-/Export-Grundgeruest, das komplette
PERMISSIONS/NAV_ITEMS/inject_nav()-Rechtesystem sowie das in Phase 1
gebaute Lizenzsystem selbst sowohl fuer die MASTER-eigene Bootstrap-Lizenz
als auch fuer das exakt gleiche licensing.py (byte-identisch zu TESM --
Signieren/Verifizieren muss zwischen beiden Seiten kompatibel bleiben).

Entfernt: Clients/Switche/Zugangsdaten/DHCP/Fileshare/Wartung/Papierkorb/
manueller PoE-Neustart/SSH-Terminal inkl. aller zugehoerigen Tabellen,
Routen, Permissions, Nav-Eintraege und Abhaengigkeiten (paramiko/Flask-Sock/
simple-websocket/PyNaCl/pyasn1 aus requirements.txt). Benutzer-/Gruppen-
Loeschung von Soft- auf Hard-Delete umgestellt (kein Papierkorb mehr).
Eigener Pfad-/Env-Var-Namespace (TESM_LICENSE_* statt TESM_*, /var/log/
tesm-license statt /var/log/tesm usw.), damit Master und TESM testweise
sogar auf demselben Host nebeneinander laufen koennen, ohne sich Log-/
Config-Pfade streitig zu machen.

Neu -- der eigentliche Lizenzserver:
- license_customers/licenses-Tabellen, Master-Signaturschluessel
  (master_signing_key.json, einmalig erzeugt, NIE automatisch rotiert --
  jede Kundenlizenz traegt den zum Ausstellungszeitpunkt aktuellen
  master_pubkey fest eingebettet).
- Dashboard ('/') als Kunden-/Lizenzuebersicht (Typ, Module, Ablauf,
  Status, letzter Heartbeat), eigene Kunden-Verwaltungsseite.
- Lizenz-Ausstellung (Typ/Module/Laufzeit -> signierte Datei via
  licensing.issue_license), Detailseite, Download, Widerruf.
- /api/activate, /api/deactivate, /api/heartbeat (unauthentifiziert per
  Design -- die Signatur der Anfrage IST der Berechtigungsnachweis) sowie
  eine manuelle Offline-Code-Seite, die dieselben drei Verarbeitungs-
  funktionen (_process_activate/_process_deactivate/_process_heartbeat)
  nutzt wie die Online-API -- ein Protokoll, zwei Transportwege.
- E-Mail-Versand ausgestellter Lizenzen per Microsoft Graph
  (Client-Credentials-Flow, reine Standardbibliothek/urllib, keine neue
  Abhaengigkeit) inkl. Einrichtungsanleitung und Verbindungstest.
- create_master_license.py: lokales Bootstrap-/Erneuerungs-Skript fuer
  die eigene Lizenz des Masters (kein externer Super-Master noetig).

Verifiziert auf dem Testsystem (192.168.82.51): App laeuft parallel zu der
dort laufenden echten TESM-Instanz (Port 5001 vs. 80/5000, eigene
Log-/DB-Pfade, TESM unangetastet). Kompletter ECHTER End-to-End-Rundlauf
per Playwright durchgespielt -- kein simulierter Gegenpart: Kunde anlegen
-> Custom-Lizenz (dhcp+fileshare) ausstellen -> herunterladen -> auf der
echten TESM-Instanz hochladen -> ECHTE Online-Aktivierung (Master
verzeichnet Fingerprint, TESM zeigt 'Aktiviert') -> ECHTE Online-
Deaktivierung (TESM zeigt 'lizenzlos, Export bleibt moeglich', Master
zeigt Status 'Deaktiviert'). Lokale Syntax-/Templatepruefung (py_compile,
pyflakes, jinja2-Parse aller Templates) sauber.

Bewusst NICHT nach main gemergt/getaggt/ausgerollt -- folgt zusammen mit
Phase 3 (Infrastruktur-Umzug + POETEST-Enterprise-Lizenz), siehe Plan
toasty-twirling-hickey.md (Phase 2 von 3).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-23 02:07:30 +02:00
alientimandClaude Sonnet 5 c9ac0bf69b 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>
2026-08-23 00:45:35 +02:00
alientimandClaude Sonnet 5 b70ebefb17 Lizenzsystem Phase 0: Ed25519-Kryptographie, Aktivierungs-/Heartbeat-Protokoll (licensing.py)
Neues gemeinsames Modul fuer Client (TESM) und kuenftigen Master-Lizenzserver:
- Zwei-Ebenen-Ed25519-Schluesselhierarchie (Master-Signaturschluessel +
  Pro-Lizenz-Schluesselpaar), kanonische JSON-Signierung
- issue_license/verify_license_file/license_status (inkl. 30-Tage-Gnadenfrist,
  30-Tage-Vorwarnung, Modul-Set)
- Ein Protokoll fuer Online- (API) und Offline-Aktivierung (Code-Copy-Paste):
  build_client_request/verify_client_request,
  build_master_response/verify_master_response, encode_code/decode_code
- system_fingerprint (Systembindung), heartbeat_stale_warning (14 Tage,
  rein informativ)

Isoliert getestet (Signatur-Rundlauf, Manipulationserkennung, Ablauf-/
Gnadenfrist-Datumslogik, Online- und Offline-Aktivierung/Deaktivierung inkl.
Fingerprint-Bindungspruefung und Reaktivierung nach Deaktivierung,
Eingabevalidierung) -- 41/41 Checks erfolgreich.

Teil von Phase 0 des Lizenz-/Modulsystems, siehe Plan
toasty-twirling-hickey.md.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-22 23:42:32 +02:00
alientimandClaude Sonnet 5 d90c27b8f8 LDAP-Export: Sperrstatus + individuelle Gruppenzuweisung von AD-Nutzern (weiterhin v1.1.11)
_export_users()/_import_users() schliessen AD/LDAP-Konten bewusst komplett
aus (sie werden ueber Active Directory verwaltet). Das hatte aber eine
Luecke: weder eine manuelle Sperrung/Entsperrung noch eine manuell (per
"Gruppe zuweisen") vergebene individuelle Gruppenmitgliedschaft eines
AD-Benutzers wurden bisher irgendwo gesichert -- bei einem Umzug auf ein
neues System (der eigentliche Zweck dieser Seite) waere ein zuvor
gesperrtes AD-Konto dort wieder entsperrt gewesen, und individuell
vergebene Rechte waeren beim naechsten Login des Nutzers verloren
gegangen.

Neu in der "ldap"-Kategorie: ad_user_states exportiert fuer jeden bereits
bekannten AD-Benutzer Sperrstatus + aktuelle Gruppenmitgliedschaft (inkl.
Admin als Sentinel, analog den bestehenden AD-Gruppenzuordnungen). Beim
Import wird ein bereits vorhandenes AD-Konto entsprechend aktualisiert;
existiert es auf dem Zielsystem noch nicht (Nutzer hat sich dort noch nie
eingeloggt), wird ein Platzhalter-Konto mit auth_source='ldap' und
korrektem Sperrstatus VORAB angelegt -- der Login-Pfad prueft is_locked
bereits vor der eigentlichen AD-Authentifizierung, ein vorab gesperrtes
Konto bleibt also gesperrt. Gruppenzuweisungen sind rein additiv
(insert or ignore), die naechste automatische AD-Gruppenzuordnung beim
Login nimmt nichts davon wieder weg.

Ausserdem:
- Die pauschalen "Was exportieren?"/"Was importieren?"-Hinweise entfernt
  (waren durch die praeziseren Kategorie-Hinweise bei Benutzer/LDAP/
  Zugangsdaten/NGINX/Auditlog laengst ueberholt und teils sachlich
  veraltet).
- Kategorie-Hinweise jetzt auch in der Import-Vorschau sichtbar (vorher
  nur beim Export).
- "Nur Admin"-Kennzeichnung aus beiden Kategorie-Listen entfernt (rein
  optisch, die serverseitige Rechtepruefung bleibt unveraendert).

Verifiziert per isoliertem Roundtrip-Test: gesperrter AD-Nutzer bleibt
nach Reset+Import gesperrt, manuell zugewiesene Gruppe eines AD-Nutzers
uebersteht Loeschen+Import, und ein komplett neuer (nie eingeloggter)
AD-Nutzer wird korrekt mit Sperrstatus/Gruppen/Admin-Flag vorangelegt --
inkl. Bestaetigung, dass der echte Login-Sperr-Check dieses vorab
angelegte Konto tatsaechlich blockiert.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-22 22:46:10 +02:00
13 changed files with 1147 additions and 53 deletions
+1 -1
View File
@@ -1 +1 @@
1.1.11
1.2.1
+539 -19
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()
@@ -5451,6 +5909,7 @@ EXPORT_SECTION_LABELS = dict(EXPORT_SECTIONS)
# 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, die automatischen AD-Gruppen- und Fileshare-Zuordnungen 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.",
"credentials": "Enthält zusätzlich den Inhalt von known_hosts (bereits bestätigte SSH-Host-Keys) — wird beim Import ergänzend (nicht überschreibend) eingespielt.",
"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).",
@@ -5537,9 +5996,18 @@ def _export_ldap(conn):
(frische Installation vergibt Gruppen-IDs neu). Exportiert wird daher
der Gruppenname (analog _export_switches()' credential_name), _import_ldap
löst ihn auf dem Zielsystem wieder zur dortigen ID auf -- gleiches
"admin"/g.name-Mapping wie in _ldap_group_mappings() weiter oben."""
"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": [], "fileshare_mappings": []}
data = {"settings": settings_data, "group_mappings": [], "fileshare_mappings": [], "ad_user_states": []}
bind = conn.execute(
"SELECT username, password FROM service_accounts WHERE purpose=?", (LDAP_BIND_SERVICE_ACCOUNT_PURPOSE,)
).fetchone()
@@ -5554,7 +6022,19 @@ def _export_ldap(conn):
data["fileshare_mappings"] = [dict(m) for m in conn.execute(
"SELECT ad_group_dn, ad_group_name, share_label, share_unc FROM ldap_fileshare_mappings"
).fetchall()]
if not (settings_data or "bind_username" in data or data["group_mappings"] or data["fileshare_mappings"]):
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["fileshare_mappings"]
or data["ad_user_states"]):
return None
return data
@@ -5800,6 +6280,39 @@ def _import_ldap(conn, data):
(m["ad_group_dn"], m["ad_group_name"], m["share_label"], m["share_unc"]),
)
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
@@ -5919,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]
@@ -6679,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()
@@ -6695,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()
@@ -6711,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()
@@ -6726,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()
@@ -6741,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()
@@ -6757,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()
@@ -6773,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()
@@ -6795,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()
@@ -6817,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()
@@ -6833,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()
@@ -6852,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()
@@ -7885,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()
+281
View File
@@ -0,0 +1,281 @@
"""
TESM Lizenzsystem — gemeinsame Kryptographie- und Protokoll-Bausteine für
Kundeninstanz (Client) UND Lizenzserver (Master). Beide Seiten importieren
exakt dieselbe Datei (auf dem Master 1:1 mitkopiert) — Signieren und
Verifizieren müssen bitidentisch funktionieren, jede Abweichung würde
Lizenzen der jeweils anderen Seite unlesbar machen.
Vollständiges Design: siehe C:\\Users\\tim\\.claude\\plans\\toasty-twirling-hickey.md
(Phase 0). Kurzfassung der Schlüsselhierarchie:
- Master-Signaturschlüssel (Ed25519): signiert Lizenzdateien selbst UND
jede Aktivierungs-/Deaktivierungs-/Heartbeat-Antwort. Privater Teil bleibt
ausschließlich auf dem Master, öffentlicher Teil steckt in jeder
ausgestellten Lizenzdatei (ermöglicht rein OFFLINE verifizierbare
Master-Antworten beim Kunden).
- Pro-Lizenz-Schlüsselpaar (Ed25519, einmalig je Lizenz erzeugt): der
private Teil reist in der Lizenzdatei zum Kunden und signiert dessen
Aktivierungs-/Deaktivierungs-/Heartbeat-*Anfragen*; der öffentliche Teil
bleibt beim Master in dessen Datenbank (ermöglicht dem Master, jede
Anfrage einer bestimmten Lizenz zweifelsfrei zuzuordnen, ohne die
ursprüngliche Aktivierung "live" gesehen haben zu müssen).
Ed25519 statt RSA: kleine Schlüssel/Signaturen (wichtig, da Aktivierungs-
Anfragen/-Antworten für den Offline-Fall als von Hand kopierbarer Code
dargestellt werden), fest vorgegebene, sichere Parameter (keine
Padding-/Hash-Wahl wie bei RSA-PSS nötig).
"""
import base64
import datetime
import hashlib
import json
import socket
import time
import uuid
from cryptography.exceptions import InvalidSignature
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey, Ed25519PublicKey
LICENSE_TYPES = ("trial", "standard", "custom", "enterprise")
ALL_MODULES = ("dhcp", "fileshare", "maintenance")
GRACE_PERIOD_DAYS = 30 # nach Ablauf, bevor lizenzpflichtige Funktionen tatsächlich abgeschaltet werden
EXPIRY_WARNING_DAYS = 30 # Vorlauf für die orange "läuft bald ab"-Anzeige
HEARTBEAT_WARNING_DAYS = 14 # Nichterreichbarkeits-Hinweis (rein informativ, schaltet nie etwas ab)
# ============================================================== Schlüssel ==
def generate_keypair():
"""Erzeugt ein neues Ed25519-Schlüsselpaar. Rückgabe: (private_b64, public_b64)."""
priv = Ed25519PrivateKey.generate()
return _encode_private_key(priv), _encode_public_key(priv.public_key())
def _encode_private_key(priv: Ed25519PrivateKey) -> str:
return base64.urlsafe_b64encode(priv.private_bytes_raw()).decode("ascii")
def _encode_public_key(pub: Ed25519PublicKey) -> str:
return base64.urlsafe_b64encode(pub.public_bytes_raw()).decode("ascii")
def _decode_private_key(s: str) -> Ed25519PrivateKey:
return Ed25519PrivateKey.from_private_bytes(base64.urlsafe_b64decode(s.encode("ascii")))
def _decode_public_key(s: str) -> Ed25519PublicKey:
return Ed25519PublicKey.from_public_bytes(base64.urlsafe_b64decode(s.encode("ascii")))
# ===================================================== Signieren/Prüfen ==
def _canonical_bytes(payload: dict) -> bytes:
"""Deterministische JSON-Kodierung (sortierte Keys, kompakte Trenner) --
Voraussetzung dafür, dass Signieren und Verifizieren exakt dieselben
Bytes sehen, unabhängig von der Dict-Einfügereihenfolge."""
return json.dumps(payload, sort_keys=True, separators=(",", ":")).encode("utf-8")
def sign_payload(payload: dict, private_key_b64: str) -> str:
priv = _decode_private_key(private_key_b64)
return base64.urlsafe_b64encode(priv.sign(_canonical_bytes(payload))).decode("ascii")
def verify_payload(payload: dict, signature_b64: str, public_key_b64: str) -> bool:
"""Gibt bewusst nur True/False zurück (nie eine Exception nach außen) --
ein Aufrufer soll "ungültig" nie mit einem Absturz verwechseln können."""
try:
pub = _decode_public_key(public_key_b64)
pub.verify(base64.urlsafe_b64decode(signature_b64.encode("ascii")), _canonical_bytes(payload))
return True
except (InvalidSignature, ValueError, TypeError, KeyError):
return False
# ============================================================ Zeitformat ==
def _iso(ts: float) -> str:
return datetime.datetime.fromtimestamp(ts, tz=datetime.timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
def _parse_iso(s: str) -> float:
return datetime.datetime.strptime(s, "%Y-%m-%dT%H:%M:%SZ").replace(tzinfo=datetime.timezone.utc).timestamp()
# ================================================================ Lizenz ==
def issue_license(*, customer, license_type, modules, valid_days,
master_private_key_b64, master_public_key_b64,
master_endpoint, vendor, license_id=None, now=None):
"""Vom MASTER aufgerufen: erzeugt eine neue, signierte Lizenz samt
frischem Pro-Lizenz-Schlüsselpaar.
Rückgabe: (license_file, license_pubkey) -- license_file ist die
komplette, an den Kunden auszuhändigende Datei (inkl. dem PRIVATEN
Lizenzschlüssel); license_pubkey ist NUR für die Master-Datenbank
bestimmt (Verifikation künftiger Aktivierungs-/Heartbeat-Anfragen
dieser Lizenz) und wird nicht an den Kunden weitergegeben."""
if license_type not in LICENSE_TYPES:
raise ValueError(f"Unbekannter Lizenztyp: {license_type!r} (erlaubt: {LICENSE_TYPES})")
unknown = set(modules) - set(ALL_MODULES)
if unknown:
raise ValueError(f"Unbekannte Module: {sorted(unknown)} (erlaubt: {ALL_MODULES})")
license_priv, license_pub = generate_keypair()
now = now if now is not None else time.time()
payload = {
"license_id": license_id or str(uuid.uuid4()),
"customer": customer,
"type": license_type,
"modules": sorted(modules),
"issued_at": _iso(now),
"expires_at": _iso(now + valid_days * 86400),
"license_pubkey": license_pub,
"master_pubkey": master_public_key_b64,
"master_endpoint": master_endpoint,
"vendor": vendor,
}
signature = sign_payload(payload, master_private_key_b64)
license_file = {**payload, "license_privkey": license_priv, "signature": signature}
return license_file, license_pub
def verify_license_file(license_file: dict) -> bool:
"""Prüft die Master-Signatur über die Lizenz-Nutzdaten. license_privkey
und signature selbst sind nicht Teil der signierten Nutzlast (die
Signatur wurde ja gerade über den Rest gebildet, siehe issue_license)."""
payload = {k: v for k, v in license_file.items() if k not in ("license_privkey", "signature")}
return verify_payload(payload, license_file.get("signature", ""), license_file.get("master_pubkey", ""))
def license_status(license_file: dict, now=None) -> dict:
"""Berechnet den aktuellen Anzeige-/Gate-Status einer (bereits als
signaturgültig geprüften!) Lizenz -- ruft NICHT selbst verify_license_file
auf, das bleibt bewusst Sache des Aufrufers, damit hier niemand versehentlich
den Status einer manipulierten Datei berechnet, ohne die Prüfung
überhaupt gemacht zu haben."""
now = now if now is not None else time.time()
expires_at = _parse_iso(license_file["expires_at"])
days_left = (expires_at - now) / 86400
expired = days_left < 0
days_since_expiry = -days_left if expired else 0.0
grace_active = expired and days_since_expiry <= GRACE_PERIOD_DAYS
modules_active = (not expired) or grace_active
return {
"expired": expired,
"days_left": days_left,
"days_since_expiry": days_since_expiry,
"expiring_soon": (not expired) and days_left <= EXPIRY_WARNING_DAYS,
"grace_active": grace_active,
"modules_active": modules_active,
"modules": set(license_file.get("modules", [])) if modules_active else set(),
"type": license_file.get("type"),
"customer": license_file.get("customer"),
"expires_at": license_file.get("expires_at"),
}
def heartbeat_stale_warning(last_heartbeat_ts, now=None) -> bool:
"""True, wenn der letzte erfolgreiche Heartbeat HEARTBEAT_WARNING_DAYS
oder länger zurückliegt. Rein informativ (siehe Moduldocstring) -- ein
fehlender/alter Heartbeat schaltet nie ein Modul ab, nur das
eingebettete Ablaufdatum selbst zählt dafür (siehe license_status)."""
if last_heartbeat_ts is None:
return False # noch nie verbunden gewesen ist der normale Ausgangszustand, kein Ausfall
now = now if now is not None else time.time()
return (now - last_heartbeat_ts) / 86400 >= HEARTBEAT_WARNING_DAYS
# ========================================== Fingerprint (System-Bindung) ==
def system_fingerprint():
"""Stabiler Identifikator dieses Hosts: /etc/machine-id (von systemd bei
der Erstinstallation einmalig erzeugt, übersteht Reboots und normale
Updates) kombiniert mit dem Hostnamen, gehasht damit die rohe
machine-id nie im Klartext übertragen/gespeichert wird. Unter
Windows/ohne /etc/machine-id fällt dies auf den Hostnamen allein
zurück (nur für lokale Tests relevant, Produktivsysteme sind Linux)."""
machine_id = ""
try:
with open("/etc/machine-id", "r", encoding="utf-8") as f:
machine_id = f.read().strip()
except OSError:
pass
raw = f"{machine_id}:{socket.gethostname()}".encode("utf-8")
return hashlib.sha256(raw).hexdigest()
# ============================ Aktivierung / Deaktivierung / Heartbeat =====
# Ein Protokoll, zwei Transportwege: online automatisch per HTTPS-API,
# offline exakt dasselbe Anfrage/Antwort-Paar als von Hand kopierbarer Code
# (encode_code/decode_code) -- der Master ist dadurch in BEIDEN Fällen die
# einzige Quelle der Wahrheit dafür, an welches System eine Lizenz gerade
# gebunden ist, siehe Plan-Abschnitt "Warum das die Doppelnutzung wirksam
# verhindert".
def build_client_request(action, license_file, nonce=None) -> dict:
"""Vom CLIENT aufgerufen: erzeugt eine signierte Anfrage (action:
"activate"/"deactivate"/"heartbeat"). Das Ergebnis ist unverändert für
einen Online-API-Aufruf nutzbar; für den Offline-Fall wird es
zusätzlich mit encode_code() in einen kopierbaren Code umgewandelt."""
if action not in ("activate", "deactivate", "heartbeat"):
raise ValueError(f"Unbekannte Aktion: {action!r}")
payload = {
"license_id": license_file["license_id"],
"fingerprint": system_fingerprint(),
"action": action,
"nonce": nonce or uuid.uuid4().hex,
"timestamp": _iso(time.time()),
}
signature = sign_payload(payload, license_file["license_privkey"])
return {**payload, "signature": signature}
def verify_client_request(request: dict, license_public_key_b64: str) -> bool:
"""Vom MASTER aufgerufen: prüft eine Client-Anfrage gegen den bei
Ausstellung dieser Lizenz in der Master-DB gespeicherten Public-Key."""
payload = {k: v for k, v in request.items() if k != "signature"}
return verify_payload(payload, request.get("signature", ""), license_public_key_b64)
def build_master_response(action, license_id, fingerprint, master_private_key_b64,
status="ok", license_update=None, now=None) -> dict:
"""Vom MASTER aufgerufen: erzeugt eine signierte Antwort/Bestätigung
(z.B. "activated", "deactivated", oder das Ergebnis eines Heartbeats).
license_update: optional eine komplette, neu signierte Lizenzdatei
(z.B. nach nachträglicher Modul-Änderung durch den Admin) -- der Client
übernimmt sie nur nach eigener, erfolgreicher Signaturprüfung."""
payload = {
"license_id": license_id,
"fingerprint": fingerprint,
"action": action,
"status": status,
"timestamp": _iso(now if now is not None else time.time()),
}
if license_update is not None:
payload["license_update"] = license_update
signature = sign_payload(payload, master_private_key_b64)
return {**payload, "signature": signature}
def verify_master_response(response: dict, master_public_key_b64: str) -> bool:
"""Vom CLIENT aufgerufen: prüft eine Master-Antwort komplett OFFLINE
gegen den in der eigenen Lizenzdatei eingebetteten master_pubkey."""
payload = {k: v for k, v in response.items() if k != "signature"}
return verify_payload(payload, response.get("signature", ""), master_public_key_b64)
# ==================================================== Codes für Offline ==
def encode_code(data: dict) -> str:
"""Kodiert ein Anfrage-/Antwort-dict als kompakten, per Copy-Paste
übertragbaren Code (Base64 einer kanonischen JSON-Darstellung, keine
Zeilenumbrüche/Sonderzeichen, damit Kopieren aus/in ein Textfeld nichts
kaputt macht)."""
return base64.urlsafe_b64encode(_canonical_bytes(data)).decode("ascii")
def decode_code(code: str) -> dict:
return json.loads(base64.urlsafe_b64decode(code.strip().encode("ascii")).decode("utf-8"))
+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>
@@ -27,7 +27,7 @@
data-confirm="Ausgewählte Kategorien wirklich importieren? Bestehende Einträge mit gleichem Namen/Hostname/MAC/Benutzernamen werden überschrieben.">
<input type="hidden" name="import_token" value="{{ import_preview.token }}">
<div class="field">
<label>Was importieren? {{ hi.hint_icon("AD/LDAP-Benutzerkonten sind hiervon unberührt — sie werden über Active Directory/Windows verwaltet, nicht über diese App, und beim nächsten Login automatisch neu angelegt.", "Was importieren?") }}</label>
<label>Was importieren?</label>
<div class="check-list">
{% for s in import_preview.sections %}
<label class="check-row">
@@ -35,7 +35,7 @@
{% if s.key not in admin_only_sections or current_user.is_admin %}checked{% endif %}
{% if s.admin_only and not current_user.is_admin %}disabled{% endif %}>
{{ s.label }} <span class="text-faint">({{ s.count }})</span>
{% if s.admin_only %}<span class="pill user" style="font-size:10px; padding:2px 7px;">Nur Admin</span>{% endif %}
{% if export_section_hints and s.key in export_section_hints %}{{ hi.hint_icon(export_section_hints[s.key], s.label) }}{% endif %}
</label>
{% endfor %}
</div>
@@ -71,10 +71,10 @@
<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? {{ hi.hint_icon("AD/LDAP-Benutzerkonten werden nie mitexportiert — sie werden über Active Directory/Windows verwaltet (nicht über diese App) und legen sich beim nächsten Login automatisch wieder an. Beim LDAP-Export wird lediglich das Bind-Konto (verschlüsselt) sowie die Gruppenzuordnungen gesichert.", "Was exportieren?") }}</label>
<label>Was exportieren?</label>
<div class="check-list">
{% for key, label in export_sections %}
<label class="check-row">
@@ -83,7 +83,6 @@
{% if key in admin_only_sections and not current_user.is_admin %}disabled{% endif %}>
{{ label }}
{% if export_section_hints and key in export_section_hints %}{{ hi.hint_icon(export_section_hints[key], label) }}{% endif %}
{% if key in admin_only_sections %}<span class="pill user" style="font-size:10px; padding:2px 7px;">Nur Admin</span>{% endif %}
</label>
{% endfor %}
</div>
@@ -97,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>
+42 -7
View File
@@ -34,11 +34,8 @@ REPO_NAME="${TESM_REPO_NAME:-tesm}"
RELEASE_TAG="${TESM_RELEASE_TAG:-latest}"
GITEA_USER="${TESM_GITEA_USER:-}"
GITEA_TOKEN="${TESM_GITEA_TOKEN:-}"
PACKAGE_NAME="tesm-${RELEASE_TAG}.tar.gz"
DOWNLOAD_URL="${GITEA_BASE}/${REPO_OWNER}/${REPO_NAME}/releases/download/${RELEASE_TAG}/${PACKAGE_NAME}"
WORK_DIR="/tmp/tesm-update-$(date +%s)"
PACKAGE_FILE="$WORK_DIR/${PACKAGE_NAME}"
RED='\033[0;31m'
GREEN='\033[0;32m'
@@ -50,6 +47,38 @@ if [ "$(id -u)" -ne 0 ]; then
exit 1
fi
CURL_AUTH=()
if [ -n "$GITEA_USER" ] && [ -n "$GITEA_TOKEN" ]; then
CURL_AUTH=(-u "${GITEA_USER}:${GITEA_TOKEN}")
fi
# ---- "latest" IMMER über die Gitea-API auflösen, nie als Literal in die
# Download-URL einsetzen ----
# Live reproduziert: /releases/download/latest/tesm-latest.tar.gz liefert
# HTTP 200 -- aber keinen echten Release-Asset, sondern (weil kein Asset
# exakt "tesm-latest.tar.gz" heißt) Giteas automatisch generiertes
# Quellcode-Archiv des AKTUELLEN Default-Branch-Stands, kommentarlos und
# ohne Fehlermeldung. Ohne diesen Auflösungsschritt würde ein einfaches
# "sudo ./update.sh" (ohne explizites TESM_RELEASE_TAG) also lautlos einen
# unversionierten, potenziell halbfertigen Zwischenstand installieren
# statt des tatsächlich neuesten Releases.
if [ "$RELEASE_TAG" == "latest" ]; then
echo -e "${RED}${NC} Ermittle aktuellsten Release-Tag von ${GITEA_BASE}/${REPO_OWNER}/${REPO_NAME}..."
RESOLVED_TAG="$(curl -fsSL "${CURL_AUTH[@]}" "${GITEA_BASE}/api/v1/repos/${REPO_OWNER}/${REPO_NAME}/releases/latest" \
| python3 -c "import json,sys; print(json.load(sys.stdin).get('tag_name',''))" 2>/dev/null)"
if [ -z "$RESOLVED_TAG" ]; then
echo "✖ Konnte den aktuellsten Release-Tag nicht über die Gitea-API ermitteln -- Abbruch." >&2
echo " (Alternative: TESM_RELEASE_TAG=vX.Y.Z explizit setzen.)" >&2
exit 1
fi
RELEASE_TAG="$RESOLVED_TAG"
echo -e "${GREEN}${NC} Aktuellster Release ist ${RELEASE_TAG}."
fi
PACKAGE_NAME="tesm-${RELEASE_TAG}.tar.gz"
DOWNLOAD_URL="${GITEA_BASE}/${REPO_OWNER}/${REPO_NAME}/releases/download/${RELEASE_TAG}/${PACKAGE_NAME}"
PACKAGE_FILE="$WORK_DIR/${PACKAGE_NAME}"
echo -e "${YELLOW}============================================================================${NC}"
echo -e "${YELLOW} TESM Update/Reinstall — Release \"${RELEASE_TAG}\"${NC}"
echo -e "${YELLOW}============================================================================${NC}"
@@ -68,16 +97,22 @@ fi
echo -e "${RED}${NC} Lade Paket von ${DOWNLOAD_URL}..."
mkdir -p "$WORK_DIR"
CURL_AUTH=()
if [ -n "$GITEA_USER" ] && [ -n "$GITEA_TOKEN" ]; then
CURL_AUTH=(-u "${GITEA_USER}:${GITEA_TOKEN}")
fi
curl -fsSL "${CURL_AUTH[@]}" -o "$PACKAGE_FILE" "$DOWNLOAD_URL"
echo -e "${GREEN}${NC} Paket heruntergeladen ($(du -h "$PACKAGE_FILE" | cut -f1))."
echo -e "${RED}${NC} Entpacke Paket..."
mkdir -p "$WORK_DIR/pkg"
tar xzf "$PACKAGE_FILE" -C "$WORK_DIR/pkg" --strip-components=1
# Absicherung gegen genau das oben beschriebene Fallback-Verhalten: statt
# eines späten, kryptischen "No such file or directory" beim Aufruf von
# install.sh hier klar benennen, WENN das heruntergeladene Paket nicht das
# erwartete ist (z.B. weil sich Giteas Verhalten wieder ändert).
if [ ! -f "$WORK_DIR/pkg/install.sh" ]; then
echo "✖ Entpacktes Paket enthält kein install.sh -- vermutlich wurde nicht das" >&2
echo " erwartete Release-Asset heruntergeladen (siehe ${DOWNLOAD_URL})." >&2
exit 1
fi
echo -e "${GREEN}${NC} Paket entpackt."
echo -e "${RED}${NC} Starte install.sh aus dem Paket..."
+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