Compare commits

...
19 Commits
Author SHA1 Message Date
alientimandClaude Sonnet 5 b69d0745dd Eigenes Recht fuer die Lizenzseite (settings_license.view/.edit)
Die Lizenzseite (Einstellungen -> Lizenz) hatte im Rechtebaum keinen
eigenen Eintrag -- sie lief komplett ueber settings_system.view/.edit
mit. Damit konnte 'Lizenz verwalten' nicht unabhaengig von 'Systemein-
stellungen verwalten' vergeben/eingeschraenkt werden (Hostname/Zeitzone/
Pruefintervall/Log-Rotation/Papierkorb-Frist/Nav-Reihenfolge).

- Neues Rechtepaar settings_license.view/settings_license.edit
  (PERMISSIONS/PERMISSION_LABELS), Property can_view_settings_license.
- Nav-Sichtbarkeit und alle /settings/license*-Routen auf das neue
  Recht umgestellt (view-Route: can_view_settings_license; alle
  mutierenden Routen -upload/-activate(/confirm)/-deactivate(/confirm):
  settings_license.edit). Das "auch ohne aktive Lizenz sichtbar
  bleiben"-Verhalten bleibt unveraendert (andere Pruefung, license_active()).
- settings_license.html: can_edit auf settings_license.edit umgestellt.
- Migration (_migrated_settings_license_permission_v1, einmalig
  geguarded): jede Gruppe mit settings_system.view/.edit bekommt
  automatisch settings_license.view/.edit dazu -- niemand verliert beim
  Update stillschweigend Zugriff, den er vorher implizit hatte.
- Getestet gegen isolierte Kopie der Dev-DB: Backfill korrekt (inkl.
  Gruppe mit nur .view, die korrekt KEIN .edit bekommt), idempotent bei
  zweitem Lauf, has_permission()/_nav_key_visible() liefern die
  erwarteten Werte je Testgruppe.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-23 20:48:27 +02:00
alientimandClaude Sonnet 5 7ab6358b9f Anbieter-Logo auf der Lizenz-Seite anzeigen (v1.2.5)
Der Lizenzserver erlaubt bereits laenger das Hochladen eines Anbieter-
Logos (Einstellungen -> Lizenz -> Anbieter & Lizenzserver) und bettet es
seit jeher als vendor.logo_base64 in jede ausgestellte Lizenzdatei ein
(siehe _get_vendor_info() im Lizenzserver) -- die Kundeninstanz zeigte
auf ihrer eigenen Lizenz-Seite zwar bereits Firma/Telefon/E-Mail/
Anschrift des Anbieters an (dieselbe eingebettete vendor-Struktur), aber
nie das Logo selbst. Reine Anzeige-Ergaenzung, keine Aenderung am
Lizenzformat oder an der Uebertragung noetig -- das Logo steckt in jeder
schon ausgestellten Lizenz bereits drin, muss also fuer bestehende
Kunden nicht neu ausgestellt werden.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-23 17:14:47 +02:00
alientimandClaude Sonnet 5 f02db4c6b5 Meldet Hostname bei Aktivierung/Heartbeat an den Lizenzserver (v1.2.4)
Der neue Master-Lizenzserver (tesm-license v1.2.0) zeigt auf seinem
Dashboard jetzt eine Kachel-Uebersicht der aktiven Kundeninstanzen inkl.
Hostname -- dafuer braucht er den Hostnamen jeder Instanz, den der Master
bisher gar nicht kannte (nur den gehashten Fingerprint, siehe
licensing.system_fingerprint()).

build_client_request() (licensing.py, identisch auf Client UND Master
gepflegt) sendet jetzt zusaetzlich socket.gethostname() mit -- rein
informativ, fliesst in keine Sicherheitsentscheidung ein (das bleibt
Sache des Fingerprints), wird aber wie jedes andere Feld mitsigniert.
Abwaerts-kompatibel in beide Richtungen: ein alter Client ohne dieses
Feld liefert dem (bereits aktualisierten) Master einfach '', der das
dann per COALESCE(NULLIF(...,''), hostname) NICHT als Ueberschreiben
eines schon bekannten Hostnamens wertet; ein neuer Client gegen einen
noch nicht aktualisierten Master aendert an dessen Verhalten nichts
(zusaetzliches Feld wird dort schlicht ignoriert).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-23 16:00:24 +02:00
alientimandClaude Sonnet 5 c7ad0d3d49 KRITISCH: install.sh loeschte license.json bei jedem Update (v1.2.3)
Live auf POETEST reproduziert: rsync --delete im App-Verzeichnis-Schritt
schliesst venv/sqlite.db/fernet.key/secret.key/known_hosts explizit aus,
vergisst dabei aber license.json (in Phase 1 neu hinzugekommen, als
Ausschluss schlicht uebersehen). Da license.json nur auf dem Zielsystem
existiert (nie im Release-Paket enthalten, korrekt .gitignored), loescht
--delete sie bei JEDEM 'sudo ./update.sh' auf einer bereits aktivierten
Instanz kommentarlos mit -- die Lizenz war danach weg, ohne dass update.sh
oder install.sh irgendeinen Fehler gemeldet haetten.

Der Master-seitige Installer (install.sh im tesm-license-Repo) hatte
diesen Ausschluss von Anfang an korrekt gesetzt -- betraf ausschliesslich
TESM-Kundeninstanzen.

Fix: license.json zur rsync --exclude-Liste hinzugefuegt, analog zu den
anderen laufzeit-generierten Secrets.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-23 11:08:02 +02:00
alientimandClaude Sonnet 5 f09aaae8a9 Lizenz: sofortiger erster Heartbeat direkt nach Aktivierung (v1.2.2)
Root Cause fuer 'Letzter Heartbeat: noch nie' trotz funktionierender
Verbindung (live auf POETEST untersucht und bestaetigt): der Heartbeat-
Hintergrund-Thread feuert einmal SOFORT beim Start von tesm.service und
danach erst wieder alle 6h. Im realistischen Ablauf (Dienst starten ->
danach erst Lizenz hochladen+aktivieren) faellt genau dieser erste,
sofortige Versuch immer ins Leere (noch keine Lizenz vorhanden, daher
stiller Abbruch ohne Fehlermeldung) -- der naechste ECHTE Versuch waere
erst bis zu 6h spaeter gekommen. Manuell nachgestellt: ein direkter Aufruf
von _send_heartbeat() nach erfolgter Aktivierung funktioniert einwandfrei
(Master verzeichnet den Heartbeat sofort) -- das Protokoll selbst war nie
kaputt, nur die Beobachtbarkeit unmittelbar nach der Aktivierung.

Fix: beide Aktivierungs-Erfolgspfade (online + Offline-Code-Bestaetigung)
loesen jetzt selbst sofort einen ersten Heartbeat aus, statt auf den
naechsten planmaessigen 6h-Durchlauf zu warten. Ein Fehlschlag hier (z.B.
Kunde tatsaechlich offline) wird wie jeder andere Heartbeat-Fehlschlag
behandelt -- rein informativ, schaltet nichts ab.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-23 11:00:15 +02:00
alientimandClaude Sonnet 5 36816c57ef Version 1.2.1: update.sh-Fix fuer 'latest'-Aufloesung
Kein App-Code geaendert (app.py unveraendert) -- reiner Versions-/Release-
Bump, damit 'latest' auch als heruntergeladenes Paket das bereits
korrigierte update.sh enthaelt (siehe vorheriger Commit).

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-23 02:07:30 +02:00
alientimandClaude Sonnet 5 c9ac0bf69b Lizenz-/Modulsystem Phase 1: TESM-seitiges Gating, Aktivierung, Topbar (v1.2.0)
Baut auf licensing.py (Phase 0) auf und schaltet TESM erstmals auf ein
lizenziertes Modulmodell um. Standard-Funktionen (Dashboard, Clients,
Switche, Zugangsdaten, Benutzer, Gruppen, Auditlog, Live-Log, Verlauf,
Import) bleiben immer frei nutzbar.

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-22 22:46:10 +02:00
alientimandClaude Sonnet 5 8577cd24cd Export-Kategorien: kuerzere Namen, Auditlog/Benutzer-Hinweise als Icon (weiterhin v1.1.11)
Sichtbare Checkbox-Namen im Export-Formular verkuerzt (nur die Kachel-
Ansicht betroffen, Funktionalitaet/Schluessel unveraendert):
Custom-Gruppen -> Gruppen, LDAP/AD-Einstellungen -> LDAP/AD,
DHCP-Einstellungen -> DHCP, NGINX-Einstellungen -> NGINX,
Benutzer (lokal) -> Benutzer.

Neue Hinweis-Icons (Klammerzusaetze wandern konsistent zu den bereits
bestehenden bei Zugangsdaten/NGINX ins "i"-Icon statt in den Namen):
- Auditlog: Hinweis, dass nur die aktuell in der DB stehenden Eintraege
  exportiert werden (bereits archivierte Tage liegen als eigene Dateien
  vor).
- Benutzer: Hinweis, dass AD/LDAP-Benutzer NICHT gesichert werden (nur
  lokale Konten sind Teil des Exports -- AD-Konten legen sich beim
  naechsten Login ohnehin automatisch neu an).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-22 22:35:49 +02:00
alientimandClaude Sonnet 5 877903bd77 NGINX-Export inkl. Zertifikat/Schluessel, Kategorie-Hinweise als Icon (weiterhin v1.1.11)
- NGINX-Export nimmt jetzt zusaetzlich das aktuell hinterlegte TLS-
  Zertifikat und den privaten Schluessel selbst mit (Base64, falls
  vorhanden) -- der gesamte Export ist ohnehin passphrasenverschluesselt,
  genau wie andere Geheimnisse (Zugangsdaten-Passwoerter, LDAP-Bind-
  Passwort) bereits im Klartext darin landen. Bewusst weiterhin
  AUSGESCHLOSSEN bleibt jeglicher Let's-Encrypt-/certbot-Zustand
  (ssl_source, ACME-Konto, Renewal-Timer) -- an genau diesen Host/diese
  Domain-Registrierung gebunden, kann nicht mitziehen. Ein Import setzt
  ssl_source deshalb immer auf "upload", auch wenn urspruenglich per Let's
  Encrypt ausgestellt.
- Die erklaerenden Klammerzusaetze bei "Zugangsdaten" (know_hosts-Hinweis)
  und "NGINX-Einstellungen" (Zertifikat/Schluessel-Hinweis) sind jetzt ein
  "i"-Hinweis-Icon statt Fliesstext im Label, passend zum bestehenden
  Hover-Tooltip-/Klick-Modal-Muster (siehe v1.1.10).

Verifiziert per isoliertem Test mit einem echten (selbstsignierten)
Test-Zertifikat: Export->Import-Roundtrip liefert bitidentische Cert-/
Key-Dateien mit korrekten Berechtigungen (0600 Schluessel, 0644
Zertifikat) auf einem eigenen Zielpfad, sowie live per Browser gegen
POETEST (Hover zeigt korrekten Tooltip-Text, 2 Icons im Export-Formular).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-22 22:33:31 +02:00
alientimandClaude Sonnet 5 2b05d012ee Im-/Export: fehlende Felder ergaenzt, NGINX neu, known_hosts (v1.1.11)
Vollstaendiger Audit aller Export-/Import-Kategorien gegen das tatsaechliche
DB-Schema (7 parallele Pruef-Agents je Kategorie) ergab mehrere Luecken --
alle behoben:

- Auditlog-Export (v1.1.10-Nacharbeit): kein Deckel mehr bei 500 Zeilen,
  exportiert wird das komplette aktuell in der DB stehende Auditlog
  (bereits archivierte Tage liegen ohnehin als eigene Dateien vor).
- Clients: ssh_port und Zugangsdaten-Zuordnung (als portabler
  credential_name statt roher ID) fehlten komplett im Export.
- Switche: ssh_port fehlte komplett im Export.
- Zugangsdaten: category (z.B. "Linux-Client" fuer Wartung) fehlte.
- Custom-Gruppen: is_default fehlte (war beim Import zusaetzlich hart auf 0
  gesetzt statt aus der Importdatei uebernommen).
- LDAP-Gruppenzuordnungen: app_group_id wurde als ROHE lokale Datenbank-ID
  exportiert -- auf einem frisch installierten Zielsystem (der eigentliche
  Zweck dieser Funktion, siehe Seiten-Untertitel "Umzug auf eine neue
  Umgebung") haben Gruppen dort andere IDs, die Zuordnung waere kaputt oder
  auf die falsche Gruppe gezeigt. Jetzt wird der Gruppenname exportiert
  (bzw. "Admin" als Sonderfall) und beim Import auf dem Zielsystem wieder zur
  dortigen ID aufgeloest -- unbekannte Zielgruppen werden übersprungen statt
  eine kaputte Referenz anzulegen.
- ldap_fileshare_mappings (AD-Gruppe -> Freigabe) fehlte im Export/Import
  komplett -- alle Fileshare-Zuordnungen gingen bei jedem Umzug verloren.
- ldap_required_login_group (Login-Beschraenkung auf eine AD-Gruppe) fehlte
  in LDAP_SETTING_KEYS und wurde nie exportiert.
- NGINX-Einstellungen (Domain, Ports, SSL/HSTS-Schalter) hatten bisher gar
  keine Export-/Import-Kategorie. Bewusst AUSGESCHLOSSEN bleiben ssl_source,
  das TLS-Zertifikat und der private Schluessel sowie jeglicher
  Let's-Encrypt-/certbot-Zustand -- das sind host-gebundene reale Dateien
  bzw. eine ACME-Registrierung fuer genau diesen Host, kein portabler
  Einstellungswert.
- known_hosts: sobald Zugangsdaten exportiert werden, wird zusaetzlich der
  Inhalt von known_hosts mitexportiert (jeder per SSH bereits bestaetigte
  Host-Key gehoert inhaltlich zu den Zugangsdaten) und beim Import ergaenzend
  (nicht ueberschreibend) mit Zeilen-Deduplizierung in die eigene
  known_hosts-Datei eingespielt.

avatar_filename (Benutzer) bleibt bewusst vom Export ausgeschlossen -- reiner
Dateiname ohne die zugehoerige Bilddatei waere auf dem Zielsystem nur ein
kaputter Verweis, analog zur bewussten Nichtaufnahme von TLS-Zertifikat/
Schluessel bei NGINX.

Verifiziert per isoliertem Roundtrip-Test gegen eine Kopie der echten
Datenbank (nie die Produktivdaten selbst): Export -> Loeschen -> Re-Import
fuer jede geaenderte Kategorie plus known_hosts-Merge (inkl.
Deduplizierungs-Probe durch zweifachen Import derselben Datei), sowie ein
echter Browser-Exportlauf gegen POETEST zur Bestaetigung der UI/Label-Texte.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-22 22:27:31 +02:00
alientimandClaude Sonnet 5 0f0fa99e53 Feld-/Funktionserklaerungen als Hover-Tooltip + Klick-Modal (v1.1.10)
Statisch sichtbare Erklaerungstexte (.hint/.field-hint) app-weit auf ein
einheitliches "i"-Icon umgestellt: Hover zeigt den Text neben dem
Mauszeiger, Klick/Tap oeffnet ein wiederverwendetes Modal mit dem vollen
Text. Zentral in app.js (initHintIcons, ensureHintTooltip/-Modal) via
Event-Delegation, kein Aufwand pro Seite. Hover ist bewusst per
matchMedia("(hover: hover) and (pointer: fine)") gated -- auf Touch-
Geraeten faellt automatisch nur Tap-zu-Modal an, da echte
mouseenter/mousemove-Events dort ohnehin nicht zuverlaessig auftreten.

Nicht jede .hint/.field-hint wurde umgestellt -- nach folgenden Kriterien
entschieden:
- Live-Status-Anzeigen (aktuelle Log-Pfade, Netzwerkstatus, Auditlog-
  Schwellenwerte, aktive Reservierungs-/Lease-Zaehler, certbot-Timer-
  Status usw.) bleiben sichtbar, da sie aktuellen Zustand zeigen, keine
  generische Erklaerung.
- Kurze, einzeilige Abschnitts-Untertitel (z.B. "Server, Bind-Konto und
  Suchparameter fuer die Anbindung an AD/LDAP.") bleiben als Orientierung
  direkt unter der Ueberschrift sichtbar.
- Bereits JS-live-aktualisierte Status-Felder (id-tragend, z.B.
  "Fragt live... ab." -> "N Gruppen geladen.") bleiben unangetastet.
- Laengere, mehrsaetzige Abschnitts-Erklaerungen (z.B. AD-Gruppenzu-
  ordnungen, Fileshare-Gruppenzuordnung, Subnetze, DHCP-Options, Wartung)
  sowie so gut wie alle Feld-Erklaerungen wandern ins Icon.

Nebenbei: Karte "Fileshare-Gruppen" in LDAP-Einstellungen zu "Fileshare-
Gruppenzuordnung" umbenannt (passt jetzt zur bereits bestehenden
log_action-Zielbezeichnung in app.py).

Neue gemeinsame Macro-Datei templates/_hint_icon.html, importiert von
15 Templates. Verifiziert: alle 18 betroffenen Seiten laden fehlerfrei
(200, keine JS-Fehler), Icon-Anzahl je Seite geprueft, Hover-Tooltip und
Klick-Modal auf Desktop, Tap-Modal auf simuliertem Mobilgeraet (390x844,
Touch), sowie stichprobenartig bestaetigt dass die bewusst belassenen
Hinweise unveraendert sichtbar bleiben.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-22 22:07:33 +02:00
alientimandClaude Sonnet 5 9b4cf8c2e1 Auditlog: Statistik-Kacheln zeigen geladen/gesamt (weiterhin v1.1.9)
Alle vier Kacheln (Alle, Hinzufuegen, Aenderungen, Loeschungen) zeigen
jetzt "geladen / gesamt" (z.B. "300 / 466"), solange noch nicht alles
geladen ist -- Gesamtzahlen je Kategorie kommen aus einer einzigen
aggregierten SQL-Abfrage (SUM/CASE ueber die bestehenden LIKE-Muster von
category_of), nicht aus einem vollstaendigen Tabellen-Scan. Sobald alles
geladen ist, wird wieder nur die reine Zahl angezeigt (client- wie
serverseitig identische Formatierungslogik).

Dieser Fix wird per Nutzervorgabe unter der GLEICHEN Releasnummer v1.1.9
ausgeliefert (Tag wird bewusst weitergezogen statt neu vergeben, da
v1.1.9 zum Zeitpunkt dieses Commits zwar schon getaggt, aber die
Kachel-Anzeige noch nicht ausgereift war).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-22 21:38:30 +02:00
alientimandClaude Sonnet 5 1c0082d3d4 Auditlog: Nachladen aelterer Eintraege in 300er-Schritten (v1.1.9)
Die Auditlog-Seite zeigte bisher unveraenderlich nur die neuesten 500
Zeilen an -- alles Aeltere war komplett unsichtbar (auch wenn es noch
nicht archiviert war, siehe v1.1.6). Jetzt:

- Seitengroesse von 500 auf 300 reduziert (Erstladung UND jeder Nachlade-
  Schritt).
- Neuer Button "Mehr laden (300)" (orange) laedt per AJAX genau den
  naechsten 300er-Block aelterer Eintraege nach (Cursor-Pagination ueber
  die id-Spalte) und haengt ihn ans bestehende tbody an, ohne die Seite
  neu zu laden.
- Neuer Button "Alle laden" ruft denselben Nachlade-Schritt wiederholt
  auf, bis wirklich alles geladen ist (mit Fortschrittsanzeige im
  Button-Text), statt in einer Anfrage die komplette (potenziell sehr
  grosse) Tabelle auf einmal zu rendern.
- Suche/Sortierung/Kategorie-Filter sowie die vier Statistik-Kacheln
  arbeiten transparent ueber ALLE aktuell geladenen Zeilen (nicht nur die
  urspruenglichen 300) -- Zeilenliste fuer die Filterung wird jetzt bei
  jedem Aufruf frisch aus dem DOM gelesen statt einmalig zwischengespeichert.
- Zeilen-Rendering (Aktions-Label/-Icon/-Farbe) in ein gemeinsames Makro
  (_audit_log_macros.html) ausgelagert, das sowohl die Erstladung als auch
  das AJAX-Fragment (_audit_log_rows.html) nutzen -- verhindert Abweichungen
  zwischen beiden Darstellungen.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-22 21:31:48 +02:00
28 changed files with 1941 additions and 289 deletions
+1 -1
View File
@@ -134,7 +134,7 @@ sudo chmod 755 /var/log/tesm
step "Deploying application to /srv/tesm" step "Deploying application to /srv/tesm"
sudo mkdir -p /srv/tesm sudo mkdir -p /srv/tesm
sudo rsync -a --delete --exclude 'venv' --exclude 'sqlite.db' --exclude 'fernet.key' --exclude 'secret.key' \ 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 "$REPO_DIR/srv/tesm/" /srv/tesm/ >>/var/log/tesm-install.log 2>&1
print_status "Application files copied" print_status "Application files copied"
+1 -1
View File
@@ -1 +1 @@
1.1.8 1.2.6
+888 -54
View File
File diff suppressed because it is too large Load Diff
+304
View File
@@ -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"))
+58
View File
@@ -574,6 +574,22 @@ button { font-family: inherit; }
70% { box-shadow: 0 0 0 6px rgba(47,208,122,0); } 70% { box-shadow: 0 0 0 6px rgba(47,208,122,0); }
100% { box-shadow: 0 0 0 0 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 { .timer-pill-refresh {
display: inline-flex; align-items: center; justify-content: center; display: inline-flex; align-items: center; justify-content: center;
@@ -968,6 +984,48 @@ select {
flex-direction: column; flex-direction: column;
animation: modal-in 0.16s ease; animation: modal-in 0.16s ease;
} }
/* ---- Hinweis-Icons (Hover-Tooltip + Klick-Modal), siehe _hint_icon.html
und initHintIcons() in app.js ---- */
.hint-icon {
display: inline-flex;
align-items: center;
justify-content: center;
width: 15px;
height: 15px;
flex: 0 0 auto;
border-radius: 50%;
border: 1.5px solid var(--text-faint);
background: transparent;
color: var(--text-faint);
font-size: 10px;
font-weight: 700;
font-style: italic;
font-family: Georgia, "Times New Roman", serif;
line-height: 1;
cursor: help;
vertical-align: middle;
margin-left: 6px;
padding: 0;
transition: border-color 0.15s, color 0.15s;
}
.hint-icon:hover, .hint-icon:focus-visible { border-color: var(--accent); color: var(--accent); }
#hint-tooltip {
position: fixed;
z-index: 10000;
max-width: 280px;
padding: 8px 12px;
border-radius: 8px;
font-size: 12px;
line-height: 1.5;
background: var(--bg-elevated);
color: var(--text);
border: 1px solid var(--border);
box-shadow: var(--shadow);
pointer-events: none;
display: none;
}
@keyframes modal-in { @keyframes modal-in {
from { opacity: 0; transform: translateY(8px) scale(0.98); } from { opacity: 0; transform: translateY(8px) scale(0.98); }
to { opacity: 1; transform: translateY(0) scale(1); } to { opacity: 1; transform: translateY(0) scale(1); }
+97
View File
@@ -285,6 +285,102 @@
.catch(() => { content.textContent = "Fehler beim Laden des Logs."; }); .catch(() => { content.textContent = "Fehler beim Laden des Logs."; });
}; };
/* ---------------- Hinweis-Icons (Hover-Tooltip + Klick-Modal) ---------------- */
/* Zentraler Mechanismus für ALLE "i"-Icons (siehe _hint_icon.html) app-weit
-- ein einziges delegiertes Set von Listenern statt pro Icon eigener
Handler, damit auch nachträglich per JS eingefügte Icons (z.B. in
dynamisch nachgeladenen Tabellenzeilen) ohne weiteres Zutun funktionieren.
Hover zeigt den Text als Tooltip neben dem Mauszeiger -- aber NUR auf
Geräten, die tatsächlich sinnvoll hovern können (matchMedia-Check bei
JEDEM mouseenter neu ausgewertet, nicht einmalig beim Laden gecacht,
damit z.B. ein Convertible/Tablet beim Umschalten Maus/Touch korrekt
reagiert). Auf reinen Touch-Geräten bleibt so nur Tap -> Modal übrig,
da dort ohnehin kein echtes mouseenter/mousemove-Hover stattfindet. */
function ensureHintTooltip() {
if (document.getElementById("hint-tooltip")) return;
const el = document.createElement("div");
el.id = "hint-tooltip";
document.body.appendChild(el);
}
function ensureHintModal() {
if (document.getElementById("hint-modal")) return;
const html = `
<div class="modal-overlay" id="hint-modal">
<div class="modal" style="max-width:440px;">
<div class="modal-header">
<h3 id="hint-modal-title">Hinweis</h3>
<button type="button" class="modal-close" data-close-modal>&times;</button>
</div>
<div class="modal-body">
<p id="hint-modal-body" class="text-dim" style="margin:0; line-height:1.6;"></p>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-close-modal>Schließen</button>
</div>
</div>
</div>`;
document.body.insertAdjacentHTML("beforeend", html);
document.querySelectorAll("#hint-modal [data-close-modal]").forEach((btn) => {
btn.addEventListener("click", () => closeModal(btn));
});
document.getElementById("hint-modal").addEventListener("click", (e) => {
if (e.target.id === "hint-modal") closeModal(e.target);
});
}
function canHover() {
return window.matchMedia && window.matchMedia("(hover: hover) and (pointer: fine)").matches;
}
function initHintIcons() {
if (!document.querySelector(".hint-icon")) return;
ensureHintTooltip();
const tooltip = document.getElementById("hint-tooltip");
function positionTooltip(e) {
const offset = 14;
const rect = tooltip.getBoundingClientRect();
let x = e.clientX + offset;
let y = e.clientY + offset;
if (x + rect.width > window.innerWidth - 8) x = e.clientX - rect.width - offset;
if (y + rect.height > window.innerHeight - 8) y = e.clientY - rect.height - offset;
tooltip.style.left = Math.max(8, x) + "px";
tooltip.style.top = Math.max(8, y) + "px";
}
// mouseenter/mouseleave bubbeln nicht -- Delegation via Capture-Phase
// auf document funktioniert dafür trotzdem zuverlässig.
document.addEventListener("mouseenter", function (e) {
const icon = e.target.closest && e.target.closest(".hint-icon");
if (!icon || !canHover()) return;
tooltip.textContent = icon.dataset.hint || "";
tooltip.style.display = "block";
positionTooltip(e);
}, true);
document.addEventListener("mouseleave", function (e) {
const icon = e.target.closest && e.target.closest(".hint-icon");
if (!icon) return;
tooltip.style.display = "none";
}, true);
document.addEventListener("mousemove", function (e) {
if (tooltip.style.display === "block") positionTooltip(e);
});
document.addEventListener("click", function (e) {
const icon = e.target.closest && e.target.closest(".hint-icon");
if (!icon) return;
e.preventDefault();
tooltip.style.display = "none";
ensureHintModal();
document.getElementById("hint-modal-title").textContent = icon.dataset.hintTitle || "Hinweis";
document.getElementById("hint-modal-body").textContent = icon.dataset.hint || "";
openModal("hint-modal");
});
}
/* ---------------- Warnung bei ungespeicherten Änderungen ---------------- */ /* ---------------- Warnung bei ungespeicherten Änderungen ---------------- */
/* Erkennt generisch auf JEDER Seite, ob ein Formular mit echten /* Erkennt generisch auf JEDER Seite, ob ein Formular mit echten
Eingabefeldern (nicht nur versteckten Aktions-Feldern wie bei Löschen/ Eingabefeldern (nicht nur versteckten Aktions-Feldern wie bei Löschen/
@@ -628,6 +724,7 @@
initLeaseCountdowns(); initLeaseCountdowns();
initUnsavedChangesGuard(); initUnsavedChangesGuard();
initSortableTables(); initSortableTables();
initHintIcons();
document.querySelectorAll("[data-theme-toggle]").forEach((btn) => btn.addEventListener("click", toggleTheme)); document.querySelectorAll("[data-theme-toggle]").forEach((btn) => btn.addEventListener("click", toggleTheme));
}); });
+67
View File
@@ -0,0 +1,67 @@
{#
Gemeinsame Auditlog-Zeilendarstellung -- von activity_log.html (Erstladung)
UND _audit_log_rows.html (AJAX-Nachladen über "Weitere 500 laden")
importiert, damit beide garantiert dieselbe Darstellung erzeugen und
action_labels/action_icons/category_of nicht an zwei Stellen gepflegt
werden müssen.
#}
{% set action_labels = {
"settings.update": "Einstellung geändert",
"device.create": "Gerät angelegt",
"device.edit": "Gerät bearbeitet",
"device.delete": "Gerät gelöscht",
"device.activate": "Gerät aktiviert",
"device.deactivate": "Gerät deaktiviert",
"switch.create": "Switch angelegt",
"switch.edit": "Switch bearbeitet",
"switch.delete": "Switch gelöscht",
"credential.create": "Zugangsdaten angelegt",
"credential.edit": "Zugangsdaten bearbeitet",
"credential.delete": "Zugangsdaten gelöscht",
"user.create": "Benutzer angelegt",
"user.edit": "Benutzer bearbeitet",
"user.delete": "Benutzer gelöscht",
"user.assign_group": "Gruppe zugewiesen",
"group.create": "Gruppe angelegt",
"group.edit": "Gruppe bearbeitet",
"group.delete": "Gruppe gelöscht",
"group.assign_admins": "Admin-Zuweisung geändert",
"profile.update": "Profil aktualisiert",
"profile.password": "Passwort geändert",
"check.run_now": "Prüfung manuell gestartet",
"fileshare.upload": "Datei(en) hochgeladen",
"fileshare.mkdir": "Ordner angelegt",
"fileshare.delete": "Datei/Ordner gelöscht",
"fileshare.rename": "Datei/Ordner umbenannt",
"fileshare.download": "Als ZIP heruntergeladen",
"auditlog.archive": "Auditlog archiviert",
"auditlog.export_delete": "Auditlog-Archiv exportiert",
} %}
{% set action_icons = {
"delete": '<path d="M3 6h18M8 6V4a2 2 0 012-2h4a2 2 0 012 2v2m3 0l-1 14a2 2 0 01-2 2H7a2 2 0 01-2-2L4 6"/>',
"create": '<path d="M12 5v14M5 12h14"/>',
"edit": '<path d="M12 20h9"/><path d="M16.5 3.5a2.1 2.1 0 013 3L7 19l-4 1 1-4z"/>',
"activate": '<path d="M20 6L9 17l-5-5"/>',
"deactivate": '<circle cx="12" cy="12" r="9"/><path d="M15 9l-6 6M9 9l6 6"/>',
} %}
{% set create_kinds = ("create", "upload", "mkdir") %}
{% macro category_of(kind) %}{% if kind in create_kinds %}create{% elif kind == "delete" %}delete{% else %}edit{% endif %}{% endmacro %}
{% macro audit_row(e) %}
{% set kind = e['action'].split('.')[-1] %}
{% set cat = category_of(kind)|trim %}
<tr data-category="{{ cat }}" data-sort-ts="{{ e['ts'] }}" data-sort-user="{{ e['username']|lower }}"
data-sort-action="{{ action_labels.get(e['action'], e['action'])|lower }}" data-sort-target="{{ (e['target'] or '')|lower }}">
<td class="text-dim mono" style="font-size:12.5px;">{{ e['ts'] }}</td>
<td class="cell-name">{{ e['username'] }}</td>
<td>
<span class="pill action-pill action-pill--{{ cat }}">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">{{ (action_icons.get(kind) or action_icons['edit'])|safe }}</svg>
{{ action_labels.get(e['action'], e['action']) }}
</span>
</td>
<td>{{ e['target'] or '—' }}</td>
<td class="text-dim">{{ e['details'] or '—' }}</td>
</tr>
{% endmacro %}
+5
View File
@@ -0,0 +1,5 @@
{# Reines Zeilenfragment fürs AJAX-Nachladen (activity_log_more()) -- kein
umschließendes <table>/<tbody>, wird per insertAdjacentHTML direkt an das
bestehende tbody von #auditTable angehängt. #}
{% import "_audit_log_macros.html" as m %}
{% for e in entries %}{{ m.audit_row(e) }}{% endfor %}
+9
View File
@@ -0,0 +1,9 @@
{#
Gemeinsames "i"-Hinweis-Icon: Hover zeigt den Text als Tooltip neben dem
Mauszeiger, Klick/Tap öffnet dasselbe (wiederverwendete) Modal mit dem
vollen Text -- Mechanik lebt zentral in app.js (initHintIcons), hier wird
nur das Icon mit seinen Daten-Attributen erzeugt. title = Kontext für die
Modal-Überschrift (z.B. das zugehörige Feld-Label), text = der eigentliche
Erklärungstext.
#}
{% macro hint_icon(text, title='Hinweis') %}<button type="button" class="hint-icon" data-hint="{{ text }}" data-hint-title="{{ title }}" aria-label="Hinweis: {{ title }}">i</button>{% endmacro %}
+129 -67
View File
@@ -1,77 +1,41 @@
{% extends "base.html" %} {% extends "base.html" %}
{% set active_page = "logs" %} {% set active_page = "logs" %}
{% block page_title %}Auditlog{% endblock %} {% block page_title %}Auditlog{% endblock %}
{% block page_sub %}<div class="topbar-sub">{{ entries|length }} Einträge (letzte 500)</div>{% endblock %} {% block page_sub %}
<div class="topbar-sub" id="auditEntryCount">
{% if has_more %}{{ entries|length }} von {{ total_count }} Einträgen geladen{% else %}{{ entries|length }} Eintrag{{ 'e' if entries|length != 1 else '' }}{% endif %}
</div>
{% endblock %}
{% block content %} {% block content %}
{% import "_audit_log_macros.html" as m %}
{% set action_labels = {
"settings.update": "Einstellung geändert",
"device.create": "Gerät angelegt",
"device.edit": "Gerät bearbeitet",
"device.delete": "Gerät gelöscht",
"device.activate": "Gerät aktiviert",
"device.deactivate": "Gerät deaktiviert",
"switch.create": "Switch angelegt",
"switch.edit": "Switch bearbeitet",
"switch.delete": "Switch gelöscht",
"credential.create": "Zugangsdaten angelegt",
"credential.edit": "Zugangsdaten bearbeitet",
"credential.delete": "Zugangsdaten gelöscht",
"user.create": "Benutzer angelegt",
"user.edit": "Benutzer bearbeitet",
"user.delete": "Benutzer gelöscht",
"user.assign_group": "Gruppe zugewiesen",
"group.create": "Gruppe angelegt",
"group.edit": "Gruppe bearbeitet",
"group.delete": "Gruppe gelöscht",
"group.assign_admins": "Admin-Zuweisung geändert",
"profile.update": "Profil aktualisiert",
"profile.password": "Passwort geändert",
"check.run_now": "Prüfung manuell gestartet",
"fileshare.upload": "Datei(en) hochgeladen",
"fileshare.mkdir": "Ordner angelegt",
"fileshare.delete": "Datei/Ordner gelöscht",
"fileshare.rename": "Datei/Ordner umbenannt",
"fileshare.download": "Als ZIP heruntergeladen",
"auditlog.archive": "Auditlog archiviert",
"auditlog.export_delete": "Auditlog-Archiv exportiert",
} %}
{% set action_icons = {
"delete": '<path d="M3 6h18M8 6V4a2 2 0 012-2h4a2 2 0 012 2v2m3 0l-1 14a2 2 0 01-2 2H7a2 2 0 01-2-2L4 6"/>',
"create": '<path d="M12 5v14M5 12h14"/>',
"edit": '<path d="M12 20h9"/><path d="M16.5 3.5a2.1 2.1 0 013 3L7 19l-4 1 1-4z"/>',
"activate": '<path d="M20 6L9 17l-5-5"/>',
"deactivate": '<circle cx="12" cy="12" r="9"/><path d="M15 9l-6 6M9 9l6 6"/>',
} %}
{% set create_kinds = ("create", "upload", "mkdir") %}
{% macro category_of(kind) %}{% if kind in create_kinds %}create{% elif kind == "delete" %}delete{% else %}edit{% endif %}{% endmacro %}
{% set ns = namespace(create=0, delete=0, edit=0) %} {% set ns = namespace(create=0, delete=0, edit=0) %}
{% for e in entries %} {% for e in entries %}
{% set cat = category_of(e['action'].split('.')[-1])|trim %} {% set cat = m.category_of(e['action'].split('.')[-1])|trim %}
{% if cat == "create" %}{% set ns.create = ns.create + 1 %} {% if cat == "create" %}{% set ns.create = ns.create + 1 %}
{% elif cat == "delete" %}{% set ns.delete = ns.delete + 1 %} {% elif cat == "delete" %}{% set ns.delete = ns.delete + 1 %}
{% else %}{% set ns.edit = ns.edit + 1 %}{% endif %} {% else %}{% set ns.edit = ns.edit + 1 %}{% endif %}
{% endfor %} {% endfor %}
{% macro fmt_count(loaded, total) %}{% if loaded < total %}{{ loaded }} / {{ total }}{% else %}{{ loaded }}{% endif %}{% endmacro %}
<div class="stat-row" style="margin-bottom:18px;"> <div class="stat-row" style="margin-bottom:18px;">
<div class="stat-card" data-category-filter=""> <div class="stat-card" data-category-filter="">
<div class="stat-label">Alle</div> <div class="stat-label">Alle</div>
<div class="stat-value">{{ entries|length }}</div> <div class="stat-value" id="statAll">{{ fmt_count(entries|length, total_count) }}</div>
</div> </div>
<div class="stat-card" data-category-filter="create"> <div class="stat-card" data-category-filter="create">
<div class="stat-label">Hinzufügen</div> <div class="stat-label">Hinzufügen</div>
<div class="stat-value" style="color:var(--success);">{{ ns.create }}</div> <div class="stat-value" id="statCreate" style="color:var(--success);">{{ fmt_count(ns.create, total_create) }}</div>
</div> </div>
<div class="stat-card" data-category-filter="edit"> <div class="stat-card" data-category-filter="edit">
<div class="stat-label">Änderungen</div> <div class="stat-label">Änderungen</div>
<div class="stat-value" style="color:var(--accent-strong);">{{ ns.edit }}</div> <div class="stat-value" id="statEdit" style="color:var(--accent-strong);">{{ fmt_count(ns.edit, total_edit) }}</div>
</div> </div>
<div class="stat-card" data-category-filter="delete"> <div class="stat-card" data-category-filter="delete">
<div class="stat-label">Löschungen</div> <div class="stat-label">Löschungen</div>
<div class="stat-value" style="color:var(--danger);">{{ ns.delete }}</div> <div class="stat-value" id="statDelete" style="color:var(--danger);">{{ fmt_count(ns.delete, total_delete) }}</div>
</div> </div>
</div> </div>
@@ -92,29 +56,21 @@
<th>Details</th> <th>Details</th>
</tr></thead> </tr></thead>
<tbody> <tbody>
{% for e in entries %} {% for e in entries %}{{ m.audit_row(e) }}{% else %}
{% set kind = e['action'].split('.')[-1] %}
{% set cat = category_of(kind)|trim %}
<tr data-category="{{ cat }}" data-sort-ts="{{ e['ts'] }}" data-sort-user="{{ e['username']|lower }}"
data-sort-action="{{ action_labels.get(e['action'], e['action'])|lower }}" data-sort-target="{{ (e['target'] or '')|lower }}">
<td class="text-dim mono" style="font-size:12.5px;">{{ e['ts'] }}</td>
<td class="cell-name">{{ e['username'] }}</td>
<td>
<span class="pill action-pill action-pill--{{ cat }}">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">{{ (action_icons.get(kind) or action_icons['edit'])|safe }}</svg>
{{ action_labels.get(e['action'], e['action']) }}
</span>
</td>
<td>{{ e['target'] or '—' }}</td>
<td class="text-dim">{{ e['details'] or '—' }}</td>
</tr>
{% else %}
<tr class="empty-row"><td colspan="5">Noch keine Änderungen protokolliert.</td></tr> <tr class="empty-row"><td colspan="5">Noch keine Änderungen protokolliert.</td></tr>
{% endfor %} {% endfor %}
</tbody> </tbody>
</table> </table>
</div> </div>
<p id="auditNoResults" class="text-faint hidden" style="padding:16px; text-align:center; font-size:12.5px;">Keine Einträge für diese Auswahl.</p> <p id="auditNoResults" class="text-faint hidden" style="padding:16px; text-align:center; font-size:12.5px;">Keine Einträge für diese Auswahl.</p>
<div id="auditLoadMoreWrap" class="{{ 'hidden' if not has_more }}" style="padding:16px; text-align:center; display:flex; gap:8px; justify-content:center;">
<button type="button" id="auditLoadMoreBtn" class="btn btn-primary" data-oldest-id="{{ oldest_loaded_id or '' }}">
Mehr laden (300)
</button>
<button type="button" id="auditLoadAllBtn" class="btn btn-secondary">
Alle laden
</button>
</div>
</div> </div>
{% endblock %} {% endblock %}
@@ -124,11 +80,11 @@
document.addEventListener("DOMContentLoaded", function () { document.addEventListener("DOMContentLoaded", function () {
const searchInput = document.getElementById("auditSearch"); const searchInput = document.getElementById("auditSearch");
const noResults = document.getElementById("auditNoResults"); const noResults = document.getElementById("auditNoResults");
const rows = Array.from(document.querySelectorAll("#auditTable tbody tr"));
let activeCategory = null; let activeCategory = null;
function applyFilters() { function applyFilters() {
const q = (searchInput ? searchInput.value : "").trim().toLowerCase(); const q = (searchInput ? searchInput.value : "").trim().toLowerCase();
const rows = Array.from(document.querySelectorAll("#auditTable tbody tr"));
let anyVisible = false; let anyVisible = false;
rows.forEach(function (row) { rows.forEach(function (row) {
if (row.classList.contains("empty-row")) return; if (row.classList.contains("empty-row")) return;
@@ -156,7 +112,113 @@ document.addEventListener("DOMContentLoaded", function () {
}); });
}); });
/* ---- Nachladen älterer Einträge (Mehr laden / Alle laden) ---- */
const totalCount = {{ total_count }};
const totalCreate = {{ total_create }};
const totalEdit = {{ total_edit }};
const totalDelete = {{ total_delete }};
const entryCountLabel = document.getElementById("auditEntryCount");
function fmtCount(loaded, total) {
return loaded < total ? loaded + " / " + total : String(loaded);
}
function recomputeStats() {
const rows = Array.from(document.querySelectorAll("#auditTable tbody tr[data-category]"));
const counts = { create: 0, edit: 0, delete: 0 };
rows.forEach(function (r) { counts[r.dataset.category] = (counts[r.dataset.category] || 0) + 1; });
document.getElementById("statAll").textContent = fmtCount(rows.length, totalCount);
document.getElementById("statCreate").textContent = fmtCount(counts.create, totalCreate);
document.getElementById("statEdit").textContent = fmtCount(counts.edit, totalEdit);
document.getElementById("statDelete").textContent = fmtCount(counts.delete, totalDelete);
if (entryCountLabel) {
entryCountLabel.textContent = rows.length < totalCount
? rows.length + " von " + totalCount + " Einträgen geladen"
: rows.length + " Eintrag" + (rows.length === 1 ? "" : "e");
}
return rows.length;
}
const loadMoreBtn = document.getElementById("auditLoadMoreBtn");
const loadAllBtn = document.getElementById("auditLoadAllBtn");
const loadMoreWrap = document.getElementById("auditLoadMoreWrap");
const tbody = document.querySelector("#auditTable tbody");
const MORE_URL = "{{ url_for('activity_log_more') }}";
// Laedt genau einen weiteren 300er-Block nach und haengt ihn an -- von
// beiden Buttons genutzt: "Mehr laden" ruft das einmal auf, "Alle laden"
// ruft es wiederholt auf, bis der Server "keine weiteren mehr" meldet.
function loadMoreOnce() {
const oldestId = loadMoreBtn.dataset.oldestId;
if (!oldestId) return Promise.resolve({ hasMore: false });
return fetch(MORE_URL + "?before_id=" + encodeURIComponent(oldestId))
.then(function (r) {
const hasMore = r.headers.get("X-Has-More") === "1";
const newOldestId = r.headers.get("X-Oldest-Id");
return r.text().then(function (html) {
return { html: html, hasMore: hasMore, newOldestId: newOldestId };
});
})
.then(function (result) {
const emptyRow = tbody.querySelector(".empty-row");
if (emptyRow) emptyRow.remove();
tbody.insertAdjacentHTML("beforeend", result.html);
if (result.newOldestId) loadMoreBtn.dataset.oldestId = result.newOldestId;
return result;
});
}
if (loadMoreBtn) {
loadMoreBtn.addEventListener("click", function () {
loadMoreBtn.disabled = true;
if (loadAllBtn) loadAllBtn.disabled = true;
const originalText = loadMoreBtn.textContent;
loadMoreBtn.textContent = "Lädt …";
loadMoreOnce()
.then(function (result) {
if (!result.hasMore) loadMoreWrap.classList.add("hidden");
recomputeStats();
applyFilters(); applyFilters();
})
.catch(function () { /* Button unten wird trotzdem wieder aktiviert */ })
.then(function () {
loadMoreBtn.disabled = false;
if (loadAllBtn) loadAllBtn.disabled = false;
loadMoreBtn.textContent = originalText;
});
});
}
if (loadAllBtn) {
loadAllBtn.addEventListener("click", function () {
loadMoreBtn.disabled = true;
loadAllBtn.disabled = true;
const originalText = loadAllBtn.textContent;
function step() {
loadAllBtn.textContent = "Lädt … (" + recomputeStats() + " von " + totalCount + ")";
return loadMoreOnce().then(function (result) {
return result.hasMore ? step() : null;
});
}
step()
.then(function () {
loadMoreWrap.classList.add("hidden");
})
.catch(function () { /* teilweise geladene Eintraege bleiben stehen */ })
.then(function () {
recomputeStats();
applyFilters();
loadMoreBtn.disabled = false;
loadAllBtn.disabled = false;
loadAllBtn.textContent = originalText;
});
});
}
applyFilters();
recomputeStats();
}); });
</script> </script>
{% endblock %} {% endblock %}
+6
View File
@@ -129,6 +129,12 @@
</button> </button>
{% endif %} {% endif %}
</span> </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 %} {% if dhcp_topbar_active is not none %}
<span class="timer-pill" title="DHCP-Dienst: {{ 'aktiv' if dhcp_topbar_active else 'inaktiv' }}"> <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 <span class="dot" style="animation:none; background:{{ 'var(--success)' if dhcp_topbar_active else 'var(--danger)' }}; box-shadow:none;"></span>DHCP
+2 -2
View File
@@ -8,6 +8,7 @@
{% block page_sub %}<div class="topbar-sub">{{ credentials|length }} Zugangsdaten</div>{% endblock %} {% block page_sub %}<div class="topbar-sub">{{ credentials|length }} Zugangsdaten</div>{% endblock %}
{% block content %} {% block content %}
{% import "_hint_icon.html" as hi %}
<div class="section-head"> <div class="section-head">
<div> <div>
@@ -95,13 +96,12 @@
<div class="field"><label>Username</label> <div class="field"><label>Username</label>
<input type="text" name="username" required placeholder="z.B. admin"> <input type="text" name="username" required placeholder="z.B. admin">
</div> </div>
<div class="field"><label>Kategorie</label> <div class="field"><label>Kategorie {{ hi.hint_icon("Bestimmt u.a., ob diese Zugangsdaten unter „Wartung“ für Bulk-Updates nutzbar sind.", "Kategorie") }}</label>
<select name="category"> <select name="category">
{% for key, label in categories %} {% for key, label in categories %}
<option value="{{ key }}">{{ label }}</option> <option value="{{ key }}">{{ label }}</option>
{% endfor %} {% endfor %}
</select> </select>
<div class="hint">Bestimmt u.a., ob diese Zugangsdaten unter „Wartung“ für Bulk-Updates nutzbar sind.</div>
</div> </div>
<div class="field"><label>Passwort</label> <div class="field"><label>Passwort</label>
<input type="password" id="password_add" name="password" required> <input type="password" id="password_add" name="password" required>
+5 -8
View File
@@ -13,6 +13,7 @@
{% block page_sub %}<div class="topbar-sub">{{ devices|length }} Geräte</div>{% endblock %} {% block page_sub %}<div class="topbar-sub">{{ devices|length }} Geräte</div>{% endblock %}
{% block content %} {% block content %}
{% import "_hint_icon.html" as hi %}
<div class="section-head"> <div class="section-head">
<div> <div>
@@ -125,9 +126,8 @@
<input type="text" name="mac" required placeholder="z.B. AA:BB:CC:DD:EE:FF"> <input type="text" name="mac" required placeholder="z.B. AA:BB:CC:DD:EE:FF">
<div class="invalid-feedback">Bitte eine gültige MAC-Adresse eingeben (xx:xx:xx:xx:xx:xx).</div> <div class="invalid-feedback">Bitte eine gültige MAC-Adresse eingeben (xx:xx:xx:xx:xx:xx).</div>
</div> </div>
<div class="field"><label>Switchport</label> <div class="field"><label>Switchport {{ hi.hint_icon("Physische Port-Nummer AM SWITCH (z.B. Port 3 von 48), an dem das Gerät eingesteckt ist — für den PoE-Neustart. Kein Netzwerk-/TCP-Port, keine SSH-Anmeldung.", "Switchport") }}</label>
<input type="text" name="port" placeholder="z.B. 3"> <input type="text" name="port" placeholder="z.B. 3">
<div class="field-hint">Physische Port-Nummer AM SWITCH (z.B. Port 3 von 48), an dem das Gerät eingesteckt ist — für den PoE-Neustart. Kein Netzwerk-/TCP-Port, keine SSH-Anmeldung.</div>
</div> </div>
<div class="field"><label>Switch (optional)</label> <div class="field"><label>Switch (optional)</label>
<select name="switch_hostname"> <select name="switch_hostname">
@@ -136,9 +136,8 @@
</select> </select>
</div> </div>
<div class="permission-group-title">SSH-Zugriff (optional, z.B. für Update-Aktionen unter „Wartung“)</div> <div class="permission-group-title">SSH-Zugriff (optional, z.B. für Update-Aktionen unter „Wartung“)</div>
<div class="field"><label>SSH-Port</label> <div class="field"><label>SSH-Port {{ hi.hint_icon("TCP-Netzwerk-Port für die SSH-Anmeldung an der IP-Adresse dieses Geräts (siehe Feld „IP-Adresse“ oben). Leer lassen, wenn Standard-Port 22 verwendet wird.", "SSH-Port") }}</label>
<input type="number" name="ssh_port" min="1" max="65535" placeholder="22 (Standard)"> <input type="number" name="ssh_port" min="1" max="65535" placeholder="22 (Standard)">
<div class="field-hint">TCP-Netzwerk-Port für die SSH-Anmeldung an der IP-Adresse dieses Geräts (siehe Feld „IP-Adresse“ oben). Leer lassen, wenn Standard-Port 22 verwendet wird.</div>
</div> </div>
<div class="field"> <div class="field">
<label>Zugangsdaten</label> <label>Zugangsdaten</label>
@@ -201,14 +200,12 @@
<input type="text" name="mac" id="edit_mac" required> <input type="text" name="mac" id="edit_mac" required>
<div class="invalid-feedback">Bitte eine gültige MAC-Adresse eingeben (xx:xx:xx:xx:xx:xx).</div> <div class="invalid-feedback">Bitte eine gültige MAC-Adresse eingeben (xx:xx:xx:xx:xx:xx).</div>
</div> </div>
<div class="field"><label>Switchport</label> <div class="field"><label>Switchport {{ hi.hint_icon("Portnummer am zugeordneten Switch, für den PoE-Neustart. Hat nichts mit dem SSH-Port unten zu tun.", "Switchport") }}</label>
<input type="text" name="port" id="edit_port"> <input type="text" name="port" id="edit_port">
<div class="field-hint">Portnummer am zugeordneten Switch, für den PoE-Neustart. Hat nichts mit dem SSH-Port unten zu tun.</div>
</div> </div>
<div class="permission-group-title">SSH-Zugriff (optional, z.B. für Update-Aktionen unter „Wartung“)</div> <div class="permission-group-title">SSH-Zugriff (optional, z.B. für Update-Aktionen unter „Wartung“)</div>
<div class="field"><label>SSH-Port</label> <div class="field"><label>SSH-Port {{ hi.hint_icon("TCP-Netzwerk-Port für die SSH-Anmeldung an der IP-Adresse dieses Geräts (siehe Feld „IP-Adresse“ oben). Leer lassen, wenn Standard-Port 22 verwendet wird.", "SSH-Port") }}</label>
<input type="number" name="ssh_port" id="edit_ssh_port" min="1" max="65535" placeholder="22 (Standard)"> <input type="number" name="ssh_port" id="edit_ssh_port" min="1" max="65535" placeholder="22 (Standard)">
<div class="field-hint">TCP-Netzwerk-Port für die SSH-Anmeldung an der IP-Adresse dieses Geräts (siehe Feld „IP-Adresse“ oben). Leer lassen, wenn Standard-Port 22 verwendet wird.</div>
</div> </div>
<div class="field"> <div class="field">
<label>Zugangsdaten</label> <label>Zugangsdaten</label>
+2 -2
View File
@@ -4,6 +4,7 @@
{% block page_sub %}<div class="topbar-sub">{{ selected_share }}{% if rel_path %} / {{ rel_path }}{% endif %}</div>{% endblock %} {% block page_sub %}<div class="topbar-sub">{{ selected_share }}{% if rel_path %} / {{ rel_path }}{% endif %}</div>{% endblock %}
{% block content %} {% block content %}
{% import "_hint_icon.html" as hi %}
{% macro render_tree_node(share, path, name, expanded_map, active_path) %} {% macro render_tree_node(share, path, name, expanded_map, active_path) %}
<li class="tree-node" data-share="{{ share }}" data-path="{{ path }}"> <li class="tree-node" data-share="{{ share }}" data-path="{{ path }}">
@@ -161,10 +162,9 @@
</div> </div>
<div class="modal-body"> <div class="modal-body">
<div class="field"> <div class="field">
<label>Datei(en)</label> <label>Datei(en) {{ hi.hint_icon("Insgesamt maximal 15 MB pro Upload-Vorgang. Mehrfachauswahl möglich (auch mehrmals nacheinander — bereits hinzugefügte Dateien bleiben dabei erhalten).", "Datei(en)") }}</label>
<input type="file" name="file" id="uploadFileInput" multiple required> <input type="file" name="file" id="uploadFileInput" multiple required>
<div id="uploadFileList" class="upload-file-list"></div> <div id="uploadFileList" class="upload-file-list"></div>
<div class="field-hint">Insgesamt maximal 15&nbsp;MB pro Upload-Vorgang. Mehrfachauswahl möglich (auch mehrmals nacheinander — bereits hinzugefügte Dateien bleiben dabei erhalten).</div>
</div> </div>
<div class="field-hint">Wird in „{{ selected_share }}{% if rel_path %} / {{ rel_path }}{% endif %}“ hochgeladen. Eine bereits vorhandene Datei gleichen Namens wird überschrieben.</div> <div class="field-hint">Wird in „{{ selected_share }}{% if rel_path %} / {{ rel_path }}{% endif %}“ hochgeladen. Eine bereits vorhandene Datei gleichen Namens wird überschrieben.</div>
</div> </div>
+3 -7
View File
@@ -4,6 +4,7 @@
{% block page_sub %}<div class="topbar-sub">{{ groups|length + 1 }} Gruppen · Rechteverwaltung</div>{% endblock %} {% block page_sub %}<div class="topbar-sub">{{ groups|length + 1 }} Gruppen · Rechteverwaltung</div>{% endblock %}
{% block content %} {% block content %}
{% import "_hint_icon.html" as hi %}
{% macro permission_table(group, group_key, checked_keys, readonly) %} {% macro permission_table(group, group_key, checked_keys, readonly) %}
{% set row_types = group_row_types[group_key] %} {% set row_types = group_row_types[group_key] %}
@@ -69,11 +70,7 @@
<div class="section-head"> <div class="section-head">
<div> <div>
<h2 style="font-size:16px;">Gruppen</h2> <h2 style="font-size:16px;">Gruppen {{ hi.hint_icon("Rechte je Gruppe granular vergeben, Mitgliedschaft in mehreren Gruppen addiert sich. „Admin“ und „Benutzer“ sind feste Systemgruppen. Legende direkt bei den Rechten.", "Gruppen") }}</h2>
<div class="hint">
Rechte je Gruppe granular vergeben, Mitgliedschaft in mehreren Gruppen addiert sich.
„Admin“ und „Benutzer“ sind feste Systemgruppen. Legende direkt bei den Rechten.
</div>
</div> </div>
{% if current_user.has_permission('groups.create') %} {% if current_user.has_permission('groups.create') %}
<button type="button" class="btn btn-primary" data-open-modal="addGroupModal"> <button type="button" class="btn btn-primary" data-open-modal="addGroupModal">
@@ -317,9 +314,8 @@
<div class="modal-body"> <div class="modal-body">
<input type="hidden" name="add_group" value="1"> <input type="hidden" name="add_group" value="1">
<div class="field"> <div class="field">
<label>Name</label> <label>Name {{ hi.hint_icon("Mitglieder werden danach über die Gruppentabelle zugeordnet.", "Name") }}</label>
<input type="text" name="name" required placeholder="z.B. Facility-Team"> <input type="text" name="name" required placeholder="z.B. Facility-Team">
<div class="field-hint">Mitglieder werden danach über die Gruppentabelle zugeordnet.</div>
</div> </div>
{{ permission_tree([], false) }} {{ permission_tree([], false) }}
</div> </div>
+2 -2
View File
@@ -4,6 +4,7 @@
{% block page_sub %}<div class="topbar-sub">{{ selected_file.range_label if selected_file else "kein Log" }}</div>{% endblock %} {% block page_sub %}<div class="topbar-sub">{{ selected_file.range_label if selected_file else "kein Log" }}</div>{% endblock %}
{% block content %} {% block content %}
{% import "_hint_icon.html" as hi %}
<div class="section-head"> <div class="section-head">
<div> <div>
@@ -54,8 +55,7 @@
<div class="section-head" style="margin-top:28px;"> <div class="section-head" style="margin-top:28px;">
<div> <div>
<h2 style="font-size:16px;">Archivierte Auditlog-Tage</h2> <h2 style="font-size:16px;">Archivierte Auditlog-Tage {{ hi.hint_icon("Ältere Auditlog-Einträge, tageweise als eigene Datei ausgelagert, sobald die laufende Tabelle den Schwellenwert überschreitet — die Dateien selbst bleiben unbegrenzt erhalten, bis sie hier bewusst exportiert werden.", "Archivierte Auditlog-Tage") }}</h2>
<div class="hint">Ältere Auditlog-Einträge, tageweise als eigene Datei ausgelagert, sobald die laufende Tabelle den Schwellenwert überschreitet — die Dateien selbst bleiben unbegrenzt erhalten, bis sie hier bewusst exportiert werden.</div>
</div> </div>
{% if current_user.can_manage_log_history and audit_files %} {% if current_user.can_manage_log_history and audit_files %}
<form method="post" action="{{ url_for('logs_history_audit_export') }}" <form method="post" action="{{ url_for('logs_history_audit_export') }}"
+5 -7
View File
@@ -4,15 +4,11 @@
{% block page_sub %}<div class="topbar-sub">{{ devices|length }} Linux-Client{{ 's' if devices|length != 1 else '' }} mit SSH-Zugangsdaten</div>{% endblock %} {% block page_sub %}<div class="topbar-sub">{{ devices|length }} Linux-Client{{ 's' if devices|length != 1 else '' }} mit SSH-Zugangsdaten</div>{% endblock %}
{% block content %} {% block content %}
{% import "_hint_icon.html" as hi %}
<div class="section-head"> <div class="section-head">
<div> <div>
<h2 style="font-size:16px;">Wartung (SSH)</h2> <h2 style="font-size:16px;">Wartung (SSH) {{ hi.hint_icon("Bulk-Update (apt update & upgrade -y) und Neustart per SSH direkt auf dem Gerät — unabhängig vom PoE-Neustart über den Switch (siehe Dashboard). Nur Geräte mit hinterlegten SSH-Zugangsdaten der Kategorie „Linux-Client“ erscheinen hier.", "Wartung (SSH)") }}</h2>
<div class="hint">
Bulk-Update (<code>apt update &amp; upgrade -y</code>) und Neustart per SSH direkt auf dem Gerät —
unabhängig vom PoE-Neustart über den Switch (siehe Dashboard). Nur Geräte mit hinterlegten
SSH-Zugangsdaten der Kategorie „Linux-Client“ erscheinen hier.
</div>
</div> </div>
</div> </div>
@@ -87,7 +83,9 @@
{% if can_run %} {% if can_run %}
<div class="section-head" style="margin-top:16px;"> <div class="section-head" style="margin-top:16px;">
<div class="hint">Ausgewählte Geräte aktualisieren (nicht-interaktiv, unbeaufsichtigte Konfig-Rückfragen werden automatisch mit den bisherigen Werten beantwortet) oder neu starten.</div> <div style="display:flex; align-items:center; gap:6px; font-size:12.5px; color:var(--text-faint);">
Bulk-Aktionen {{ hi.hint_icon("Ausgewählte Geräte aktualisieren (nicht-interaktiv, unbeaufsichtigte Konfig-Rückfragen werden automatisch mit den bisherigen Werten beantwortet) oder neu starten.", "Bulk-Aktionen") }}
</div>
<div style="display:flex; gap:8px;"> <div style="display:flex; gap:8px;">
<button type="button" class="btn btn-secondary" onclick="bulkReboot()"> <button type="button" class="btn btn-secondary" onclick="bulkReboot()">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12a9 9 0 11-3.2-6.9M21 4v5h-5"/></svg> <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12a9 9 0 11-3.2-6.9M21 4v5h-5"/></svg>
+12 -5
View File
@@ -17,6 +17,13 @@
</div> </div>
</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 style="display:flex; flex-direction:column; gap:16px;">
<div class="card card-pad"> <div class="card card-pad">
@@ -44,7 +51,7 @@
<td class="mono">{{ d['ip'] }}</td> <td class="mono">{{ d['ip'] }}</td>
<td class="text-faint" style="font-size:12px;">{{ d['deleted_at'] }}</td> <td class="text-faint" style="font-size:12px;">{{ d['deleted_at'] }}</td>
<td> <td>
{% if current_user.has_permission('papierkorb.edit') %} {% if current_user.has_permission('papierkorb.edit') and license_active() %}
<div class="row-actions"> <div class="row-actions">
<form method="post" action="{{ url_for('restore_device', mac=d['mac']) }}"> <form method="post" action="{{ url_for('restore_device', mac=d['mac']) }}">
<button type="submit" class="btn btn-sm btn-secondary">Wiederherstellen</button> <button type="submit" class="btn btn-sm btn-secondary">Wiederherstellen</button>
@@ -92,7 +99,7 @@
<td class="mono">{{ s['ip'] }}</td> <td class="mono">{{ s['ip'] }}</td>
<td class="text-faint" style="font-size:12px;">{{ s['deleted_at'] }}</td> <td class="text-faint" style="font-size:12px;">{{ s['deleted_at'] }}</td>
<td> <td>
{% if current_user.has_permission('papierkorb.edit') %} {% if current_user.has_permission('papierkorb.edit') and license_active() %}
<div class="row-actions"> <div class="row-actions">
<form method="post" action="{{ url_for('restore_switch', hostname=s['hostname']) }}"> <form method="post" action="{{ url_for('restore_switch', hostname=s['hostname']) }}">
<button type="submit" class="btn btn-sm btn-secondary">Wiederherstellen</button> <button type="submit" class="btn btn-sm btn-secondary">Wiederherstellen</button>
@@ -140,7 +147,7 @@
<td class="mono">{{ c['username'] }}</td> <td class="mono">{{ c['username'] }}</td>
<td class="text-faint" style="font-size:12px;">{{ c['deleted_at'] }}</td> <td class="text-faint" style="font-size:12px;">{{ c['deleted_at'] }}</td>
<td> <td>
{% if current_user.has_permission('papierkorb.edit') %} {% if current_user.has_permission('papierkorb.edit') and license_active() %}
<div class="row-actions"> <div class="row-actions">
<form method="post" action="{{ url_for('restore_credential', cred_id=c['id']) }}"> <form method="post" action="{{ url_for('restore_credential', cred_id=c['id']) }}">
<button type="submit" class="btn btn-sm btn-secondary">Wiederherstellen</button> <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-dim">{{ full_name or '—' }}</td>
<td class="text-faint" style="font-size:12px;">{{ u['deleted_at'] }}</td> <td class="text-faint" style="font-size:12px;">{{ u['deleted_at'] }}</td>
<td> <td>
{% if current_user.has_permission('papierkorb.edit') %} {% if current_user.has_permission('papierkorb.edit') and license_active() %}
<div class="row-actions"> <div class="row-actions">
<form method="post" action="{{ url_for('restore_user', user_id=u['id']) }}"> <form method="post" action="{{ url_for('restore_user', user_id=u['id']) }}">
<button type="submit" class="btn btn-sm btn-secondary">Wiederherstellen</button> <button type="submit" class="btn btn-sm btn-secondary">Wiederherstellen</button>
@@ -235,7 +242,7 @@
<td class="cell-name">{{ g['name'] }}</td> <td class="cell-name">{{ g['name'] }}</td>
<td class="text-faint" style="font-size:12px;">{{ g['deleted_at'] }}</td> <td class="text-faint" style="font-size:12px;">{{ g['deleted_at'] }}</td>
<td> <td>
{% if current_user.has_permission('papierkorb.edit') %} {% if current_user.has_permission('papierkorb.edit') and license_active() %}
<div class="row-actions"> <div class="row-actions">
<form method="post" action="{{ url_for('restore_group', group_id=g['id']) }}"> <form method="post" action="{{ url_for('restore_group', group_id=g['id']) }}">
<button type="submit" class="btn btn-sm btn-secondary">Wiederherstellen</button> <button type="submit" class="btn btn-sm btn-secondary">Wiederherstellen</button>
+29 -24
View File
@@ -4,6 +4,7 @@
{% block page_sub %}<div class="topbar-sub">Prüfintervall und Netzwerkkonfiguration dieses Hosts</div>{% endblock %} {% block page_sub %}<div class="topbar-sub">Prüfintervall und Netzwerkkonfiguration dieses Hosts</div>{% endblock %}
{% block content %} {% block content %}
{% import "_hint_icon.html" as hi %}
<div class="settings-grid"> <div class="settings-grid">
<div class="card card-pad"> <div class="card card-pad">
@@ -13,7 +14,8 @@
<div class="hint">Name und Zeitzone dieses Hosts.</div> <div class="hint">Name und Zeitzone dieses Hosts.</div>
</div> </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"> <form method="post">
<div class="field"> <div class="field">
<label for="hostname">Hostname</label> <label for="hostname">Hostname</label>
@@ -27,31 +29,18 @@
<hr style="border:none; border-top:1px solid var(--border-soft); margin:18px 0;"> <hr style="border:none; border-top:1px solid var(--border-soft); margin:18px 0;">
<form method="post"> <form method="post">
<div class="field"> <div class="field">
<label for="timezone">Zeitzone</label> <label for="timezone">Zeitzone {{ hi.hint_icon("Bestimmt die lokale Zeit in allen Logs und im Änderungsverlauf — wirkt sofort für diese laufende App-Instanz, ohne Dienst-Neustart.", "Zeitzone") }}</label>
<select name="timezone" id="timezone" required> <select name="timezone" id="timezone" required>
{% for tz in timezones %} {% for tz in timezones %}
<option value="{{ tz }}" {% if tz == current_timezone %}selected{% endif %}>{{ tz }}</option> <option value="{{ tz }}" {% if tz == current_timezone %}selected{% endif %}>{{ tz }}</option>
{% endfor %} {% endfor %}
</select> </select>
<div class="field-hint">Bestimmt die lokale Zeit in allen Logs und im Änderungsverlauf — wirkt sofort für diese laufende App-Instanz, ohne Dienst-Neustart.</div>
</div> </div>
<button type="submit" class="btn btn-primary btn-block"> <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> <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>
Zeitzone setzen Zeitzone setzen
</button> </button>
</form> </form>
<hr style="border:none; border-top:1px solid var(--border-soft); margin:18px 0;">
<form method="post">
<div class="field">
<label for="interval">Prüfintervall (Minuten)</label>
<input type="number" name="interval" id="interval" value="{{ interval }}" min="1" required>
<div class="field-hint">Wie oft Geräte auf Erreichbarkeit geprüft werden. Der Hintergrund-Dienst (tesm-check.service) wird nach dem Speichern automatisch neu gestartet.</div>
</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>
Speichern & Service neustarten
</button>
</form>
{% else %} {% else %}
<div class="field"> <div class="field">
<label>Hostname</label> <label>Hostname</label>
@@ -61,10 +50,30 @@
<label>Zeitzone</label> <label>Zeitzone</label>
<input type="text" value="{{ current_timezone or '' }}" disabled> <input type="text" value="{{ current_timezone or '' }}" disabled>
</div> </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>
<input type="number" name="interval" id="interval" value="{{ interval }}" min="1" required>
</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>
Speichern & Service neustarten
</button>
</form>
{% else %}
<div class="field"> <div class="field">
<label>Prüfintervall (Minuten)</label> <label>Prüfintervall (Minuten)</label>
<input type="number" value="{{ interval }}" disabled> <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> </div>
{% endif %} {% endif %}
</div> </div>
@@ -118,13 +127,12 @@
{% elif net_backend != 'unknown' and current_user.has_permission('settings_system.edit') %} {% elif net_backend != 'unknown' and current_user.has_permission('settings_system.edit') %}
<form method="post" data-confirm="Netzwerkkonfiguration wirklich ändern? Falls die Verbindung danach abbricht, wird die vorherige Konfiguration automatisch nach {{ net_revert_seconds }} Sekunden wiederhergestellt."> <form method="post" data-confirm="Netzwerkkonfiguration wirklich ändern? Falls die Verbindung danach abbricht, wird die vorherige Konfiguration automatisch nach {{ net_revert_seconds }} Sekunden wiederhergestellt.">
<input type="hidden" name="apply_network" value="1"> <input type="hidden" name="apply_network" value="1">
<div class="field"><label>Interface</label> <div class="field"><label>Interface {{ hi.hint_icon("Jedes Interface hat seine eigene Konfiguration — ein Wechsel hier lädt unten dessen tatsächlichen Ist-Zustand, ändert aber noch nichts.", "Interface") }}</label>
<select name="net_interface" id="netInterfaceSelect" onchange="loadNetworkState(this.value)"> <select name="net_interface" id="netInterfaceSelect" onchange="loadNetworkState(this.value)">
{% for iface in net_interfaces %} {% for iface in net_interfaces %}
<option value="{{ iface }}" {% if iface == net_interface %}selected{% endif %}>{{ iface }}</option> <option value="{{ iface }}" {% if iface == net_interface %}selected{% endif %}>{{ iface }}</option>
{% endfor %} {% endfor %}
</select> </select>
<div class="field-hint">Jedes Interface hat seine eigene Konfiguration — ein Wechsel hier lädt unten dessen tatsächlichen Ist-Zustand, ändert aber noch nichts.</div>
</div> </div>
<div class="field"><label>Modus</label> <div class="field"><label>Modus</label>
<select name="net_mode" id="netModeSelect" onchange="document.getElementById('netStaticFields').classList.toggle('hidden', this.value !== 'static')"> <select name="net_mode" id="netModeSelect" onchange="document.getElementById('netStaticFields').classList.toggle('hidden', this.value !== 'static')">
@@ -143,9 +151,8 @@
<input type="text" name="net_gateway" id="netGatewayInput" value="{{ net_state.gateway if net_state.mode == 'static' else '' }}" placeholder="z.B. 192.168.1.1"> <input type="text" name="net_gateway" id="netGatewayInput" value="{{ net_state.gateway if net_state.mode == 'static' else '' }}" placeholder="z.B. 192.168.1.1">
</div> </div>
</div> </div>
<div class="field"><label>DNS-Server</label> <div class="field"><label>DNS-Server {{ hi.hint_icon("Kommagetrennt. Leer lassen, um die per DHCP zugewiesenen DNS-Server zu verwenden.", "DNS-Server") }}</label>
<input type="text" name="net_dns" id="netDnsInput" value="{{ net_state.dns|join(', ') if net_state and net_state.dns else '' }}" placeholder="z.B. 1.1.1.1, 8.8.8.8"> <input type="text" name="net_dns" id="netDnsInput" value="{{ net_state.dns|join(', ') if net_state and net_state.dns else '' }}" placeholder="z.B. 1.1.1.1, 8.8.8.8">
<div class="field-hint">Kommagetrennt. Leer lassen, um die per DHCP zugewiesenen DNS-Server zu verwenden.</div>
</div> </div>
<button type="submit" class="btn btn-primary btn-block"> <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> <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>
@@ -184,9 +191,8 @@
</select> </select>
</div> </div>
<div class="field"> <div class="field">
<label for="log_rotation_keep">Aufbewahrung (Anzahl Rotationen)</label> <label for="log_rotation_keep">Aufbewahrung (Anzahl Rotationen) {{ hi.hint_icon("Standard: wöchentlich, 4 Rotationen (≈ 1 Monat Historie je Log).", "Aufbewahrung (Anzahl Rotationen)") }}</label>
<input type="number" name="log_rotation_keep" id="log_rotation_keep" value="{{ log_rotation_keep }}" min="1" required> <input type="number" name="log_rotation_keep" id="log_rotation_keep" value="{{ log_rotation_keep }}" min="1" required>
<div class="field-hint">Standard: wöchentlich, 4 Rotationen (≈ 1 Monat Historie je Log).</div>
</div> </div>
<button type="submit" class="btn btn-primary btn-block"> <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> <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>
@@ -221,9 +227,8 @@
{% if current_user.has_permission('settings_system.edit') %} {% if current_user.has_permission('settings_system.edit') %}
<form method="post"> <form method="post">
<div class="field"> <div class="field">
<label for="trash_retention_days">Aufbewahrungsdauer (Tage)</label> <label for="trash_retention_days">Aufbewahrungsdauer (Tage) {{ hi.hint_icon("Danach werden Papierkorb-Einträge automatisch unwiderruflich gelöscht (AD/LDAP-Benutzer sind nie im Papierkorb, da sie sich beim nächsten Login automatisch neu anlegen).", "Aufbewahrungsdauer (Tage)") }}</label>
<input type="number" name="trash_retention_days" id="trash_retention_days" value="{{ trash_retention_days }}" min="1" required> <input type="number" name="trash_retention_days" id="trash_retention_days" value="{{ trash_retention_days }}" min="1" required>
<div class="field-hint">Danach werden Papierkorb-Einträge automatisch unwiderruflich gelöscht (AD/LDAP-Benutzer sind nie im Papierkorb, da sie sich beim nächsten Login automatisch neu anlegen).</div>
</div> </div>
<button type="submit" name="save_trash_retention" value="1" class="btn btn-primary btn-block"> <button type="submit" name="save_trash_retention" value="1" 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> <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>
+15 -32
View File
@@ -5,6 +5,7 @@
{% block page_sub %}<div class="topbar-sub">Reservierungen und eigene Options aus den Client-Stammdaten (Kea DHCP)</div>{% endblock %} {% block page_sub %}<div class="topbar-sub">Reservierungen und eigene Options aus den Client-Stammdaten (Kea DHCP)</div>{% endblock %}
{% block content %} {% block content %}
{% import "_hint_icon.html" as hi %}
<div class="settings-grid"> <div class="settings-grid">
<div class="card card-pad"> <div class="card card-pad">
@@ -81,9 +82,8 @@
<div class="field"><label>Lease-Zeit Maximum (Sek.)</label> <div class="field"><label>Lease-Zeit Maximum (Sek.)</label>
<input type="number" name="dhcp_lease_max" value="{{ cfg.dhcp_lease_max }}"> <input type="number" name="dhcp_lease_max" value="{{ cfg.dhcp_lease_max }}">
</div> </div>
<div class="field"><label>Ausgabepfad (Kea-Konfigurationsdatei)</label> <div class="field"><label>Ausgabepfad (Kea-Konfigurationsdatei) {{ hi.hint_icon("Standardmäßig die aktive Kea-Konfiguration. Läuft der Dienst gerade, schreibt „Speichern“ die Datei neu und startet ihn automatisch neu.", "Ausgabepfad (Kea-Konfigurationsdatei)") }}</label>
<input type="text" name="dhcp_output_path" value="{{ cfg.dhcp_output_path }}" class="mono"> <input type="text" name="dhcp_output_path" value="{{ cfg.dhcp_output_path }}" class="mono">
<div class="field-hint">Standardmäßig die aktive Kea-Konfiguration. Läuft der Dienst gerade, schreibt „Speichern“ die Datei neu und startet ihn automatisch neu.</div>
</div> </div>
<button type="submit" class="btn btn-primary btn-block"> <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> <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>
@@ -98,11 +98,7 @@
<div class="card card-pad" style="grid-column:1 / -1;"> <div class="card card-pad" style="grid-column:1 / -1;">
<div class="section-head" style="margin-bottom:16px;"> <div class="section-head" style="margin-bottom:16px;">
<div> <div>
<h2 style="font-size:16px;">Subnetze</h2> <h2 style="font-size:16px;">Subnetze {{ hi.hint_icon("Ein Host kann mehrere IPs/Interfaces mit jeweils eigenem Netz bedienen — je Subnetz eigene Range, optional eigenes Gateway/DNS. Subnet/Netzmaske werden live vom Interface übernommen, nicht manuell gepflegt.", "Subnetze") }}</h2>
<div class="hint">
Ein Host kann mehrere IPs/Interfaces mit jeweils eigenem Netz bedienen — je Subnetz eigene Range,
optional eigenes Gateway/DNS. Subnet/Netzmaske werden live vom Interface übernommen, nicht manuell gepflegt.
</div>
</div> </div>
{% if can_edit %} {% if can_edit %}
<button type="button" class="btn btn-primary" data-open-modal="addSubnetModal"> <button type="button" class="btn btn-primary" data-open-modal="addSubnetModal">
@@ -175,11 +171,7 @@
<div class="card card-pad" style="grid-column:1 / -1;"> <div class="card card-pad" style="grid-column:1 / -1;">
<div class="section-head" style="margin-bottom:16px;"> <div class="section-head" style="margin-bottom:16px;">
<div> <div>
<h2 style="font-size:16px;">DHCP-Options</h2> <h2 style="font-size:16px;">DHCP-Options {{ hi.hint_icon('Standard-Optionen (analog den "Predefined Options" eines Windows-DHCP-Servers) sind bereits vorbefüllt, eigene/herstellerspezifische Options lassen sich zusätzlich anlegen — beide global oder pro Client überschreibbar.', "DHCP-Options") }}</h2>
<div class="hint">
Standard-Optionen (analog den "Predefined Options" eines Windows-DHCP-Servers) sind bereits vorbefüllt,
eigene/herstellerspezifische Options lassen sich zusätzlich anlegen — beide global oder pro Client überschreibbar.
</div>
</div> </div>
{% if can_edit %} {% if can_edit %}
<button type="button" class="btn btn-primary" data-open-modal="addOptionModal"> <button type="button" class="btn btn-primary" data-open-modal="addOptionModal">
@@ -217,7 +209,9 @@
<input type="text" name="value" value="{{ option_values.get(d.id, {}).get('', '') }}" style="max-width:220px;" placeholder="(nicht gesetzt)"> <input type="text" name="value" value="{{ option_values.get(d.id, {}).get('', '') }}" style="max-width:220px;" placeholder="(nicht gesetzt)">
<button type="submit" class="btn btn-secondary btn-sm">Speichern</button> <button type="submit" class="btn btn-secondary btn-sm">Speichern</button>
</form> </form>
<div class="field-hint">Leeren Wert speichern, um eine Standard-Option wieder auszublenden.</div> <div class="text-faint" style="font-size:11.5px; display:flex; align-items:center; gap:6px;">
Standard-Option zurücksetzen {{ hi.hint_icon("Leeren Wert speichern, um eine Standard-Option wieder auszublenden.", "Standard-Option zurücksetzen") }}
</div>
{% else %} {% else %}
{{ option_values.get(d.id, {}).get('', '—') }} {{ option_values.get(d.id, {}).get('', '—') }}
{% endif %} {% endif %}
@@ -272,11 +266,7 @@
<div class="card card-pad" style="grid-column:1 / -1;"> <div class="card card-pad" style="grid-column:1 / -1;">
<div class="section-head" style="margin-bottom:16px;"> <div class="section-head" style="margin-bottom:16px;">
<div> <div>
<h2 style="font-size:16px;">Aktive Leases</h2> <h2 style="font-size:16px;">Aktive Leases {{ hi.hint_icon("Direkt aus Kea gelesen (nicht aus den Reservierungen) — zeigt auch Clients OHNE eigene Reservierung, die sich einfach eine freie IP aus dem Pool genommen haben.", "Aktive Leases") }}</h2>
<div class="hint">
Direkt aus Kea gelesen (nicht aus den Reservierungen) — zeigt auch Clients OHNE eigene Reservierung,
die sich einfach eine freie IP aus dem Pool genommen haben.
</div>
</div> </div>
</div> </div>
@@ -497,9 +487,8 @@
<div class="field"><label>Code</label> <div class="field"><label>Code</label>
<input type="number" name="code" min="1" max="254" required placeholder="z.B. 225"> <input type="number" name="code" min="1" max="254" required placeholder="z.B. 225">
</div> </div>
<div class="field"><label>Name</label> <div class="field"><label>Name {{ hi.hint_icon("Nur Kleinbuchstaben, Ziffern und Bindestrich, muss mit einem Buchstaben beginnen.", "Name") }}</label>
<input type="text" name="name" required placeholder="z.B. terminal-url" pattern="[a-z][a-z0-9-]*"> <input type="text" name="name" required placeholder="z.B. terminal-url" pattern="[a-z][a-z0-9-]*">
<div class="field-hint">Nur Kleinbuchstaben, Ziffern und Bindestrich, muss mit einem Buchstaben beginnen.</div>
</div> </div>
<div class="field"><label>Datentyp</label> <div class="field"><label>Datentyp</label>
<select name="type"> <select name="type">
@@ -539,13 +528,11 @@
<div class="field"><label>MAC-Adresse</label> <div class="field"><label>MAC-Adresse</label>
<input type="text" name="mac" required placeholder="z.B. AA:BB:CC:DD:EE:FF"> <input type="text" name="mac" required placeholder="z.B. AA:BB:CC:DD:EE:FF">
</div> </div>
<div class="field"><label>IP-Adresse</label> <div class="field"><label>IP-Adresse {{ hi.hint_icon("Muss in einem der oben konfigurierten Subnetze liegen, sonst wird sie beim Schreiben übersprungen.", "IP-Adresse") }}</label>
<input type="text" name="ip" required placeholder="z.B. 192.168.1.50"> <input type="text" name="ip" required placeholder="z.B. 192.168.1.50">
<div class="field-hint">Muss in einem der oben konfigurierten Subnetze liegen, sonst wird sie beim Schreiben übersprungen.</div>
</div> </div>
<div class="field"><label>Name</label> <div class="field"><label>Name {{ hi.hint_icon("Wird zum Hostname der Reservierung. DHCP-Optionen (z.B. Hostname erzwingen) lassen sich danach über das Options-Symbol in der Tabelle setzen.", "Name") }}</label>
<input type="text" name="name" required placeholder="z.B. Empfangsdrucker"> <input type="text" name="name" required placeholder="z.B. Empfangsdrucker">
<div class="field-hint">Wird zum Hostname der Reservierung. DHCP-Optionen (z.B. Hostname erzwingen) lassen sich danach über das Options-Symbol in der Tabelle setzen.</div>
</div> </div>
</div> </div>
<div class="modal-footer"> <div class="modal-footer">
@@ -575,16 +562,14 @@
<div class="field"><label>Range Start</label> <div class="field"><label>Range Start</label>
<input type="text" name="range_start" required placeholder="z.B. 192.168.1.100"> <input type="text" name="range_start" required placeholder="z.B. 192.168.1.100">
</div> </div>
<div class="field"><label>Range Ende</label> <div class="field"><label>Range Ende {{ hi.hint_icon("Muss im tatsächlich am Interface erkannten Netz liegen — sonst wird das Subnetz abgelehnt.", "Range Ende") }}</label>
<input type="text" name="range_end" required placeholder="z.B. 192.168.1.200"> <input type="text" name="range_end" required placeholder="z.B. 192.168.1.200">
<div class="field-hint">Muss im tatsächlich am Interface erkannten Netz liegen — sonst wird das Subnetz abgelehnt.</div>
</div> </div>
<div class="field"><label>Gateway (optional)</label> <div class="field"><label>Gateway (optional)</label>
<input type="text" name="gateway" placeholder="Automatisch: erkanntes Gateway des Interfaces"> <input type="text" name="gateway" placeholder="Automatisch: erkanntes Gateway des Interfaces">
</div> </div>
<div class="field"><label>DNS-Server (optional)</label> <div class="field"><label>DNS-Server (optional) {{ hi.hint_icon("Kommagetrennt, falls mehrere.", "DNS-Server (optional)") }}</label>
<input type="text" name="dns" placeholder="z.B. 1.1.1.1, 8.8.8.8"> <input type="text" name="dns" placeholder="z.B. 1.1.1.1, 8.8.8.8">
<div class="field-hint">Kommagetrennt, falls mehrere.</div>
</div> </div>
</div> </div>
<div class="modal-footer"> <div class="modal-footer">
@@ -617,16 +602,14 @@
<div class="field"><label>Range Start</label> <div class="field"><label>Range Start</label>
<input type="text" name="range_start" value="{{ s.range_start }}" required> <input type="text" name="range_start" value="{{ s.range_start }}" required>
</div> </div>
<div class="field"><label>Range Ende</label> <div class="field"><label>Range Ende {{ hi.hint_icon("Muss im tatsächlich am Interface erkannten Netz liegen — sonst wird das Subnetz abgelehnt.", "Range Ende") }}</label>
<input type="text" name="range_end" value="{{ s.range_end }}" required> <input type="text" name="range_end" value="{{ s.range_end }}" required>
<div class="field-hint">Muss im tatsächlich am Interface erkannten Netz liegen — sonst wird das Subnetz abgelehnt.</div>
</div> </div>
<div class="field"><label>Gateway (optional)</label> <div class="field"><label>Gateway (optional)</label>
<input type="text" name="gateway" value="{{ s.gateway or '' }}" placeholder="Automatisch: erkanntes Gateway des Interfaces"> <input type="text" name="gateway" value="{{ s.gateway or '' }}" placeholder="Automatisch: erkanntes Gateway des Interfaces">
</div> </div>
<div class="field"><label>DNS-Server (optional)</label> <div class="field"><label>DNS-Server (optional) {{ hi.hint_icon("Kommagetrennt, falls mehrere.", "DNS-Server (optional)") }}</label>
<input type="text" name="dns" value="{{ s.dns or '' }}" placeholder="z.B. 1.1.1.1, 8.8.8.8"> <input type="text" name="dns" value="{{ s.dns or '' }}" placeholder="z.B. 1.1.1.1, 8.8.8.8">
<div class="field-hint">Kommagetrennt, falls mehrere.</div>
</div> </div>
</div> </div>
<div class="modal-footer"> <div class="modal-footer">
+7 -14
View File
@@ -4,6 +4,7 @@
{% block page_sub %}<div class="topbar-sub">Umzug auf eine neue Umgebung</div>{% endblock %} {% block page_sub %}<div class="topbar-sub">Umzug auf eine neue Umgebung</div>{% endblock %}
{% block content %} {% block content %}
{% import "_hint_icon.html" as hi %}
<div class="settings-grid"> <div class="settings-grid">
<div class="card card-pad"> <div class="card card-pad">
@@ -34,14 +35,10 @@
{% if s.key not in admin_only_sections or current_user.is_admin %}checked{% endif %} {% if s.key not in admin_only_sections or current_user.is_admin %}checked{% endif %}
{% if s.admin_only and not current_user.is_admin %}disabled{% endif %}> {% if s.admin_only and not current_user.is_admin %}disabled{% endif %}>
{{ s.label }} <span class="text-faint">({{ s.count }})</span> {{ s.label }} <span class="text-faint">({{ s.count }})</span>
{% if s.admin_only %}<span class="pill user" style="font-size:10px; padding:2px 7px;">Nur Admin</span>{% endif %} {% if export_section_hints and s.key in export_section_hints %}{{ hi.hint_icon(export_section_hints[s.key], s.label) }}{% endif %}
</label> </label>
{% endfor %} {% endfor %}
</div> </div>
<div class="field-hint">
AD/LDAP-Benutzerkonten sind hiervon unberührt — sie werden über Active Directory/Windows verwaltet,
nicht über diese App, und beim nächsten Login automatisch neu angelegt.
</div>
</div> </div>
<button type="submit" class="btn btn-primary btn-block"> <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> <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>
@@ -74,7 +71,7 @@
<div class="hint">Ausgewählte Kategorien verschlüsselt sichern.</div> <div class="hint">Ausgewählte Kategorien verschlüsselt sichern.</div>
</div> </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') }}"> <form method="post" action="{{ url_for('export_data') }}">
<div class="field"> <div class="field">
<label>Was exportieren?</label> <label>Was exportieren?</label>
@@ -85,26 +82,22 @@
{% if key not in admin_only_sections or current_user.is_admin %}checked{% endif %} {% if key not in admin_only_sections or current_user.is_admin %}checked{% endif %}
{% if key in admin_only_sections and not current_user.is_admin %}disabled{% endif %}> {% if key in admin_only_sections and not current_user.is_admin %}disabled{% endif %}>
{{ label }} {{ label }}
{% if key in admin_only_sections %}<span class="pill user" style="font-size:10px; padding:2px 7px;">Nur Admin</span>{% endif %} {% if export_section_hints and key in export_section_hints %}{{ hi.hint_icon(export_section_hints[key], label) }}{% endif %}
</label> </label>
{% endfor %} {% endfor %}
</div> </div>
<div class="field-hint">
AD/LDAP-Benutzerkonten werden nie mitexportiert — sie werden über Active Directory/Windows verwaltet
(nicht über diese App) und legen sich beim nächsten Login automatisch wieder an. Beim LDAP-Export wird
lediglich das Bind-Konto (verschlüsselt) sowie die Gruppenzuordnungen gesichert.
</div>
</div> </div>
<div class="field"> <div class="field">
<label for="export_passphrase">Passphrase</label> <label for="export_passphrase">Passphrase {{ hi.hint_icon("Wird zum Verschlüsseln der Export-Datei benötigt — für den späteren Import dieselbe Passphrase erneut eingeben.", "Passphrase") }}</label>
<input type="password" name="export_passphrase" id="export_passphrase" required> <input type="password" name="export_passphrase" id="export_passphrase" required>
<div class="field-hint">Wird zum Verschlüsseln der Export-Datei benötigt — für den späteren Import dieselbe Passphrase erneut eingeben.</div>
</div> </div>
<button type="submit" class="btn btn-secondary btn-block"> <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="M7 10l5 5 5-5"/><path d="M12 15V3"/></svg> <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="M7 10l5 5 5-5"/><path d="M12 15V3"/></svg>
Export herunterladen Export herunterladen
</button> </button>
</form> </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 %} {% else %}
<p class="text-faint" style="font-size:12.5px;">Für den Export fehlt das Recht „Daten exportieren“.</p> <p class="text-faint" style="font-size:12.5px;">Für den Export fehlt das Recht „Daten exportieren“.</p>
{% endif %} {% endif %}
+21 -35
View File
@@ -1,10 +1,11 @@
{% extends "base.html" %} {% extends "base.html" %}
{% set active_page = "settings_ldap" %} {% 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_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 %} {% block page_sub %}<div class="topbar-sub">Anmeldung mit dem Domänen-Passwort, zusätzlich zu lokalen Konten</div>{% endblock %}
{% block content %} {% block content %}
{% import "_hint_icon.html" as hi %}
<div class="settings-grid settings-grid--wide"> <div class="settings-grid settings-grid--wide">
<div class="card card-pad"> <div class="card card-pad">
@@ -23,18 +24,16 @@
<span>LDAP-Anmeldung aktivieren</span> <span>LDAP-Anmeldung aktivieren</span>
</label> </label>
</div> </div>
<div class="field"><label>Server</label> <div class="field"><label>Server {{ hi.hint_icon("DNS-Name oder IP-Adresse — beides wird genau so gespeichert und beim Verbinden verwendet.", "Server") }}</label>
<input type="text" name="ldap_server" value="{{ ldap.server }}" placeholder="z.B. 192.168.1.1 oder dc01.firma.local"> <input type="text" name="ldap_server" value="{{ ldap.server }}" placeholder="z.B. 192.168.1.1 oder dc01.firma.local">
<div class="field-hint">DNS-Name oder IP-Adresse — beides wird genau so gespeichert und beim Verbinden verwendet.</div>
</div> </div>
<div class="field"> <div class="field">
<label class="switch-check" style="display:inline-flex; align-items:center; gap:8px;"> <label class="switch-check" style="display:inline-flex; align-items:center; gap:8px;">
<input type="checkbox" name="ldap_use_ssl" id="ldap_use_ssl" {% if ldap.use_ssl %}checked{% endif %} <input type="checkbox" name="ldap_use_ssl" id="ldap_use_ssl" {% if ldap.use_ssl %}checked{% endif %}
onchange="document.getElementById('ldap_port').value = this.checked ? 636 : 389;"> onchange="document.getElementById('ldap_port').value = this.checked ? 636 : 389;">
<span class="track"></span> <span class="track"></span>
<span>LDAPS/TLS verwenden</span> <span>LDAPS/TLS verwenden {{ hi.hint_icon("Ohne LDAPS wird das Passwort unverschlüsselt übertragen — nur für interne Tests geeignet, vor Produktivbetrieb LDAPS auf dem Domain Controller einrichten. Stellt beim Umschalten den Port automatisch auf 636/389 — unten weiterhin manuell änderbar.", "LDAPS/TLS verwenden") }}</span>
</label> </label>
<div class="field-hint">Ohne LDAPS wird das Passwort unverschlüsselt übertragen — nur für interne Tests geeignet, vor Produktivbetrieb LDAPS auf dem Domain Controller einrichten. Stellt beim Umschalten den Port automatisch auf 636/389 — unten weiterhin manuell änderbar.</div>
</div> </div>
<div class="field"><label>Port</label> <div class="field"><label>Port</label>
<input type="number" name="ldap_port" id="ldap_port" min="1" max="65535" value="{{ ldap.port }}"> <input type="number" name="ldap_port" id="ldap_port" min="1" max="65535" value="{{ ldap.port }}">
@@ -43,13 +42,11 @@
<label class="switch-check" style="display:inline-flex; align-items:center; gap:8px;"> <label class="switch-check" style="display:inline-flex; align-items:center; gap:8px;">
<input type="checkbox" name="ldap_tls_skip_verify" {% if ldap.tls_skip_verify %}checked{% endif %}> <input type="checkbox" name="ldap_tls_skip_verify" {% if ldap.tls_skip_verify %}checked{% endif %}>
<span class="track"></span> <span class="track"></span>
<span>Zertifikatsprüfung überspringen (nur LDAPS)</span> <span>Zertifikatsprüfung überspringen (nur LDAPS) {{ hi.hint_icon("Akzeptiert jedes Server-Zertifikat, auch selbstsignierte/nicht vertrauenswürdige — praktisch für interne Tests, schützt dann aber nicht mehr vor einem gefälschten Server. Vor Produktivbetrieb ein echtes, vertrauenswürdiges Zertifikat einrichten und diese Option deaktivieren.", "Zertifikatsprüfung überspringen") }}</span>
</label> </label>
<div class="field-hint">Akzeptiert jedes Server-Zertifikat, auch selbstsignierte/nicht vertrauenswürdige — praktisch für interne Tests, schützt dann aber nicht mehr vor einem gefälschten Server. Vor Produktivbetrieb ein echtes, vertrauenswürdiges Zertifikat einrichten und diese Option deaktivieren.</div>
</div> </div>
<div class="field"><label>Bind-Konto (Service-Account)</label> <div class="field"><label>Bind-Konto (Service-Account) {{ hi.hint_icon("Ein normales, unprivilegiertes Domänenkonto reicht — es wird nur zum Suchen von Benutzern verwendet, keine Admin-Rechte nötig. Ein neu gespeichertes Konto ersetzt das bisherige vollständig.", "Bind-Konto (Service-Account)") }}</label>
<input type="text" name="ldap_bind_dn" value="{{ ldap.bind_dn }}" placeholder="z.B. ldap@ad.firma.local"> <input type="text" name="ldap_bind_dn" value="{{ ldap.bind_dn }}" placeholder="z.B. ldap@ad.firma.local">
<div class="field-hint">Ein normales, unprivilegiertes Domänenkonto reicht — es wird nur zum Suchen von Benutzern verwendet, keine Admin-Rechte nötig. Ein neu gespeichertes Konto ersetzt das bisherige vollständig.</div>
</div> </div>
<div class="field"><label>Bind-Passwort</label> <div class="field"><label>Bind-Passwort</label>
<input type="password" name="ldap_bind_password" placeholder="{{ '(unverändert lassen)' if ldap.bind_password_enc else '' }}"> <input type="password" name="ldap_bind_password" placeholder="{{ '(unverändert lassen)' if ldap.bind_password_enc else '' }}">
@@ -57,18 +54,16 @@
<div class="field"><label>Base-DN</label> <div class="field"><label>Base-DN</label>
<input type="text" name="ldap_base_dn" value="{{ ldap.base_dn }}" placeholder="Leer = automatisch ermitteln"> <input type="text" name="ldap_base_dn" value="{{ ldap.base_dn }}" placeholder="Leer = automatisch ermitteln">
</div> </div>
<div class="field"><label>Attribut für Benutzername</label> <div class="field"><label>Attribut für Benutzername {{ hi.hint_icon("Für Active Directory: sAMAccountName. Für generisches LDAP (z.B. OpenLDAP): meist uid. Anmeldung per userPrincipalName (E-Mail/UPN) funktioniert unabhängig davon immer zusätzlich.", "Attribut für Benutzername") }}</label>
<input type="text" name="ldap_user_filter_attr" value="{{ ldap.filter_attr }}" placeholder="sAMAccountName"> <input type="text" name="ldap_user_filter_attr" value="{{ ldap.filter_attr }}" placeholder="sAMAccountName">
<div class="field-hint">Für Active Directory: sAMAccountName. Für generisches LDAP (z.B. OpenLDAP): meist uid. Anmeldung per userPrincipalName (E-Mail/UPN) funktioniert unabhängig davon immer zusätzlich.</div>
</div> </div>
<div class="field"><label>Standardgruppe für neue AD-Benutzer</label> <div class="field"><label>Standardgruppe für neue AD-Benutzer {{ hi.hint_icon("Wird nur zugewiesen, wenn unten keine AD-Gruppenzuordnung greift — siehe Karte „AD-Gruppenzuordnungen“.", "Standardgruppe für neue AD-Benutzer") }}</label>
<select name="ldap_default_group"> <select name="ldap_default_group">
<option value="">Systemstandard ({{ ldap_groups|selectattr('is_default')|map(attribute='name')|first or 'Benutzer' }})</option> <option value="">Systemstandard ({{ ldap_groups|selectattr('is_default')|map(attribute='name')|first or 'Benutzer' }})</option>
{% for g in ldap_groups %} {% for g in ldap_groups %}
<option value="{{ g['id'] }}" {% if ldap.default_group == g['id']|string %}selected{% endif %}>{{ g['name'] }}</option> <option value="{{ g['id'] }}" {% if ldap.default_group == g['id']|string %}selected{% endif %}>{{ g['name'] }}</option>
{% endfor %} {% endfor %}
</select> </select>
<div class="field-hint">Wird nur zugewiesen, wenn unten keine AD-Gruppenzuordnung greift — siehe Karte „AD-Gruppenzuordnungen“.</div>
</div> </div>
<div class="field"><label>Erforderliche AD-Gruppe für Login (optional)</label> <div class="field"><label>Erforderliche AD-Gruppe für Login (optional)</label>
<div class="flex gap-2"> <div class="flex gap-2">
@@ -101,19 +96,20 @@
</form> </form>
{% endif %} {% endif %}
{% else %} {% 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 %} {% endif %}
</div> </div>
<div class="card card-pad"> <div class="card card-pad">
<div class="section-head" style="margin-bottom:16px;"> <div class="section-head" style="margin-bottom:16px;">
<div> <div>
<h2 style="font-size:16px;">AD-Gruppenzuordnungen</h2> <h2 style="font-size:16px;">AD-Gruppenzuordnungen {{ hi.hint_icon("Ist ein AD-Benutzer (rekursiv, auch über verschachtelte Gruppen) Mitglied einer hier zugeordneten AD-Gruppe, erhält er beim Login zusätzlich die zugeordnete App-Rechtegruppe — additiv, mehrere Zuordnungen können gleichzeitig greifen. Wird keine Zuordnung getroffen, gilt die Standardgruppe oben.", "AD-Gruppenzuordnungen") }}</h2>
<div class="hint">
Ist ein AD-Benutzer (rekursiv, auch über verschachtelte Gruppen) Mitglied einer hier zugeordneten
AD-Gruppe, erhält er beim Login zusätzlich die zugeordnete App-Rechtegruppe — additiv, mehrere
Zuordnungen können gleichzeitig greifen. Wird keine Zuordnung getroffen, gilt die Standardgruppe oben.
</div>
</div> </div>
{% if can_edit %} {% if can_edit %}
<button type="button" class="btn btn-primary" data-open-modal="addLdapMappingModal"> <button type="button" class="btn btn-primary" data-open-modal="addLdapMappingModal">
@@ -163,13 +159,7 @@
<div class="card card-pad"> <div class="card card-pad">
<div class="section-head" style="margin-bottom:16px;"> <div class="section-head" style="margin-bottom:16px;">
<div> <div>
<h2 style="font-size:16px;">Fileshare-Gruppen</h2> <h2 style="font-size:16px;">Fileshare-Gruppenzuordnung {{ hi.hint_icon("Ist ein AD-Benutzer (rekursiv) Mitglied einer hier zugeordneten AD-Gruppe, wird die zugehörige Freigabe beim Login für ihn gemountet — sofern er zusätzlich das TESM-Recht „Dateifreigaben lesen“ hat (siehe Gruppen → Rechte, Bereich „Dateifreigaben“). Fehlt das Recht, wird nicht gemountet und der Menüpunkt „Dateifreigaben“ erscheint nicht, unabhängig von der AD-Gruppenmitgliedschaft.", "Fileshare-Gruppenzuordnung") }}</h2>
<div class="hint">
Ist ein AD-Benutzer (rekursiv) Mitglied einer hier zugeordneten AD-Gruppe, wird die zugehörige
Freigabe beim Login für ihn gemountet — sofern er zusätzlich das TESM-Recht „Dateifreigaben lesen“
hat (siehe Gruppen → Rechte, Bereich „Dateifreigaben“). Fehlt das Recht, wird nicht gemountet und
der Menüpunkt „Dateifreigaben“ erscheint nicht, unabhängig von der AD-Gruppenmitgliedschaft.
</div>
</div> </div>
{% if can_edit %} {% if can_edit %}
<button type="button" class="btn btn-primary" data-open-modal="addFileshareMappingModal"> <button type="button" class="btn btn-primary" data-open-modal="addFileshareMappingModal">
@@ -278,13 +268,11 @@
<input type="hidden" name="fs_ad_group_name" id="fsMappingAdGroupName"> <input type="hidden" name="fs_ad_group_name" id="fsMappingAdGroupName">
<div class="field-hint" id="fsMappingLoadStatus">Fragt live per Bind-Konto alle Gruppen aus dem Verzeichnis ab.</div> <div class="field-hint" id="fsMappingLoadStatus">Fragt live per Bind-Konto alle Gruppen aus dem Verzeichnis ab.</div>
</div> </div>
<div class="field"><label>Bezeichnung</label> <div class="field"><label>Bezeichnung {{ hi.hint_icon("Anzeigename in der Freigaben-Auswahl — auch Ordnername unter dem Mount-Punkt.", "Bezeichnung") }}</label>
<input type="text" name="fs_share_label" placeholder="z.B. Vertrieb" required> <input type="text" name="fs_share_label" placeholder="z.B. Vertrieb" required>
<div class="field-hint">Anzeigename in der Freigaben-Auswahl — auch Ordnername unter dem Mount-Punkt.</div>
</div> </div>
<div class="field"><label>Freigabe-Pfad (UNC)</label> <div class="field"><label>Freigabe-Pfad (UNC) {{ hi.hint_icon("Beide Schreibweisen funktionieren — \\\\server\\freigabe wird automatisch in das von Linux benötigte //server/freigabe umgewandelt.", "Freigabe-Pfad (UNC)") }}</label>
<input type="text" name="fs_share_unc" placeholder="//fileserver/freigabe" required> <input type="text" name="fs_share_unc" placeholder="//fileserver/freigabe" required>
<div class="field-hint">Beide Schreibweisen funktionieren — <code>\\server\freigabe</code> wird automatisch in das von Linux benötigte <code>//server/freigabe</code> umgewandelt.</div>
</div> </div>
</div> </div>
<div class="modal-footer"> <div class="modal-footer">
@@ -349,13 +337,11 @@
<input type="hidden" name="fs_ad_group_name" id="editFsMappingAdGroupName"> <input type="hidden" name="fs_ad_group_name" id="editFsMappingAdGroupName">
<div class="field-hint" id="editFsMappingLoadStatus">Fragt live per Bind-Konto alle Gruppen aus dem Verzeichnis ab.</div> <div class="field-hint" id="editFsMappingLoadStatus">Fragt live per Bind-Konto alle Gruppen aus dem Verzeichnis ab.</div>
</div> </div>
<div class="field"><label>Bezeichnung</label> <div class="field"><label>Bezeichnung {{ hi.hint_icon("Anzeigename in der Freigaben-Auswahl — auch Ordnername unter dem Mount-Punkt.", "Bezeichnung") }}</label>
<input type="text" name="fs_share_label" id="editFsMappingLabel" placeholder="z.B. Vertrieb" required> <input type="text" name="fs_share_label" id="editFsMappingLabel" placeholder="z.B. Vertrieb" required>
<div class="field-hint">Anzeigename in der Freigaben-Auswahl — auch Ordnername unter dem Mount-Punkt.</div>
</div> </div>
<div class="field"><label>Freigabe-Pfad (UNC)</label> <div class="field"><label>Freigabe-Pfad (UNC) {{ hi.hint_icon("Beide Schreibweisen funktionieren — \\\\server\\freigabe wird automatisch in das von Linux benötigte //server/freigabe umgewandelt.", "Freigabe-Pfad (UNC)") }}</label>
<input type="text" name="fs_share_unc" id="editFsMappingUnc" placeholder="//fileserver/freigabe" required> <input type="text" name="fs_share_unc" id="editFsMappingUnc" placeholder="//fileserver/freigabe" required>
<div class="field-hint">Beide Schreibweisen funktionieren — <code>\\server\freigabe</code> wird automatisch in das von Linux benötigte <code>//server/freigabe</code> umgewandelt.</div>
</div> </div>
</div> </div>
<div class="modal-footer"> <div class="modal-footer">
+184
View File
@@ -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 %}
+20 -14
View File
@@ -1,10 +1,11 @@
{% extends "base.html" %} {% extends "base.html" %}
{% set active_page = "settings_nginx" %} {% 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_title %}NGINX{% endblock %}
{% block page_sub %}<div class="topbar-sub">Reverse-Proxy: Domain, Ports, SSL/HSTS und Zertifikat</div>{% endblock %} {% block page_sub %}<div class="topbar-sub">Reverse-Proxy: Domain, Ports, SSL/HSTS und Zertifikat</div>{% endblock %}
{% block content %} {% block content %}
{% import "_hint_icon.html" as hi %}
<div class="settings-grid settings-grid--wide"> <div class="settings-grid settings-grid--wide">
<div class="card card-pad"> <div class="card card-pad">
@@ -38,9 +39,8 @@
<form method="post" <form method="post"
data-confirm="nginx-Konfiguration wirklich ändern? Falls die Verbindung danach abbricht, wird die vorherige Konfiguration automatisch nach {{ nginx_revert_seconds }} Sekunden wiederhergestellt."> data-confirm="nginx-Konfiguration wirklich ändern? Falls die Verbindung danach abbricht, wird die vorherige Konfiguration automatisch nach {{ nginx_revert_seconds }} Sekunden wiederhergestellt.">
<div class="field"> <div class="field">
<label for="server_name">Domain (server_name)</label> <label for="server_name">Domain (server_name) {{ hi.hint_icon("\"_\" ist nginx' Catch-all (Standard für interne Instanzen ohne eigene Domain) — für Let's Encrypt muss hier die tatsächliche, öffentlich auflösbare Domain stehen.", "Domain (server_name)") }}</label>
<input type="text" name="server_name" id="server_name" value="{{ server_name }}" placeholder="_ (kein bestimmter Hostname) oder z.B. tesm.example.com"> <input type="text" name="server_name" id="server_name" value="{{ server_name }}" placeholder="_ (kein bestimmter Hostname) oder z.B. tesm.example.com">
<div class="field-hint">"_" ist nginx' Catch-all (Standard für interne Instanzen ohne eigene Domain) — für Let's Encrypt muss hier die tatsächliche, öffentlich auflösbare Domain stehen.</div>
</div> </div>
<div class="flex gap-2"> <div class="flex gap-2">
<div class="field field--half"> <div class="field field--half">
@@ -48,14 +48,10 @@
<input type="number" name="http_port" id="http_port" min="1" max="65535" value="{{ http_port }}" required> <input type="number" name="http_port" id="http_port" min="1" max="65535" value="{{ http_port }}" required>
</div> </div>
<div class="field field--half"> <div class="field field--half">
<label for="https_port">HTTPS-Port</label> <label for="https_port">HTTPS-Port {{ hi.hint_icon("Let's Encrypt validiert IMMER über Port 80 (protokollbedingt, unabhängig vom hier eingestellten HTTP-Port) — weicht der HTTP-Port von 80 ab, wird dafür automatisch zusätzlich ein minimaler Port-80-Block mitgeschrieben.", "HTTP-Port / HTTPS-Port") }}</label>
<input type="number" name="https_port" id="https_port" min="1" max="65535" value="{{ https_port }}" required> <input type="number" name="https_port" id="https_port" min="1" max="65535" value="{{ https_port }}" required>
</div> </div>
</div> </div>
<div class="field-hint" style="margin-top:-8px; margin-bottom:14px;">
Let's Encrypt validiert IMMER über Port 80 (protokollbedingt, unabhängig vom hier eingestellten HTTP-Port) —
weicht der HTTP-Port von 80 ab, wird dafür automatisch zusätzlich ein minimaler Port-80-Block mitgeschrieben.
</div>
<div class="field"> <div class="field">
<label class="switch-check" style="display:inline-flex; align-items:center; gap:8px;"> <label class="switch-check" style="display:inline-flex; align-items:center; gap:8px;">
<input type="checkbox" name="ssl_enabled" id="ssl_enabled_check" {% if ssl_enabled %}checked{% endif %} {% if not cert_info %}disabled{% endif %}> <input type="checkbox" name="ssl_enabled" id="ssl_enabled_check" {% if ssl_enabled %}checked{% endif %} {% if not cert_info %}disabled{% endif %}>
@@ -68,9 +64,8 @@
<label class="switch-check" style="display:inline-flex; align-items:center; gap:8px;"> <label class="switch-check" style="display:inline-flex; align-items:center; gap:8px;">
<input type="checkbox" name="hsts_enabled" id="hsts_enabled_check" {% if hsts_enabled %}checked{% endif %} {% if not ssl_enabled %}disabled{% endif %}> <input type="checkbox" name="hsts_enabled" id="hsts_enabled_check" {% if hsts_enabled %}checked{% endif %} {% if not ssl_enabled %}disabled{% endif %}>
<span class="track"></span> <span class="track"></span>
<span>HSTS (Strict-Transport-Security)</span> <span>HSTS (Strict-Transport-Security) {{ hi.hint_icon("Weist Browser an, diese Instanz künftig NUR noch über HTTPS aufzurufen — bleibt auch bei einem späteren Zurückschalten auf HTTP im Browser für 1 Jahr bestehen. Nur aktivieren, wenn das Zertifikat dauerhaft gepflegt wird.", "HSTS") }}</span>
</label> </label>
<div class="field-hint">Weist Browser an, diese Instanz künftig NUR noch über HTTPS aufzurufen — bleibt auch bei einem späteren Zurückschalten auf HTTP im Browser für 1 Jahr bestehen. Nur aktivieren, wenn das Zertifikat dauerhaft gepflegt wird.</div>
</div> </div>
<button type="submit" name="apply_nginx" value="1" class="btn btn-primary btn-block"> <button type="submit" name="apply_nginx" value="1" 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="M9 12l2 2 4-4"/><path d="M12 2l8 4v6c0 5-3.5 8.5-8 10-4.5-1.5-8-5-8-10V6z"/></svg> <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9 12l2 2 4-4"/><path d="M12 2l8 4v6c0 5-3.5 8.5-8 10-4.5-1.5-8-5-8-10V6z"/></svg>
@@ -78,7 +73,13 @@
</button> </button>
</form> </form>
{% else %} {% 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 %} {% endif %}
</div> </div>
@@ -165,9 +166,8 @@
<input type="file" name="cert_file" id="cert_file" accept=".pem,.crt,.cer" required> <input type="file" name="cert_file" id="cert_file" accept=".pem,.crt,.cer" required>
</div> </div>
<div class="field"> <div class="field">
<label for="key_file">Privater Schlüssel (PEM, unverschlüsselt)</label> <label for="key_file">Privater Schlüssel (PEM, unverschlüsselt) {{ hi.hint_icon("Ein passwortgeschütztes Schlüssel wird abgelehnt — nginx könnte ihn beim Start ohnehin nicht ohne manuelle Passworteingabe laden.", "Privater Schlüssel") }}</label>
<input type="file" name="key_file" id="key_file" accept=".pem,.key" required> <input type="file" name="key_file" id="key_file" accept=".pem,.key" required>
<div class="field-hint">Ein passwortgeschütztes Schlüssel wird abgelehnt — nginx könnte ihn beim Start ohnehin nicht ohne manuelle Passworteingabe laden.</div>
</div> </div>
<button type="submit" name="upload_cert" value="1" class="btn btn-secondary btn-block"> <button type="submit" name="upload_cert" value="1" 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> <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>
@@ -183,7 +183,13 @@
<p class="text-faint" style="font-size:11.5px;">SSL muss zuerst deaktiviert werden, bevor das Zertifikat entfernt werden kann.</p> <p class="text-faint" style="font-size:11.5px;">SSL muss zuerst deaktiviert werden, bevor das Zertifikat entfernt werden kann.</p>
{% endif %} {% endif %}
{% else %} {% 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 %} {% endif %}
</div> </div>
+3 -4
View File
@@ -10,6 +10,7 @@
{% block page_sub %}<div class="topbar-sub">{{ switches|length }} Switche</div>{% endblock %} {% block page_sub %}<div class="topbar-sub">{{ switches|length }} Switche</div>{% endblock %}
{% block content %} {% block content %}
{% import "_hint_icon.html" as hi %}
<div class="section-head"> <div class="section-head">
<div> <div>
@@ -102,9 +103,8 @@
<input type="text" name="ip" value="{{ s['ip'] }}" required placeholder="z.B. 192.168.1.100"> <input type="text" name="ip" value="{{ s['ip'] }}" required placeholder="z.B. 192.168.1.100">
<div class="invalid-feedback">Bitte eine gültige IP-Adresse eingeben.</div> <div class="invalid-feedback">Bitte eine gültige IP-Adresse eingeben.</div>
</div> </div>
<div class="field"><label>SSH-Port</label> <div class="field"><label>SSH-Port {{ hi.hint_icon("Leer lassen, wenn der Switch den Standard-Port 22 verwendet.", "SSH-Port") }}</label>
<input type="number" name="ssh_port" value="{{ s['ssh_port'] or '' }}" min="1" max="65535" placeholder="22 (Standard)"> <input type="number" name="ssh_port" value="{{ s['ssh_port'] or '' }}" min="1" max="65535" placeholder="22 (Standard)">
<div class="field-hint">Leer lassen, wenn der Switch den Standard-Port 22 verwendet.</div>
</div> </div>
<div class="field"> <div class="field">
<label>Zugangsdaten</label> <label>Zugangsdaten</label>
@@ -162,9 +162,8 @@
<input type="text" name="ip" required placeholder="z.B. 192.168.1.100"> <input type="text" name="ip" required placeholder="z.B. 192.168.1.100">
<div class="invalid-feedback">Bitte eine gültige IP-Adresse eingeben.</div> <div class="invalid-feedback">Bitte eine gültige IP-Adresse eingeben.</div>
</div> </div>
<div class="field"><label>SSH-Port</label> <div class="field"><label>SSH-Port {{ hi.hint_icon("Leer lassen, wenn der Switch den Standard-Port 22 verwendet.", "SSH-Port") }}</label>
<input type="number" name="ssh_port" min="1" max="65535" placeholder="22 (Standard)"> <input type="number" name="ssh_port" min="1" max="65535" placeholder="22 (Standard)">
<div class="field-hint">Leer lassen, wenn der Switch den Standard-Port 22 verwendet.</div>
</div> </div>
<div class="field"> <div class="field">
<label>Zugangsdaten</label> <label>Zugangsdaten</label>
+2 -2
View File
@@ -4,6 +4,7 @@
{% block page_sub %}<div class="topbar-sub">{{ users|length }} Benutzer</div>{% endblock %} {% block page_sub %}<div class="topbar-sub">{{ users|length }} Benutzer</div>{% endblock %}
{% block content %} {% block content %}
{% import "_hint_icon.html" as hi %}
<div class="section-head"> <div class="section-head">
<div> <div>
@@ -205,13 +206,12 @@
</div> </div>
<div class="modal-body"> <div class="modal-body">
<div class="field"> <div class="field">
<label>Gruppe</label> <label>Gruppe {{ hi.hint_icon("Ersetzt die bisherige Gruppen-/Rollenzuordnung dieses Benutzers.", "Gruppe") }}</label>
<select name="group_id" id="group_select"> <select name="group_id" id="group_select">
<option value="">Keine Gruppe</option> <option value="">Keine Gruppe</option>
{% for g in all_groups %}<option value="{{ g['id'] }}">{{ g['name'] }}</option>{% endfor %} {% for g in all_groups %}<option value="{{ g['id'] }}">{{ g['name'] }}</option>{% endfor %}
{% if current_user.is_admin %}<option value="admin">Admin</option>{% endif %} {% if current_user.is_admin %}<option value="admin">Admin</option>{% endif %}
</select> </select>
<div class="field-hint">Ersetzt die bisherige Gruppen-/Rollenzuordnung dieses Benutzers.</div>
</div> </div>
</div> </div>
<div class="modal-footer"> <div class="modal-footer">
+42 -7
View File
@@ -34,11 +34,8 @@ REPO_NAME="${TESM_REPO_NAME:-tesm}"
RELEASE_TAG="${TESM_RELEASE_TAG:-latest}" RELEASE_TAG="${TESM_RELEASE_TAG:-latest}"
GITEA_USER="${TESM_GITEA_USER:-}" GITEA_USER="${TESM_GITEA_USER:-}"
GITEA_TOKEN="${TESM_GITEA_TOKEN:-}" 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)" WORK_DIR="/tmp/tesm-update-$(date +%s)"
PACKAGE_FILE="$WORK_DIR/${PACKAGE_NAME}"
RED='\033[0;31m' RED='\033[0;31m'
GREEN='\033[0;32m' GREEN='\033[0;32m'
@@ -50,6 +47,38 @@ if [ "$(id -u)" -ne 0 ]; then
exit 1 exit 1
fi 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}============================================================================${NC}"
echo -e "${YELLOW} TESM Update/Reinstall — Release \"${RELEASE_TAG}\"${NC}" echo -e "${YELLOW} TESM Update/Reinstall — Release \"${RELEASE_TAG}\"${NC}"
echo -e "${YELLOW}============================================================================${NC}" echo -e "${YELLOW}============================================================================${NC}"
@@ -68,16 +97,22 @@ fi
echo -e "${RED}${NC} Lade Paket von ${DOWNLOAD_URL}..." echo -e "${RED}${NC} Lade Paket von ${DOWNLOAD_URL}..."
mkdir -p "$WORK_DIR" 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" curl -fsSL "${CURL_AUTH[@]}" -o "$PACKAGE_FILE" "$DOWNLOAD_URL"
echo -e "${GREEN}${NC} Paket heruntergeladen ($(du -h "$PACKAGE_FILE" | cut -f1))." echo -e "${GREEN}${NC} Paket heruntergeladen ($(du -h "$PACKAGE_FILE" | cut -f1))."
echo -e "${RED}${NC} Entpacke Paket..." echo -e "${RED}${NC} Entpacke Paket..."
mkdir -p "$WORK_DIR/pkg" mkdir -p "$WORK_DIR/pkg"
tar xzf "$PACKAGE_FILE" -C "$WORK_DIR/pkg" --strip-components=1 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 "${GREEN}${NC} Paket entpackt."
echo -e "${RED}${NC} Starte install.sh aus dem Paket..." echo -e "${RED}${NC} Starte install.sh aus dem Paket..."
+22 -1
View File
@@ -80,6 +80,7 @@ EOF
function check_device() { function check_device() {
local rpi_ip=$1 dev_name=$2 switch_ip=$3 switch_ssh_port=$4 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 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 if ping -c 1 -W 2 "$rpi_ip" &> /dev/null; then
echo "$(date '+%Y-%m-%d %H:%M:%S') $dev_name ist erreichbar!" >> "$LOGFILE" echo "$(date '+%Y-%m-%d %H:%M:%S') $dev_name ist erreichbar!" >> "$LOGFILE"
@@ -92,6 +93,15 @@ function check_device() {
return return
fi 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 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" 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 return
@@ -146,8 +156,19 @@ fi
while true; do while true; do
echo "--------------------------------------------------------------------" >> "$LOGFILE" 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 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) done < <(python3 /srv/tesm/generate_ips.py)
wait wait