diff --git a/srv/tesm/VERSION b/srv/tesm/VERSION index 9ee1f78..26aaba0 100644 --- a/srv/tesm/VERSION +++ b/srv/tesm/VERSION @@ -1 +1 @@ -1.1.11 +1.2.0 diff --git a/srv/tesm/app.py b/srv/tesm/app.py index 1094bd9..0974d42 100644 --- a/srv/tesm/app.py +++ b/srv/tesm/app.py @@ -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/", 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/", 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/", 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/", 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/", 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/", 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/", 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/", 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/", 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/", 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() diff --git a/srv/tesm/static/css/style.css b/srv/tesm/static/css/style.css index 9cb8f76..2284d91 100644 --- a/srv/tesm/static/css/style.css +++ b/srv/tesm/static/css/style.css @@ -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; diff --git a/srv/tesm/templates/base.html b/srv/tesm/templates/base.html index 338e529..49a6937 100644 --- a/srv/tesm/templates/base.html +++ b/srv/tesm/templates/base.html @@ -129,6 +129,12 @@ {% endif %} + {% if license_topbar %} + + {{ license_topbar.text }} + + {% endif %} {% if dhcp_topbar_active is not none %} DHCP diff --git a/srv/tesm/templates/papierkorb.html b/srv/tesm/templates/papierkorb.html index c96fb15..9dd13e1 100644 --- a/srv/tesm/templates/papierkorb.html +++ b/srv/tesm/templates/papierkorb.html @@ -17,6 +17,13 @@ +{% if not license_active() %} +
+ Wiederherstellen und endgültiges Löschen sind nur mit gültiger Lizenz verfügbar — siehe + Lizenz. Ansehen ist weiterhin uneingeschränkt möglich. +
+{% endif %} +
@@ -44,7 +51,7 @@ {{ d['ip'] }} {{ d['deleted_at'] }} - {% if current_user.has_permission('papierkorb.edit') %} + {% if current_user.has_permission('papierkorb.edit') and license_active() %}
@@ -92,7 +99,7 @@ {{ s['ip'] }} {{ s['deleted_at'] }} - {% if current_user.has_permission('papierkorb.edit') %} + {% if current_user.has_permission('papierkorb.edit') and license_active() %}
@@ -140,7 +147,7 @@ {{ c['username'] }} {{ c['deleted_at'] }} - {% if current_user.has_permission('papierkorb.edit') %} + {% if current_user.has_permission('papierkorb.edit') and license_active() %}
@@ -189,7 +196,7 @@ {{ full_name or '—' }} {{ u['deleted_at'] }} - {% if current_user.has_permission('papierkorb.edit') %} + {% if current_user.has_permission('papierkorb.edit') and license_active() %}
@@ -235,7 +242,7 @@ {{ g['name'] }} {{ g['deleted_at'] }} - {% if current_user.has_permission('papierkorb.edit') %} + {% if current_user.has_permission('papierkorb.edit') and license_active() %}
diff --git a/srv/tesm/templates/settings.html b/srv/tesm/templates/settings.html index 310009e..ca582ac 100644 --- a/srv/tesm/templates/settings.html +++ b/srv/tesm/templates/settings.html @@ -14,7 +14,8 @@
Name und Zeitzone dieses Hosts.
- {% if current_user.has_permission('settings_system.edit') %} + {% set can_edit_host = current_user.has_permission('settings_system.edit') %} + {% if can_edit_host %}
@@ -40,7 +41,18 @@ Zeitzone setzen + {% else %} +
+ + +
+
+ + +
+ {% endif %}
+ {% if can_edit_host and license_active() %}
@@ -52,18 +64,16 @@ {% else %} -
- - -
-
- - -
-
Nur Lesezugriff — für Änderungen fehlt das Recht „Systemeinstellungen ändern“.
+
+ {% if not can_edit_host %} + Nur Lesezugriff — für Änderungen fehlt das Recht „Systemeinstellungen ändern“. + {% else %} + Nur mit gültiger Lizenz änderbar — siehe Lizenz. + {% endif %} +
{% endif %}
diff --git a/srv/tesm/templates/settings_import_export.html b/srv/tesm/templates/settings_import_export.html index 6685821..1a33a28 100644 --- a/srv/tesm/templates/settings_import_export.html +++ b/srv/tesm/templates/settings_import_export.html @@ -71,7 +71,7 @@
Ausgewählte Kategorien verschlüsselt sichern.
- {% if current_user.has_permission('settings_importexport.export') %} + {% if current_user.has_permission('settings_importexport.export') and license_export_allowed() %}
@@ -96,6 +96,8 @@ Export herunterladen + {% elif current_user.has_permission('settings_importexport.export') %} +

Für den Export ist keine gültige Lizenz vorhanden — siehe Lizenz.

{% else %}

Für den Export fehlt das Recht „Daten exportieren“.

{% endif %} diff --git a/srv/tesm/templates/settings_ldap.html b/srv/tesm/templates/settings_ldap.html index 4f33ab7..0e86a1d 100644 --- a/srv/tesm/templates/settings_ldap.html +++ b/srv/tesm/templates/settings_ldap.html @@ -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 %}
Anmeldung mit dem Domänen-Passwort, zusätzlich zu lokalen Konten
{% endblock %} @@ -96,7 +96,13 @@ {% endif %} {% else %} -

Für Änderungen fehlt das Recht „LDAP/AD-Konfiguration speichern“.

+

+ {% if current_user.has_permission('settings_ldap.edit') %} + Nur mit Lizenz verfügbar — siehe Lizenz. + {% else %} + Für Änderungen fehlt das Recht „LDAP/AD-Konfiguration speichern“. + {% endif %} +

{% endif %}
diff --git a/srv/tesm/templates/settings_license.html b/srv/tesm/templates/settings_license.html new file mode 100644 index 0000000..edd7f6d --- /dev/null +++ b/srv/tesm/templates/settings_license.html @@ -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 %}
Lizenzstatus, Module und Aktivierung dieses Systems
{% endblock %} + +{% block content %} +{% import "_hint_icon.html" as hi %} +
+ +
+
+
+

Status

+
Aktuell installierte Lizenz und ihr Zustand.
+
+
+ + {% if license_error %} +
{{ license_error }}
+ {% endif %} + + {% if not license_file %} +

Keine Lizenzdatei vorhanden — bitte unten eine Lizenzdatei hochladen.

+ {% else %} +
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+ {% if license_last_error %} +
+ + +
+ {% endif %} +
+ + +
+ {% endif %} +
+ + {% if license_file and license_valid %} +
+
+
+

Aktivierung

+
+ Ein Protokoll, zwei Wege: automatisch online, oder — falls kein Netzwerkzugriff auf den + Lizenzserver besteht — per Code manuell mit dem Lizenzserver-Administrator ausgetauscht. +
+
+
+ + {% if not can_edit %} +

Für Aktivierung/Deaktivierung fehlt das Recht „Systemeinstellungen ändern“.

+ + {% elif pending_action %} +

+ Offene {{ 'Aktivierungs' if pending_action.action == 'activate' else 'Deaktivierungs' }}-Anfrage — folgenden Code + beim Lizenzserver-Administrator eingeben: +

+
+ +
+
+
+ + +
+ +
+
+ +
+ + {% elif license_is_activated %} +

+ 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. +

+
+ +
+ + {% else %} +
+ +
+ {% endif %} +
+ {% endif %} + +
+
+
+

Lizenzdatei hochladen

+
Vom Anbieter erhaltene Lizenzdatei einspielen — muss danach noch aktiviert werden.
+
+
+ {% if can_edit %} +
+
+ + +
+ +
+ {% else %} +

Für den Upload fehlt das Recht „Systemeinstellungen ändern“.

+ {% endif %} +
+ + {% if license_file and license_file.vendor %} +
+
+
+

Anbieter

+
Kontakt für Rückfragen zu dieser Lizenz.
+
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ {% endif %} + +
+{% endblock %} diff --git a/srv/tesm/templates/settings_nginx.html b/srv/tesm/templates/settings_nginx.html index 8b3ad48..f833ae4 100644 --- a/srv/tesm/templates/settings_nginx.html +++ b/srv/tesm/templates/settings_nginx.html @@ -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 %}
Reverse-Proxy: Domain, Ports, SSL/HSTS und Zertifikat
{% endblock %} @@ -73,7 +73,13 @@ {% else %} -

Für Änderungen fehlt das Recht „NGINX ändern“.

+

+ {% if current_user.has_permission('settings_nginx.edit') %} + Nur mit Lizenz verfügbar — siehe Lizenz. + {% else %} + Für Änderungen fehlt das Recht „NGINX ändern“. + {% endif %} +

{% endif %}
@@ -177,7 +183,13 @@

SSL muss zuerst deaktiviert werden, bevor das Zertifikat entfernt werden kann.

{% endif %} {% else %} -

Für Anfordern/Upload/Entfernen fehlt das Recht „NGINX ändern“.

+

+ {% if current_user.has_permission('settings_nginx.edit') %} + Nur mit Lizenz verfügbar — siehe Lizenz. + {% else %} + Für Anfordern/Upload/Entfernen fehlt das Recht „NGINX ändern“. + {% endif %} +

{% endif %}
diff --git a/usr/local/bin/custom/poe.sh b/usr/local/bin/custom/poe.sh index 945e103..2cfd9c0 100644 --- a/usr/local/bin/custom/poe.sh +++ b/usr/local/bin/custom/poe.sh @@ -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