Compare commits

...
5 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
5 changed files with 104 additions and 15 deletions
+1 -1
View File
@@ -134,7 +134,7 @@ sudo chmod 755 /var/log/tesm
step "Deploying application to /srv/tesm"
sudo mkdir -p /srv/tesm
sudo rsync -a --delete --exclude 'venv' --exclude 'sqlite.db' --exclude 'fernet.key' --exclude 'secret.key' \
--exclude 'known_hosts' \
--exclude 'known_hosts' --exclude 'license.json' \
"$REPO_DIR/srv/tesm/" /srv/tesm/ >>/var/log/tesm-install.log 2>&1
print_status "Application files copied"
+1 -1
View File
@@ -1 +1 @@
1.2.1
1.2.6
+71 -10
View File
@@ -326,6 +326,10 @@ PERMISSIONS = {
"label": "Systemeinstellungen",
"rows": {"view": "settings_system.view", "edit": "settings_system.edit"},
},
"settings_license": {
"label": "Lizenz",
"rows": {"view": "settings_license.view", "edit": "settings_license.edit"},
},
"settings_importexport": {
"label": "Im-/Export",
"rows": {
@@ -392,6 +396,8 @@ PERMISSION_LABELS = {
"groups.view": "Gruppen lesen", "groups.create": "Gruppen anlegen",
"groups.edit": "Gruppen ändern (inkl. Löschen)",
"settings_system.view": "Systemeinstellungen lesen", "settings_system.edit": "Systemeinstellungen ändern",
"settings_license.view": "Lizenzstatus ansehen",
"settings_license.edit": "Lizenzdatei hochladen sowie Lizenz aktivieren/deaktivieren (online oder per Code)",
"settings_importexport.view": "Im-/Export-Seite ansehen",
"settings_importexport.edit": "Daten importieren",
"settings_importexport.export": "Daten exportieren (Export-Datei enthält Passwörter im Klartext)",
@@ -486,10 +492,11 @@ def _nav_key_visible(key, user):
if key == "settings_system":
return user.can_view_settings_system
if key == "settings_license":
# Immer erreichbar für jeden mit Systemeinstellungen-Leserecht --
# muss auch OHNE Lizenz sichtbar bleiben, sonst könnte niemand
# jemals eine erste Lizenz hochladen/aktivieren.
return user.can_view_settings_system
# Eigenes Recht (settings_license.view), unabhängig von
# settings_system -- muss auch OHNE eine aktive Lizenz sichtbar
# bleiben (das ist eine andere Prüfung, siehe license_active()),
# sonst könnte niemand jemals eine erste Lizenz hochladen/aktivieren.
return user.can_view_settings_license
if key == "settings_importexport":
return user.can_view_settings_importexport
if key == "settings_dhcp":
@@ -726,6 +733,10 @@ class User(UserMixin):
def can_view_settings_system(self):
return self.has_permission("settings_system.view")
@property
def can_view_settings_license(self):
return self.has_permission("settings_license.view")
@property
def can_view_settings_importexport(self):
return self.has_any_permission([
@@ -1146,6 +1157,32 @@ def _ensure_schema():
"INSERT OR IGNORE INTO settings (key, value) VALUES (?, '1')", (_migration_key_v2,)
)
_migration_key_license_perm = "_migrated_settings_license_permission_v1"
if not conn.execute("SELECT 1 FROM settings WHERE key=?", (_migration_key_license_perm,)).fetchone():
# "Lizenz" (settings_license) hatte bislang KEIN eigenes Recht --
# die Seite lief komplett über settings_system.view/.edit mit.
# Backfill: jede Gruppe, die settings_system.view/.edit bereits
# hatte, bekommt automatisch das neue, gespiegelte
# settings_license.view/.edit dazu -- ohne diesen Schritt würden
# bestehende Gruppen beim Update stillschweigend den Zugriff auf
# die Lizenzseite verlieren, den sie vorher implizit hatten.
_system_to_license = {
"settings_system.view": "settings_license.view",
"settings_system.edit": "settings_license.edit",
}
for system_perm, license_perm in _system_to_license.items():
rows = conn.execute(
"SELECT DISTINCT group_id FROM group_permissions WHERE permission=?", (system_perm,)
).fetchall()
for row in rows:
conn.execute(
"INSERT OR IGNORE INTO group_permissions (group_id, permission) VALUES (?, ?)",
(row["group_id"], license_perm),
)
conn.execute(
"INSERT OR IGNORE INTO settings (key, value) VALUES (?, '1')", (_migration_key_license_perm,)
)
_migration_key_old_logs = "_cleaned_up_legacy_rpi_log_files_v1"
if not conn.execute("SELECT 1 FROM settings WHERE key=?", (_migration_key_old_logs,)).fetchone():
for stale_log in glob.glob("/var/log/rpi-*.log"):
@@ -4469,7 +4506,7 @@ def settings():
@app.route("/settings/license")
@login_required
def settings_license():
if not current_user.can_view_settings_system:
if not current_user.can_view_settings_license:
flash("Keine Berechtigung, die Lizenz anzusehen.", "danger")
return redirect(url_for("index"))
pending_raw = get_setting("license_pending_request", "")
@@ -4492,7 +4529,7 @@ def settings_license():
@app.route("/settings/license/upload", methods=["POST"])
@login_required
def license_upload():
if not current_user.has_permission("settings_system.edit"):
if not current_user.has_permission("settings_license.edit"):
flash("Keine Berechtigung, die Lizenz zu ändern.", "danger")
return redirect(url_for("settings_license"))
uploaded = request.files.get("license_file")
@@ -4585,7 +4622,7 @@ def _license_handshake_confirm(action, success_setting_fn, success_log_action, s
@app.route("/settings/license/activate", methods=["POST"])
@login_required
def license_activate():
if not current_user.has_permission("settings_system.edit"):
if not current_user.has_permission("settings_license.edit"):
flash("Keine Berechtigung, die Lizenz zu aktivieren.", "danger")
return redirect(url_for("settings_license"))
if not _license_state.get("file") or not _license_state.get("valid"):
@@ -4595,6 +4632,18 @@ def license_activate():
def on_success():
set_setting("license_activation_status", "active")
set_setting("license_export_grace", "")
# Sofort einen ersten Heartbeat auslösen, statt bis zu 6h auf den
# nächsten planmäßigen Durchlauf zu warten (siehe
# _license_heartbeat_loop) -- ohne das würde "Letzter Heartbeat"
# direkt nach der Aktivierung fälschlich wie ein Verbindungsproblem
# aussehen ("noch nie"), obwohl schlicht noch keine Zeit vergangen
# ist. Ein Fehlschlag hier (z.B. Kunde offline) wird wie jeder
# andere Heartbeat-Fehlschlag behandelt (nur vermerkt, nichts wird
# deswegen abgeschaltet).
try:
_send_heartbeat()
except Exception:
app.logger.error("Erster Heartbeat nach Aktivierung fehlgeschlagen:\n%s", traceback.format_exc())
return _license_handshake("activate", "/api/activate", on_success, "license.activated", "Lizenz erfolgreich aktiviert.")
@@ -4602,13 +4651,25 @@ def license_activate():
@app.route("/settings/license/activate/confirm", methods=["POST"])
@login_required
def license_activate_confirm():
if not current_user.has_permission("settings_system.edit"):
if not current_user.has_permission("settings_license.edit"):
flash("Keine Berechtigung, die Lizenz zu aktivieren.", "danger")
return redirect(url_for("settings_license"))
def on_success():
set_setting("license_activation_status", "active")
set_setting("license_export_grace", "")
# Sofort einen ersten Heartbeat auslösen, statt bis zu 6h auf den
# nächsten planmäßigen Durchlauf zu warten (siehe
# _license_heartbeat_loop) -- ohne das würde "Letzter Heartbeat"
# direkt nach der Aktivierung fälschlich wie ein Verbindungsproblem
# aussehen ("noch nie"), obwohl schlicht noch keine Zeit vergangen
# ist. Ein Fehlschlag hier (z.B. Kunde offline) wird wie jeder
# andere Heartbeat-Fehlschlag behandelt (nur vermerkt, nichts wird
# deswegen abgeschaltet).
try:
_send_heartbeat()
except Exception:
app.logger.error("Erster Heartbeat nach Aktivierung fehlgeschlagen:\n%s", traceback.format_exc())
return _license_handshake_confirm("activate", on_success, "license.activated", "Lizenz erfolgreich aktiviert.")
@@ -4616,7 +4677,7 @@ def license_activate_confirm():
@app.route("/settings/license/deactivate", methods=["POST"])
@login_required
def license_deactivate():
if not current_user.has_permission("settings_system.edit"):
if not current_user.has_permission("settings_license.edit"):
flash("Keine Berechtigung, die Lizenz zu deaktivieren.", "danger")
return redirect(url_for("settings_license"))
if not _license_state.get("file") or not license_activated():
@@ -4636,7 +4697,7 @@ def license_deactivate():
@app.route("/settings/license/deactivate/confirm", methods=["POST"])
@login_required
def license_deactivate_confirm():
if not current_user.has_permission("settings_system.edit"):
if not current_user.has_permission("settings_license.edit"):
flash("Keine Berechtigung, die Lizenz zu deaktivieren.", "danger")
return redirect(url_for("settings_license"))
+25 -2
View File
@@ -103,14 +103,30 @@ 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):
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
@@ -130,7 +146,7 @@ def issue_license(*, customer, license_type, modules, valid_days,
"type": license_type,
"modules": sorted(modules),
"issued_at": _iso(now),
"expires_at": _iso(now + valid_days * 86400),
"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,
@@ -225,6 +241,13 @@ def build_client_request(action, license_file, nonce=None) -> dict:
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()),
+6 -1
View File
@@ -1,6 +1,6 @@
{% extends "base.html" %}
{% set active_page = "settings_license" %}
{% set can_edit = current_user.has_permission('settings_system.edit') %}
{% 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 %}
@@ -156,6 +156,11 @@
<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>