Compare commits
12
Commits
d90c27b8f8
...
v1.2.6
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b69d0745dd | ||
|
|
7ab6358b9f | ||
|
|
f02db4c6b5 | ||
|
|
c7ad0d3d49 | ||
|
|
f09aaae8a9 | ||
|
|
36816c57ef | ||
|
|
4a77a3ba32 | ||
|
|
7a1f2b164e | ||
|
|
682e9b4420 | ||
|
|
90e07aa310 | ||
|
|
c9ac0bf69b | ||
|
|
b70ebefb17 |
+1
-1
@@ -134,7 +134,7 @@ sudo chmod 755 /var/log/tesm
|
||||
step "Deploying application to /srv/tesm"
|
||||
sudo mkdir -p /srv/tesm
|
||||
sudo rsync -a --delete --exclude 'venv' --exclude 'sqlite.db' --exclude 'fernet.key' --exclude 'secret.key' \
|
||||
--exclude 'known_hosts' \
|
||||
--exclude 'known_hosts' --exclude 'license.json' \
|
||||
"$REPO_DIR/srv/tesm/" /srv/tesm/ >>/var/log/tesm-install.log 2>&1
|
||||
print_status "Application files copied"
|
||||
|
||||
|
||||
+1
-1
@@ -1 +1 @@
|
||||
1.1.11
|
||||
1.2.6
|
||||
|
||||
+542
-16
@@ -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")
|
||||
@@ -322,6 +326,10 @@ PERMISSIONS = {
|
||||
"label": "Systemeinstellungen",
|
||||
"rows": {"view": "settings_system.view", "edit": "settings_system.edit"},
|
||||
},
|
||||
"settings_license": {
|
||||
"label": "Lizenz",
|
||||
"rows": {"view": "settings_license.view", "edit": "settings_license.edit"},
|
||||
},
|
||||
"settings_importexport": {
|
||||
"label": "Im-/Export",
|
||||
"rows": {
|
||||
@@ -388,6 +396,8 @@ PERMISSION_LABELS = {
|
||||
"groups.view": "Gruppen lesen", "groups.create": "Gruppen anlegen",
|
||||
"groups.edit": "Gruppen ändern (inkl. Löschen)",
|
||||
"settings_system.view": "Systemeinstellungen lesen", "settings_system.edit": "Systemeinstellungen ändern",
|
||||
"settings_license.view": "Lizenzstatus ansehen",
|
||||
"settings_license.edit": "Lizenzdatei hochladen sowie Lizenz aktivieren/deaktivieren (online oder per Code)",
|
||||
"settings_importexport.view": "Im-/Export-Seite ansehen",
|
||||
"settings_importexport.edit": "Daten importieren",
|
||||
"settings_importexport.export": "Daten exportieren (Export-Datei enthält Passwörter im Klartext)",
|
||||
@@ -438,6 +448,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 +476,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 +484,23 @@ 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":
|
||||
# Eigenes Recht (settings_license.view), unabhängig von
|
||||
# settings_system -- muss auch OHNE eine aktive Lizenz sichtbar
|
||||
# bleiben (das ist eine andere Prüfung, siehe license_active()),
|
||||
# sonst könnte niemand jemals eine erste Lizenz hochladen/aktivieren.
|
||||
return user.can_view_settings_license
|
||||
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 +512,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
|
||||
|
||||
|
||||
@@ -712,6 +733,10 @@ class User(UserMixin):
|
||||
def can_view_settings_system(self):
|
||||
return self.has_permission("settings_system.view")
|
||||
|
||||
@property
|
||||
def can_view_settings_license(self):
|
||||
return self.has_permission("settings_license.view")
|
||||
|
||||
@property
|
||||
def can_view_settings_importexport(self):
|
||||
return self.has_any_permission([
|
||||
@@ -1132,6 +1157,32 @@ def _ensure_schema():
|
||||
"INSERT OR IGNORE INTO settings (key, value) VALUES (?, '1')", (_migration_key_v2,)
|
||||
)
|
||||
|
||||
_migration_key_license_perm = "_migrated_settings_license_permission_v1"
|
||||
if not conn.execute("SELECT 1 FROM settings WHERE key=?", (_migration_key_license_perm,)).fetchone():
|
||||
# "Lizenz" (settings_license) hatte bislang KEIN eigenes Recht --
|
||||
# die Seite lief komplett über settings_system.view/.edit mit.
|
||||
# Backfill: jede Gruppe, die settings_system.view/.edit bereits
|
||||
# hatte, bekommt automatisch das neue, gespiegelte
|
||||
# settings_license.view/.edit dazu -- ohne diesen Schritt würden
|
||||
# bestehende Gruppen beim Update stillschweigend den Zugriff auf
|
||||
# die Lizenzseite verlieren, den sie vorher implizit hatten.
|
||||
_system_to_license = {
|
||||
"settings_system.view": "settings_license.view",
|
||||
"settings_system.edit": "settings_license.edit",
|
||||
}
|
||||
for system_perm, license_perm in _system_to_license.items():
|
||||
rows = conn.execute(
|
||||
"SELECT DISTINCT group_id FROM group_permissions WHERE permission=?", (system_perm,)
|
||||
).fetchall()
|
||||
for row in rows:
|
||||
conn.execute(
|
||||
"INSERT OR IGNORE INTO group_permissions (group_id, permission) VALUES (?, ?)",
|
||||
(row["group_id"], license_perm),
|
||||
)
|
||||
conn.execute(
|
||||
"INSERT OR IGNORE INTO settings (key, value) VALUES (?, '1')", (_migration_key_license_perm,)
|
||||
)
|
||||
|
||||
_migration_key_old_logs = "_cleaned_up_legacy_rpi_log_files_v1"
|
||||
if not conn.execute("SELECT 1 FROM settings WHERE key=?", (_migration_key_old_logs,)).fetchone():
|
||||
for stale_log in glob.glob("/var/log/rpi-*.log"):
|
||||
@@ -2100,6 +2151,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 +2631,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 +3345,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 +3359,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 +4343,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 +4503,214 @@ def settings():
|
||||
)
|
||||
|
||||
|
||||
@app.route("/settings/license")
|
||||
@login_required
|
||||
def settings_license():
|
||||
if not current_user.can_view_settings_license:
|
||||
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_license.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_license.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", "")
|
||||
# Sofort einen ersten Heartbeat auslösen, statt bis zu 6h auf den
|
||||
# nächsten planmäßigen Durchlauf zu warten (siehe
|
||||
# _license_heartbeat_loop) -- ohne das würde "Letzter Heartbeat"
|
||||
# direkt nach der Aktivierung fälschlich wie ein Verbindungsproblem
|
||||
# aussehen ("noch nie"), obwohl schlicht noch keine Zeit vergangen
|
||||
# ist. Ein Fehlschlag hier (z.B. Kunde offline) wird wie jeder
|
||||
# andere Heartbeat-Fehlschlag behandelt (nur vermerkt, nichts wird
|
||||
# deswegen abgeschaltet).
|
||||
try:
|
||||
_send_heartbeat()
|
||||
except Exception:
|
||||
app.logger.error("Erster Heartbeat nach Aktivierung fehlgeschlagen:\n%s", traceback.format_exc())
|
||||
|
||||
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_license.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", "")
|
||||
# Sofort einen ersten Heartbeat auslösen, statt bis zu 6h auf den
|
||||
# nächsten planmäßigen Durchlauf zu warten (siehe
|
||||
# _license_heartbeat_loop) -- ohne das würde "Letzter Heartbeat"
|
||||
# direkt nach der Aktivierung fälschlich wie ein Verbindungsproblem
|
||||
# aussehen ("noch nie"), obwohl schlicht noch keine Zeit vergangen
|
||||
# ist. Ein Fehlschlag hier (z.B. Kunde offline) wird wie jeder
|
||||
# andere Heartbeat-Fehlschlag behandelt (nur vermerkt, nichts wird
|
||||
# deswegen abgeschaltet).
|
||||
try:
|
||||
_send_heartbeat()
|
||||
except Exception:
|
||||
app.logger.error("Erster Heartbeat nach Aktivierung fehlgeschlagen:\n%s", traceback.format_exc())
|
||||
|
||||
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_license.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_license.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 +5299,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 +6493,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 +7256,7 @@ def _purge_expired_trash():
|
||||
@app.route("/devices/restore/<mac>", methods=["POST"])
|
||||
@login_required
|
||||
def restore_device(mac):
|
||||
if not current_user.has_permission("papierkorb.edit"):
|
||||
if not current_user.has_permission("papierkorb.edit") or not license_active():
|
||||
flash("Keine Berechtigung, Geräte wiederherzustellen.", "danger")
|
||||
return redirect(url_for("papierkorb"))
|
||||
conn = get_db_connection()
|
||||
@@ -6750,7 +7272,7 @@ def restore_device(mac):
|
||||
@app.route("/devices/purge/<mac>", methods=["POST"])
|
||||
@login_required
|
||||
def purge_device(mac):
|
||||
if not current_user.has_permission("papierkorb.edit"):
|
||||
if not current_user.has_permission("papierkorb.edit") or not license_active():
|
||||
flash("Keine Berechtigung, Geräte endgültig zu löschen.", "danger")
|
||||
return redirect(url_for("papierkorb"))
|
||||
conn = get_db_connection()
|
||||
@@ -6766,7 +7288,7 @@ def purge_device(mac):
|
||||
@app.route("/switches/restore/<hostname>", methods=["POST"])
|
||||
@login_required
|
||||
def restore_switch(hostname):
|
||||
if not current_user.has_permission("papierkorb.edit"):
|
||||
if not current_user.has_permission("papierkorb.edit") or not license_active():
|
||||
flash("Keine Berechtigung, Switche wiederherzustellen.", "danger")
|
||||
return redirect(url_for("papierkorb"))
|
||||
conn = get_db_connection()
|
||||
@@ -6781,7 +7303,7 @@ def restore_switch(hostname):
|
||||
@app.route("/switches/purge/<hostname>", methods=["POST"])
|
||||
@login_required
|
||||
def purge_switch(hostname):
|
||||
if not current_user.has_permission("papierkorb.edit"):
|
||||
if not current_user.has_permission("papierkorb.edit") or not license_active():
|
||||
flash("Keine Berechtigung, Switche endgültig zu löschen.", "danger")
|
||||
return redirect(url_for("papierkorb"))
|
||||
conn = get_db_connection()
|
||||
@@ -6796,7 +7318,7 @@ def purge_switch(hostname):
|
||||
@app.route("/credentials/restore/<int:cred_id>", methods=["POST"])
|
||||
@login_required
|
||||
def restore_credential(cred_id):
|
||||
if not current_user.has_permission("papierkorb.edit"):
|
||||
if not current_user.has_permission("papierkorb.edit") or not license_active():
|
||||
flash("Keine Berechtigung, Zugangsdaten wiederherzustellen.", "danger")
|
||||
return redirect(url_for("papierkorb"))
|
||||
conn = get_db_connection()
|
||||
@@ -6812,7 +7334,7 @@ def restore_credential(cred_id):
|
||||
@app.route("/credentials/purge/<int:cred_id>", methods=["POST"])
|
||||
@login_required
|
||||
def purge_credential(cred_id):
|
||||
if not current_user.has_permission("papierkorb.edit"):
|
||||
if not current_user.has_permission("papierkorb.edit") or not license_active():
|
||||
flash("Keine Berechtigung, Zugangsdaten endgültig zu löschen.", "danger")
|
||||
return redirect(url_for("papierkorb"))
|
||||
conn = get_db_connection()
|
||||
@@ -6828,7 +7350,7 @@ def purge_credential(cred_id):
|
||||
@app.route("/users/restore/<int:user_id>", methods=["POST"])
|
||||
@login_required
|
||||
def restore_user(user_id):
|
||||
if not current_user.has_permission("papierkorb.edit"):
|
||||
if not current_user.has_permission("papierkorb.edit") or not license_active():
|
||||
flash("Keine Berechtigung, Benutzer wiederherzustellen.", "danger")
|
||||
return redirect(url_for("papierkorb"))
|
||||
conn = get_db_connection()
|
||||
@@ -6850,7 +7372,7 @@ def restore_user(user_id):
|
||||
@app.route("/users/purge/<int:user_id>", methods=["POST"])
|
||||
@login_required
|
||||
def purge_user(user_id):
|
||||
if not current_user.has_permission("papierkorb.edit"):
|
||||
if not current_user.has_permission("papierkorb.edit") or not license_active():
|
||||
flash("Keine Berechtigung, Benutzer endgültig zu löschen.", "danger")
|
||||
return redirect(url_for("papierkorb"))
|
||||
conn = get_db_connection()
|
||||
@@ -6872,7 +7394,7 @@ def purge_user(user_id):
|
||||
@app.route("/groups/restore/<int:group_id>", methods=["POST"])
|
||||
@login_required
|
||||
def restore_group(group_id):
|
||||
if not current_user.has_permission("papierkorb.edit"):
|
||||
if not current_user.has_permission("papierkorb.edit") or not license_active():
|
||||
flash("Keine Berechtigung, Gruppen wiederherzustellen.", "danger")
|
||||
return redirect(url_for("papierkorb"))
|
||||
conn = get_db_connection()
|
||||
@@ -6888,7 +7410,7 @@ def restore_group(group_id):
|
||||
@app.route("/groups/purge/<int:group_id>", methods=["POST"])
|
||||
@login_required
|
||||
def purge_group(group_id):
|
||||
if not current_user.has_permission("papierkorb.edit"):
|
||||
if not current_user.has_permission("papierkorb.edit") or not license_active():
|
||||
flash("Keine Berechtigung, Gruppen endgültig zu löschen.", "danger")
|
||||
return redirect(url_for("papierkorb"))
|
||||
conn = get_db_connection()
|
||||
@@ -6907,6 +7429,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 +8464,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()
|
||||
|
||||
@@ -0,0 +1,304 @@
|
||||
"""
|
||||
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()
|
||||
|
||||
|
||||
# Sentinel-Ablaufdatum fuer "Lifetime"-Lizenzen. Bewusst ein fixes, weit in
|
||||
# der Zukunft liegendes Datum statt eines Sonderwerts wie None/0 fuer
|
||||
# expires_at -- dadurch bleibt JEDE bestehende Datumsvergleichs-/
|
||||
# Differenzrechnung (hier, im Master-Dashboard, UND im TESM-Client) ohne
|
||||
# jede Sonderbehandlung korrekt: "in > 90 Tagen faellig" o.ae. ist fuer ein
|
||||
# Datum im Jahr 2099 schlicht immer wahr. Ob eine Lizenz als "Lifetime"
|
||||
# geflaggt ist, wird zusaetzlich explizit in der Master-DB gespeichert
|
||||
# (licenses.lifetime) -- dieses Sentinel-Datum ist nur die tatsaechlich in
|
||||
# die signierte Lizenzdatei eingebettete, fuer den Client sichtbare Reprae-
|
||||
# sentation davon.
|
||||
LIFETIME_EXPIRES_AT = "2099-12-31T00:00:00Z"
|
||||
|
||||
|
||||
# ================================================================ 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, lifetime=False):
|
||||
"""Vom MASTER aufgerufen: erzeugt eine neue, signierte Lizenz samt
|
||||
frischem Pro-Lizenz-Schlüsselpaar.
|
||||
|
||||
lifetime=True ignoriert valid_days komplett und setzt stattdessen das
|
||||
feste Sentinel-Datum LIFETIME_EXPIRES_AT -- siehe dortigen Kommentar.
|
||||
|
||||
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": LIFETIME_EXPIRES_AT if lifetime else _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(),
|
||||
# Rein informativ fuer die Dashboard-Kacheln des Masters (siehe
|
||||
# dortiges index()) -- fliesst NICHT in irgendeine Sicherheits-
|
||||
# entscheidung ein (die bleibt allein Sache des Fingerprints), wird
|
||||
# aber wie alle anderen Felder mitsigniert, damit ein Angreifer
|
||||
# ohne den privaten Lizenzschluessel keinen falschen Hostnamen
|
||||
# unterschieben kann.
|
||||
"hostname": socket.gethostname(),
|
||||
"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"))
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -14,7 +14,8 @@
|
||||
<div class="hint">Name und Zeitzone dieses Hosts.</div>
|
||||
</div>
|
||||
</div>
|
||||
{% if current_user.has_permission('settings_system.edit') %}
|
||||
{% set can_edit_host = current_user.has_permission('settings_system.edit') %}
|
||||
{% if can_edit_host %}
|
||||
<form method="post">
|
||||
<div class="field">
|
||||
<label for="hostname">Hostname</label>
|
||||
@@ -40,7 +41,18 @@
|
||||
Zeitzone setzen
|
||||
</button>
|
||||
</form>
|
||||
{% else %}
|
||||
<div class="field">
|
||||
<label>Hostname</label>
|
||||
<input type="text" value="{{ current_hostname or '' }}" disabled>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>Zeitzone</label>
|
||||
<input type="text" value="{{ current_timezone or '' }}" disabled>
|
||||
</div>
|
||||
{% endif %}
|
||||
<hr style="border:none; border-top:1px solid var(--border-soft); margin:18px 0;">
|
||||
{% if can_edit_host and license_active() %}
|
||||
<form method="post">
|
||||
<div class="field">
|
||||
<label for="interval">Prüfintervall (Minuten) {{ hi.hint_icon("Wie oft Geräte auf Erreichbarkeit geprüft werden. Der Hintergrund-Dienst (tesm-check.service) wird nach dem Speichern automatisch neu gestartet.", "Prüfintervall (Minuten)") }}</label>
|
||||
@@ -52,18 +64,16 @@
|
||||
</button>
|
||||
</form>
|
||||
{% else %}
|
||||
<div class="field">
|
||||
<label>Hostname</label>
|
||||
<input type="text" value="{{ current_hostname or '' }}" disabled>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>Zeitzone</label>
|
||||
<input type="text" value="{{ current_timezone or '' }}" disabled>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>Prüfintervall (Minuten)</label>
|
||||
<input type="number" value="{{ interval }}" disabled>
|
||||
<div class="field-hint">Nur Lesezugriff — für Änderungen fehlt das Recht „Systemeinstellungen ändern“.</div>
|
||||
<div class="field-hint">
|
||||
{% if not can_edit_host %}
|
||||
Nur Lesezugriff — für Änderungen fehlt das Recht „Systemeinstellungen ändern“.
|
||||
{% else %}
|
||||
Nur mit gültiger Lizenz änderbar — siehe <a href="{{ url_for('settings_license') }}">Lizenz</a>.
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
@@ -71,7 +71,7 @@
|
||||
<div class="hint">Ausgewählte Kategorien verschlüsselt sichern.</div>
|
||||
</div>
|
||||
</div>
|
||||
{% if current_user.has_permission('settings_importexport.export') %}
|
||||
{% if current_user.has_permission('settings_importexport.export') and license_export_allowed() %}
|
||||
<form method="post" action="{{ url_for('export_data') }}">
|
||||
<div class="field">
|
||||
<label>Was exportieren?</label>
|
||||
@@ -96,6 +96,8 @@
|
||||
Export herunterladen
|
||||
</button>
|
||||
</form>
|
||||
{% elif current_user.has_permission('settings_importexport.export') %}
|
||||
<p class="text-faint" style="font-size:12.5px;">Für den Export ist keine gültige Lizenz vorhanden — siehe <a href="{{ url_for('settings_license') }}">Lizenz</a>.</p>
|
||||
{% else %}
|
||||
<p class="text-faint" style="font-size:12.5px;">Für den Export fehlt das Recht „Daten exportieren“.</p>
|
||||
{% endif %}
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -0,0 +1,184 @@
|
||||
{% extends "base.html" %}
|
||||
{% set active_page = "settings_license" %}
|
||||
{% set can_edit = current_user.has_permission('settings_license.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>
|
||||
{% if license_file.vendor.logo_base64 %}
|
||||
<div class="field" style="text-align:center;">
|
||||
<img src="{{ license_file.vendor.logo_base64 }}" alt="{{ license_file.vendor.name or 'Anbieter' }}" style="max-width:220px; max-height:90px; margin:0 auto 8px;">
|
||||
</div>
|
||||
{% endif %}
|
||||
<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 %}
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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..."
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user