Sub-Lizenzserver melden ihren Kunden-/Ticket-/Lizenzstand an den Master

Ein Sub-Lizenzserver (role='license_server') meldet jetzt seinen
kompletten eigenen Kunden-/Ticket-/Lizenzstand an den Master, der ihn
als Ueberblick auf einer neuen Kunden-Detailseite anzeigt: unter dem
Kunden, der die Lizenzserver-Lizenz haelt, erscheinen dessen eigene
Kunden mit ihren Tickets/Lizenzen -- genau wie gewuenscht.

Transport: huckepack auf dem bereits bestehenden 6-Stunden-Heartbeat
(licensing.py: neues optionales 'report'-Feld in
build_client_request(), signiert wie jedes andere Feld -- ein
Angreifer ohne den privaten Lizenzschluessel kann keinen gefaelschten
Bericht unterschieben). Zusaetzlich sofortiger Best-Effort-Report per
Hintergrund-Thread nach jeder lokalen Ticket-/Kunden-/Lizenz-Aenderung
(_report_to_master_async()), damit der Master nicht bis zu 6h auf
Aktualitaet warten muss. Funktioniert automatisch auch ueber den
Offline-Code-Weg (/licenses/manual-code), da beide Transportwege
denselben _process_heartbeat()-Code nutzen.

- Drei neue, rein informative Tabellen (reported_sub_customers/
  _tickets/_licenses), verankert an source_license_id (die Lizenz,
  die den Sub-Lizenzserver identifiziert). Ein Bericht ist immer ein
  VOLLER Schnappschuss -- bei jedem Empfang werden alle bisherigen
  Zeilen dieser source_license_id ersetzt (kein Diffing noetig, ein
  beim Sub-Lizenzserver geloeschtes Ticket verschwindet dadurch
  automatisch korrekt auch hier).
- _build_own_issuance_report(): baut den Schnappschuss auf der
  Sub-Lizenzserver-Seite.
- _ingest_sub_report(): uebernimmt ihn auf der Master-Seite, NUR wenn
  row['role']=='license_server' fuer die absendende Lizenz (Verteid-
  igung in der Tiefe -- row['role'] steht in der Master-DB fest, nicht
  im Client-Request, ein gewoehnlicher Kunde kann sich also nicht als
  Lizenzserver ausgeben).
- Neue Kunden-Detailseite (/customers/<id>, customer_detail.html):
  Stammdaten, eigene Tickets bei uns, und -- falls dieser Kunde eine
  aktive role='license_server'-Lizenz haelt -- die gemeldeten eigenen
  Kunden mit Tickets/Lizenzen (Ampelfarbe/Resttage ueber dieselbe
  _license_dict_for_display()-Funktion wie ueberall sonst). Kunden-
  liste verlinkt jetzt auf die Detailseite, zeigt 'Lizenzserver'-Badge.

Getestet: licensing.py isoliert (report signiert+verifiziert,
Manipulation bricht die Signatur, unveraendertes Verhalten ohne
report-Argument), _ingest_sub_report() isoliert (Erstbericht korrekt
uebernommen, Zweitbericht ersetzt korrekt inkl. verschwindender
Tickets, Isolation zwischen mehreren source_license_id sauber).
ZUSAETZLICH ein vollstaendiger Zwei-Prozess-Integrationstest ueber
echtes HTTP (zwei unabhaengige, native laufende Instanzen: 'Master' +
'Sub-Lizenzserver' auf unterschiedlichen Ports): Ticket mit
role=license_server angelegt, Lizenz ausgestellt, auf der zweiten
Instanz hochgeladen und ONLINE aktiviert, dort ein eigener Endkunde
samt Ticket/Lizenz angelegt, automatischer Report ausgeloest --
Master-Kundendetailseite zeigt den gemeldeten Endkunden mit korrektem
Ticket-Typ/Laufzeit/Status und aktuellem Heartbeat-Zeitstempel.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-23 22:45:17 +02:00
co-authored by Claude Sonnet 5
parent ed2ed00a4e
commit ed9ba1645b
6 changed files with 476 additions and 6 deletions
+1 -1
View File
@@ -1 +1 @@
1.2.6
1.2.7
+274 -2
View File
@@ -881,6 +881,64 @@ def _ensure_schema():
)
""")
# Sub-Lizenzserver-Reporting (siehe role="license_server" in
# licensing.py): ein Sub-Lizenzserver meldet bei jedem Heartbeat (und
# sofort nach jeder lokalen Aenderung, siehe _report_to_master_async())
# einen vollstaendigen Schnappschuss seiner EIGENEN Kunden/Tickets/
# Lizenzen. Bewusst DREI EIGENE Tabellen statt Wiederverwendung von
# license_customers/license_tickets/licenses -- das hier ist rein
# gemeldete, von diesem Master aus nicht editierbare Fremd-Information
# (der Master verwaltet diese Daten nicht selbst, er zeigt sie nur an),
# eine Vermischung mit selbst verwalteten Datensaetzen wuerde das
# verwischen. source_license_id verankert jede Zeile an genau der
# Lizenz (role='license_server'), die diesen Sub-Lizenzserver auf
# diesem Master identifiziert -- bei jedem neuen Bericht werden alle
# drei Tabellen fuer diese source_license_id komplett ersetzt (siehe
# _ingest_sub_report()), kein Diffing noetig, da ein voller
# Schnappschuss geschickt wird.
conn.execute("""
CREATE TABLE IF NOT EXISTS reported_sub_customers (
id INTEGER PRIMARY KEY AUTOINCREMENT,
source_license_id TEXT NOT NULL,
remote_customer_ref TEXT NOT NULL,
name TEXT NOT NULL,
contact_email TEXT, contact_phone TEXT, contact_person TEXT,
street TEXT, house_number TEXT, postal_code TEXT, city TEXT,
last_reported_at TEXT NOT NULL,
UNIQUE (source_license_id, remote_customer_ref)
)
""")
conn.execute("""
CREATE TABLE IF NOT EXISTS reported_sub_tickets (
id INTEGER PRIMARY KEY AUTOINCREMENT,
source_license_id TEXT NOT NULL,
remote_ticket_id TEXT NOT NULL,
remote_customer_ref TEXT NOT NULL,
type TEXT NOT NULL, modules TEXT NOT NULL DEFAULT '[]',
valid_days INTEGER, lifetime INTEGER NOT NULL DEFAULT 0,
status TEXT NOT NULL DEFAULT 'open', role TEXT NOT NULL DEFAULT 'customer',
created_at TEXT,
last_reported_at TEXT NOT NULL,
UNIQUE (source_license_id, remote_ticket_id)
)
""")
conn.execute("""
CREATE TABLE IF NOT EXISTS reported_sub_licenses (
id INTEGER PRIMARY KEY AUTOINCREMENT,
source_license_id TEXT NOT NULL,
remote_license_id TEXT NOT NULL,
remote_ticket_id TEXT,
type TEXT NOT NULL, modules TEXT NOT NULL DEFAULT '[]',
issued_at TEXT, expires_at TEXT, status TEXT NOT NULL, lifetime INTEGER NOT NULL DEFAULT 0,
fingerprint TEXT, hostname TEXT, activated_at TEXT, last_heartbeat_at TEXT,
last_reported_at TEXT NOT NULL,
UNIQUE (source_license_id, remote_license_id)
)
""")
conn.execute("CREATE INDEX IF NOT EXISTS idx_reported_sub_customers_source ON reported_sub_customers(source_license_id)")
conn.execute("CREATE INDEX IF NOT EXISTS idx_reported_sub_tickets_source ON reported_sub_tickets(source_license_id)")
conn.execute("CREATE INDEX IF NOT EXISTS idx_reported_sub_licenses_source ON reported_sub_licenses(source_license_id)")
# Rückwirkend ein Ticket für jede Lizenz nachbilden, die vor Einführung
# des Ticket-Konzepts direkt ausgestellt wurde (ticket_id noch NULL) --
# sonst würden genau diese Lizenzen (auf einer echten Installation z.B.
@@ -1962,6 +2020,56 @@ def _license_api_post(endpoint, path, payload, timeout=10):
return json.loads(resp.read().decode("utf-8"))
MAX_REPORT_ROWS = 5000
def _build_own_issuance_report(conn):
"""Nur relevant, wenn DIESE Instanz selbst ein Sub-Lizenzserver ist
(eigene Lizenz mit role='license_server', siehe licensing.LICENSE_ROLES)
-- baut einen vollstaendigen Schnappschuss der EIGENEN, von dieser
Instanz selbst verwalteten Kunden/Tickets/Lizenzen (NICHT die eigene
Bootstrap-Lizenz dieser Instanz selbst -- die steht in license.json,
nicht in diesen Tabellen). Wird bei jedem Heartbeat an den Master
mitgeschickt (siehe _send_heartbeat()), der daraus seine
reported_sub_*-Tabellen befuellt (siehe _ingest_sub_report() auf der
Master-Seite). Ein voller Schnappschuss statt Deltas -- deutlich
einfacher korrekt zu halten (keine Delta-/Loeschverfolgung noetig),
bei den hier realistischen Datenmengen (Kunden eines einzelnen
Sub-Lizenzservers) unproblematisch."""
customers = conn.execute(
"SELECT id, name, contact_email, contact_phone, contact_person, "
"street, house_number, postal_code, city FROM license_customers "
"ORDER BY id LIMIT ?", (MAX_REPORT_ROWS,)
).fetchall()
tickets = conn.execute(
"SELECT id, customer_id, type, modules, valid_days, lifetime, status, role, created_at "
"FROM license_tickets ORDER BY created_at LIMIT ?", (MAX_REPORT_ROWS,)
).fetchall()
licenses_rows = conn.execute(
"SELECT license_id, ticket_id, type, modules, issued_at, expires_at, status, lifetime, "
"fingerprint, hostname, activated_at, last_heartbeat_at "
"FROM licenses ORDER BY created_at LIMIT ?", (MAX_REPORT_ROWS,)
).fetchall()
return {
"customers": [dict(r) for r in customers],
"tickets": [dict(r) for r in tickets],
"licenses": [dict(r) for r in licenses_rows],
}
def _report_to_master_async():
"""Feuert einen sofortigen Best-Effort-Heartbeat (inkl. Bericht, siehe
_build_own_issuance_report) in einem eigenen Hintergrund-Thread aus --
aufgerufen direkt nach jeder lokalen Ticket-/Lizenz-Aenderung dieser
Instanz, damit der Master zeitnah einen aktuellen Stand sieht, statt bis
zu LICENSE_HEARTBEAT_INTERVAL_SECONDS (6h) warten zu muessen. Laeuft
bewusst asynchron und schluckt jeden Fehler (siehe _send_heartbeat) --
ein nicht erreichbarer Master darf eine lokale Ticket-/Lizenzaktion
niemals verzoegern oder fehlschlagen lassen."""
if _license_state.get("status") and _license_state["status"].get("role") == "license_server":
threading.Thread(target=_send_heartbeat, daemon=True).start()
def _send_heartbeat():
"""Ein einzelner Heartbeat-Versuch gegen den in der Lizenz hinterlegten
master_endpoint. Bei Erfolg: license_last_heartbeat_at aktualisieren
@@ -1976,7 +2084,14 @@ def _send_heartbeat():
endpoint = license_file.get("master_endpoint", "")
if not endpoint:
return
request_payload = licensing.build_client_request("heartbeat", license_file)
report = None
if _license_state.get("status", {}).get("role") == "license_server":
report_conn = get_db_connection()
try:
report = _build_own_issuance_report(report_conn)
finally:
report_conn.close()
request_payload = licensing.build_client_request("heartbeat", license_file, report=report)
try:
response_payload = _license_api_post(endpoint, "/api/heartbeat", request_payload)
except Exception as exc:
@@ -2214,6 +2329,7 @@ def _create_ticket(conn, customer_id, license_type, modules, valid_days, actor,
"ticket.create", customer["name"] if customer else str(customer_id),
f"{LICENSE_TYPE_LABELS.get(license_type, license_type)}, {duration_label}{role_label} (Akteur: {actor})",
)
_report_to_master_async()
return ticket_id
@@ -2299,6 +2415,7 @@ def _issue_license_from_ticket(conn, ticket_id, actor, as_trial=False):
"license.issue", customer["name"] if customer else "?",
f"{LICENSE_TYPE_LABELS.get(license_type, license_type)}, {expiry_label} (Ticket, Akteur: {actor})",
)
_report_to_master_async()
return license_file
@@ -2319,6 +2436,7 @@ def _revoke_license_row(conn, license_row, actor):
"license.revoke", customer["name"] if customer else license_row["license_id"],
f"Akteur: {actor}",
)
_report_to_master_async()
# --- Aktivierungs-/Deaktivierungs-/Heartbeat-Verarbeitung -------------------
@@ -2434,6 +2552,64 @@ def _process_deactivate(data):
return licensing.build_master_response("deactivated", license_id, fingerprint, MASTER_PRIVATE_KEY, status="ok"), 200
def _ingest_sub_report(conn, source_license_id, report):
"""Uebernimmt einen von einem Sub-Lizenzserver per Heartbeat gemeldeten
Schnappschuss (siehe _build_own_issuance_report() auf dessen Seite) in
die reported_sub_*-Tabellen dieses Masters -- rein informativ, dieser
Master verwaltet diese Datensaetze nicht selbst. Ersetzt bei jedem
Bericht ALLE bisherigen Zeilen fuer diese source_license_id komplett
(kein Diffing) -- ein voller Schnappschuss macht z.B. beim Sub-
Lizenzserver geloeschte Tickets/Kunden automatisch korrekt verschwinden,
ohne eigene Loeschverfolgung. Aufrufer MUSS bereits geprueft haben, dass
die zugehoerige Lizenz tatsaechlich role='license_server' traegt (siehe
_process_heartbeat) -- diese Funktion vertraut dem Aufrufer."""
if not isinstance(report, dict):
return
now = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
conn.execute("DELETE FROM reported_sub_licenses WHERE source_license_id=?", (source_license_id,))
conn.execute("DELETE FROM reported_sub_tickets WHERE source_license_id=?", (source_license_id,))
conn.execute("DELETE FROM reported_sub_customers WHERE source_license_id=?", (source_license_id,))
for c in (report.get("customers") or [])[:MAX_REPORT_ROWS]:
try:
conn.execute(
"INSERT INTO reported_sub_customers (source_license_id, remote_customer_ref, name, "
"contact_email, contact_phone, contact_person, street, house_number, postal_code, city, "
"last_reported_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
(source_license_id, str(c.get("id", "")), c.get("name", "") or "?",
c.get("contact_email"), c.get("contact_phone"), c.get("contact_person"),
c.get("street"), c.get("house_number"), c.get("postal_code"), c.get("city"), now),
)
except (sqlite3.IntegrityError, TypeError, KeyError):
continue
for t in (report.get("tickets") or [])[:MAX_REPORT_ROWS]:
try:
conn.execute(
"INSERT INTO reported_sub_tickets (source_license_id, remote_ticket_id, remote_customer_ref, "
"type, modules, valid_days, lifetime, status, role, created_at, last_reported_at) "
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
(source_license_id, str(t.get("id", "")), str(t.get("customer_id", "")),
t.get("type", "standard"), t.get("modules", "[]"), t.get("valid_days"),
int(bool(t.get("lifetime"))), t.get("status", "open"), t.get("role", "customer"),
t.get("created_at"), now),
)
except (sqlite3.IntegrityError, TypeError, KeyError):
continue
for l in (report.get("licenses") or [])[:MAX_REPORT_ROWS]:
try:
conn.execute(
"INSERT INTO reported_sub_licenses (source_license_id, remote_license_id, remote_ticket_id, "
"type, modules, issued_at, expires_at, status, lifetime, fingerprint, hostname, "
"activated_at, last_heartbeat_at, last_reported_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
(source_license_id, l.get("license_id", ""), l.get("ticket_id"),
l.get("type", "standard"), l.get("modules", "[]"), l.get("issued_at"), l.get("expires_at"),
l.get("status", "issued"), int(bool(l.get("lifetime"))), l.get("fingerprint"),
l.get("hostname"), l.get("activated_at"), l.get("last_heartbeat_at"), now),
)
except (sqlite3.IntegrityError, TypeError, KeyError):
continue
conn.commit()
def _process_heartbeat(data):
license_id = data.get("license_id")
conn = get_db_connection()
@@ -2466,6 +2642,14 @@ def _process_heartbeat(data):
(now, hostname, license_id),
)
conn.commit()
# Sub-Lizenzserver-Reporting: NUR uebernehmen, wenn die Lizenz dieses
# Absenders tatsaechlich role='license_server' traegt -- Verteidigung
# in der Tiefe, ein gewoehnlicher Kunde koennte sonst versuchen, einen
# "report"-Payload unterzuschieben, um sich als Lizenzserver
# auszugeben. row['role'] steht in der Master-DB fest (beim Ausstellen
# gesetzt, siehe _issue_license_from_ticket), nicht im Client-Request.
if row["role"] == "license_server" and data.get("report"):
_ingest_sub_report(conn, license_id, data["report"])
license_update = json.loads(row["license_file_json"])
conn.close()
return licensing.build_master_response(
@@ -2622,11 +2806,17 @@ def customers():
log_action("customer.delete", str(customer_id))
flash("Kunde gelöscht.", "success")
conn.close()
# Deckt alle drei Sub-Aktionen mit einem Aufruf ab -- der Report
# ist ohnehin immer ein voller Schnappschuss, ein zusaetzlicher
# Versand bei einer fehlgeschlagenen Validierung sendet einfach
# den unveraenderten Stand erneut (harmlos).
_report_to_master_async()
return redirect(url_for("customers"))
customers_rows = conn.execute("""
SELECT license_customers.*, COUNT(DISTINCT license_tickets.id) AS ticket_count,
COUNT(DISTINCT licenses.id) AS license_count
COUNT(DISTINCT licenses.id) AS license_count,
MAX(CASE WHEN licenses.role='license_server' AND licenses.status='active' THEN 1 ELSE 0 END) AS is_sub_license_server
FROM license_customers
LEFT JOIN license_tickets ON license_tickets.customer_id = license_customers.id
LEFT JOIN licenses ON licenses.customer_id = license_customers.id
@@ -2641,6 +2831,85 @@ def customers():
)
@app.route("/customers/<int:customer_id>")
@login_required
def customer_detail(customer_id):
"""Kunden-Detailseite -- zeigt Stammdaten sowie, falls dieser Kunde eine
aktive role='license_server'-Lizenz haelt (er also selbst ein
Sub-Lizenzserver ist), einen zusaetzlichen Abschnitt mit dessen
GEMELDETEN eigenen Kunden/Tickets/Lizenzen (siehe reported_sub_*-
Tabellen, befuellt durch _ingest_sub_report() bei jedem Heartbeat
dieses Sub-Lizenzservers). Rein informativ/read-only -- dieser Master
verwaltet die gemeldeten Datensaetze nicht selbst."""
if not current_user.can_manage_customers:
flash("Keine Berechtigung, Kunden anzusehen.", "danger")
return redirect(url_for("index"))
conn = get_db_connection()
customer = _customer_row(conn, customer_id)
if not customer:
conn.close()
flash("Kunde nicht gefunden.", "danger")
return redirect(url_for("customers"))
own_tickets = conn.execute(
"SELECT * FROM license_tickets WHERE customer_id=? ORDER BY created_at DESC", (customer_id,)
).fetchall()
now = time.time()
tickets_view = []
for t in own_tickets:
d = dict(t)
d["modules_list"] = json.loads(d.get("modules") or "[]")
current = _latest_license_for_ticket(conn, t["id"])
d["license"] = _license_dict_for_display(current, now) if current else None
tickets_view.append(d)
# Ist dieser Kunde selbst ein (aktiver) Sub-Lizenzserver? Referenz fuer
# die reported_sub_*-Zeilen ist die license_id der role='license_server'-
# Lizenz, die dieser Kunde von UNS erhalten hat -- nicht seine eigene,
# bei ihm lokal laufende Lizenz.
sub_license_row = conn.execute(
"SELECT * FROM licenses WHERE customer_id=? AND role='license_server' AND status='active' "
"ORDER BY activated_at DESC LIMIT 1", (customer_id,)
).fetchone()
reported_customers, reported_tickets_by_customer, reported_licenses_by_ticket = [], {}, {}
if sub_license_row:
source_license_id = sub_license_row["license_id"]
reported_customers = [
dict(r) for r in conn.execute(
"SELECT * FROM reported_sub_customers WHERE source_license_id=? ORDER BY name COLLATE NOCASE",
(source_license_id,),
).fetchall()
]
for t in conn.execute(
"SELECT * FROM reported_sub_tickets WHERE source_license_id=? ORDER BY created_at DESC",
(source_license_id,),
).fetchall():
reported_tickets_by_customer.setdefault(t["remote_customer_ref"], []).append(dict(t))
for l in conn.execute(
"SELECT * FROM reported_sub_licenses WHERE source_license_id=? ORDER BY issued_at DESC",
(source_license_id,),
).fetchall():
# _license_dict_for_display() erwartet dieselben Spaltennamen
# (expires_at/lifetime/status/modules), die reported_sub_licenses
# bewusst 1:1 uebernimmt -- liefert damit automatisch dieselbe
# Ampelfarbe/Resttage-Berechnung wie fuer eigene Lizenzen.
reported_licenses_by_ticket.setdefault(l["remote_ticket_id"], []).append(
_license_dict_for_display(l, now)
)
conn.close()
return render_template(
"customer_detail.html", customer=customer, tickets=tickets_view,
sub_license=dict(sub_license_row) if sub_license_row else None,
reported_customers=reported_customers,
reported_tickets_by_customer=reported_tickets_by_customer,
reported_licenses_by_ticket=reported_licenses_by_ticket,
type_labels=LICENSE_TYPE_LABELS,
can_edit=current_user.has_permission("customers.edit"),
)
@app.route("/tickets/new", methods=["GET", "POST"])
@login_required
def ticket_new():
@@ -2871,6 +3140,7 @@ def ticket_edit(ticket_id):
"ticket.update", customer["name"] if customer else ticket_id,
f"{LICENSE_TYPE_LABELS.get(license_type, license_type)}, {duration_label}",
)
_report_to_master_async()
flash("Ticket aktualisiert -- gilt für die nächste daraus erstellte Lizenz.", "success")
return redirect(url_for("ticket_detail", ticket_id=ticket_id))
@@ -2916,6 +3186,7 @@ def ticket_delete(ticket_id):
conn.commit()
conn.close()
log_action("ticket.delete", customer["name"] if customer else ticket_id)
_report_to_master_async()
flash("Ticket gelöscht.", "success")
return redirect(url_for("tickets_list"))
@@ -3132,6 +3403,7 @@ def license_delete(license_id):
conn.commit()
conn.close()
log_action("license.delete", customer["name"] if customer else license_id, f"Status war: {row['status']}")
_report_to_master_async()
flash("Lizenz gelöscht.", "success")
if ticket_id:
return redirect(url_for("ticket_detail", ticket_id=ticket_id))
+43
View File
@@ -180,6 +180,49 @@ CREATE TABLE IF NOT EXISTS portal_access_tokens (
);
""")
c.execute("""
CREATE TABLE IF NOT EXISTS reported_sub_customers (
id INTEGER PRIMARY KEY AUTOINCREMENT,
source_license_id TEXT NOT NULL,
remote_customer_ref TEXT NOT NULL,
name TEXT NOT NULL,
contact_email TEXT, contact_phone TEXT, contact_person TEXT,
street TEXT, house_number TEXT, postal_code TEXT, city TEXT,
last_reported_at TEXT NOT NULL,
UNIQUE (source_license_id, remote_customer_ref)
);
""")
c.execute("""
CREATE TABLE IF NOT EXISTS reported_sub_tickets (
id INTEGER PRIMARY KEY AUTOINCREMENT,
source_license_id TEXT NOT NULL,
remote_ticket_id TEXT NOT NULL,
remote_customer_ref TEXT NOT NULL,
type TEXT NOT NULL, modules TEXT NOT NULL DEFAULT '[]',
valid_days INTEGER, lifetime INTEGER NOT NULL DEFAULT 0,
status TEXT NOT NULL DEFAULT 'open', role TEXT NOT NULL DEFAULT 'customer',
created_at TEXT,
last_reported_at TEXT NOT NULL,
UNIQUE (source_license_id, remote_ticket_id)
);
""")
c.execute("""
CREATE TABLE IF NOT EXISTS reported_sub_licenses (
id INTEGER PRIMARY KEY AUTOINCREMENT,
source_license_id TEXT NOT NULL,
remote_license_id TEXT NOT NULL,
remote_ticket_id TEXT,
type TEXT NOT NULL, modules TEXT NOT NULL DEFAULT '[]',
issued_at TEXT, expires_at TEXT, status TEXT NOT NULL, lifetime INTEGER NOT NULL DEFAULT 0,
fingerprint TEXT, hostname TEXT, activated_at TEXT, last_heartbeat_at TEXT,
last_reported_at TEXT NOT NULL,
UNIQUE (source_license_id, remote_license_id)
);
""")
c.execute("CREATE INDEX IF NOT EXISTS idx_reported_sub_customers_source ON reported_sub_customers(source_license_id)")
c.execute("CREATE INDEX IF NOT EXISTS idx_reported_sub_tickets_source ON reported_sub_tickets(source_license_id)")
c.execute("CREATE INDEX IF NOT EXISTS idx_reported_sub_licenses_source ON reported_sub_licenses(source_license_id)")
c.execute("INSERT OR IGNORE INTO settings (key, value) VALUES (?, ?)", ("interval", "5"))
cur = c.execute("INSERT OR IGNORE INTO groups (name, is_default, is_system) VALUES (?, 1, 1)", ("Benutzer",))
+17 -2
View File
@@ -275,11 +275,24 @@ def system_fingerprint():
# gebunden ist, siehe Plan-Abschnitt "Warum das die Doppelnutzung wirksam
# verhindert".
def build_client_request(action, license_file, nonce=None) -> dict:
def build_client_request(action, license_file, nonce=None, report=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."""
zusätzlich mit encode_code() in einen kopierbaren Code umgewandelt.
report: NUR für Heartbeats einer role="license_server"-Lizenz gedacht
(siehe Lizenzserver-app.py _build_own_issuance_report()) -- ein
optionaler Schnappschuss der eigenen Kunden/Tickets/Lizenzen dieses
Sub-Lizenzservers, den der Master beim Empfang in seine
reported_sub_*-Tabellen übernimmt, um einen Gesamtüberblick zu haben.
Bewusst Teil DIESER generischen, gemeinsam genutzten Funktion (nicht
ein eigenes Protokoll-Feature) -- ein normaler TESM-Client/eine
"customer"-Lizenz übergibt hier einfach nie einen Wert, das Feld fehlt
dann in der Payload wie bisher (keine Verhaltensänderung für alle
bestehenden Aufrufer). Wie jedes andere Feld wird auch report
mitsigniert -- ein Angreifer ohne den privaten Lizenzschlüssel kann
keine gefälschten Berichte unterschieben."""
if action not in ("activate", "deactivate", "heartbeat"):
raise ValueError(f"Unbekannte Aktion: {action!r}")
payload = {
@@ -296,6 +309,8 @@ def build_client_request(action, license_file, nonce=None) -> dict:
"nonce": nonce or uuid.uuid4().hex,
"timestamp": _iso(time.time()),
}
if report is not None:
payload["report"] = report
signature = sign_payload(payload, license_file["license_privkey"])
return {**payload, "signature": signature}
@@ -0,0 +1,137 @@
{% extends "base.html" %}
{% set active_page = "customers" %}
{% block page_title %}Kunde{% endblock %}
{% block page_sub %}<div class="topbar-sub">{{ customer.name }}</div>{% endblock %}
{% block content %}
{% import "_hint_icon.html" as hi %}
<div class="section-head" style="margin-bottom:16px;">
<div>
<h2 style="font-size:16px;">
{{ customer.name }}
{% if sub_license %}<span class="pill admin" style="margin-left:6px;">Lizenzserver</span>{% endif %}
</h2>
</div>
<a href="{{ url_for('customers') }}" class="btn btn-secondary">← Alle Kunden</a>
</div>
<div class="settings-grid">
<div class="card card-pad">
<div class="section-head" style="margin-bottom:16px;">
<div><h2 style="font-size:16px;">Stammdaten</h2></div>
{% if can_edit %}
<a href="{{ url_for('customers') }}" class="btn btn-sm btn-secondary">Bearbeiten</a>
{% endif %}
</div>
<div class="field"><label>E-Mail</label><input type="text" value="{{ customer.contact_email or '—' }}" disabled></div>
<div class="field"><label>Telefon</label><input type="text" value="{{ customer.contact_phone or '—' }}" disabled></div>
<div class="field"><label>Ansprechpartner</label><input type="text" value="{{ customer.contact_person or '—' }}" disabled></div>
<div class="field">
<label>Anschrift</label>
<input type="text" value="{{ [customer.street, customer.house_number]|select|join(' ') or '—' }}{{ ', ' ~ [customer.postal_code, customer.city]|select|join(' ') if customer.postal_code or customer.city else '' }}" disabled>
</div>
{% if customer.notes %}
<div class="field"><label>Notizen</label><textarea disabled rows="3">{{ customer.notes }}</textarea></div>
{% endif %}
</div>
<div class="card card-pad">
<div class="section-head" style="margin-bottom:16px;">
<div><h2 style="font-size:16px;">Eigene Tickets bei uns</h2></div>
<a href="{{ url_for('ticket_new') }}?customer_id={{ customer.id }}" class="btn btn-sm btn-secondary">Neues Ticket</a>
</div>
{% if tickets %}
<div style="overflow-x:auto;">
<table class="data-table">
<thead><tr><th>Typ</th><th>Rolle</th><th>Ticket</th><th>Lizenz-Status</th></tr></thead>
<tbody>
{% for t in tickets %}
<tr>
<td>{{ type_labels.get(t.type, t.type) }}</td>
<td>{% if t.role == 'license_server' %}<span class="pill admin">Lizenzserver</span>{% else %}Kunde{% endif %}</td>
<td>
<a href="{{ url_for('ticket_detail', ticket_id=t.id) }}" class="pill {{ 'online' if t.status == 'open' else 'offline' }}">
{{ 'Offen' if t.status == 'open' else 'Gesperrt' }}
</a>
</td>
<td>
{% if t.license %}
<span class="pill {{ {'active': 'online', 'issued': 'unknown', 'deactivated': 'disabled', 'revoked': 'offline'}.get(t.license.status, 'disabled') }}">
{{ {"issued": "Ausgestellt", "active": "Aktiv", "deactivated": "Deaktiviert", "revoked": "Widerrufen"}.get(t.license.status, t.license.status) }}
</span>
{% else %}<span class="text-faint">Noch keine Lizenz</span>{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<p class="text-faint" style="font-size:12.5px;">Noch keine Tickets für diesen Kunden.</p>
{% endif %}
</div>
</div>
{% if sub_license %}
<div class="card card-pad" style="margin-top:20px;">
<div class="section-head" style="margin-bottom:16px;">
<div>
<h2 style="font-size:16px;">Von diesem Lizenzserver verwaltete Kunden</h2>
<div class="hint">
Gemeldet vom Sub-Lizenzserver selbst bei jedem Heartbeat {{ hi.hint_icon("Rein informativ -- diese Daten werden nicht von uns verwaltet, sondern vom Sub-Lizenzserver selbst bei jedem Heartbeat (alle 6h, zusaetzlich sofort nach jeder dortigen Aenderung) als vollstaendiger Schnappschuss gemeldet und hier nur angezeigt.", "Gemeldete Daten") }}
{% if sub_license.last_heartbeat_at %}— zuletzt {{ sub_license.last_heartbeat_at }}{% else %}— noch kein Heartbeat empfangen{% endif %}
</div>
</div>
</div>
{% if reported_customers %}
{% for rc in reported_customers %}
<div class="card card-pad" style="margin-bottom:12px; background:var(--bg-elevated);">
<div class="section-head" style="margin-bottom:10px;">
<div><strong>{{ rc.name }}</strong> <span class="text-dim" style="font-size:12px;">{{ rc.contact_email or '' }}</span></div>
</div>
{% set rtickets = reported_tickets_by_customer.get(rc.remote_customer_ref, []) %}
{% if rtickets %}
<div style="overflow-x:auto;">
<table class="data-table">
<thead><tr><th>Typ</th><th>Laufzeit</th><th>Ticket-Status</th><th>Lizenz-Status</th><th>Ablauf</th></tr></thead>
<tbody>
{% for rt in rtickets %}
{% set rlicenses = reported_licenses_by_ticket.get(rt.remote_ticket_id, []) %}
{% set rl = rlicenses[0] if rlicenses else none %}
<tr>
<td>{{ type_labels.get(rt.type, rt.type) }}</td>
<td class="text-dim">{{ 'Lifetime' if rt.lifetime else (rt.valid_days ~ ' Tage' if rt.valid_days else '—') }}</td>
<td><span class="pill {{ 'online' if rt.status == 'open' else 'offline' }}">{{ 'Offen' if rt.status == 'open' else 'Gesperrt' }}</span></td>
{% if rl %}
<td>
<span class="pill {{ {'active': 'online', 'issued': 'unknown', 'deactivated': 'disabled', 'revoked': 'offline'}.get(rl.status, 'disabled') }}">
{{ {"issued": "Ausgestellt", "active": "Aktiv", "deactivated": "Deaktiviert", "revoked": "Widerrufen"}.get(rl.status, rl.status) }}
</span>
</td>
<td class="text-dim">
{% if rl.is_lifetime %}Lifetime
{% elif rl.days_left is not none %}
<span style="{{ 'color:var(--danger);' if rl.color == 'red' else ('color:var(--warning);' if rl.color == 'orange' else '') }}">{{ rl.days_left }} Tage</span>
{% else %}—{% endif %}
</td>
{% else %}
<td class="text-faint" colspan="2">Noch keine Lizenz</td>
{% endif %}
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<p class="text-faint" style="font-size:12px;">Noch keine Tickets gemeldet.</p>
{% endif %}
</div>
{% endfor %}
{% else %}
<p class="text-faint" style="font-size:12.5px;">Dieser Lizenzserver hat noch keine eigenen Kunden gemeldet.</p>
{% endif %}
</div>
{% endif %}
{% endblock %}
+4 -1
View File
@@ -37,7 +37,10 @@
{% for c in customers %}
<tbody data-sort-name="{{ c.name|lower }}" data-sort-address="{{ (c.city or '')|lower }}" data-sort-email="{{ (c.contact_email or '')|lower }}" data-sort-tickets="{{ c.ticket_count }}" data-sort-licenses="{{ c.license_count }}">
<tr>
<td class="cell-name">{{ c.name }}</td>
<td class="cell-name">
<a href="{{ url_for('customer_detail', customer_id=c.id) }}">{{ c.name }}</a>
{% if c.is_sub_license_server %}<span class="pill admin" style="margin-left:6px;">Lizenzserver</span>{% endif %}
</td>
<td class="text-dim">
{% if c.street or c.city %}
{{ c.street }} {{ c.house_number }}{% if c.street %}<br>{% endif %}{{ c.postal_code }} {{ c.city }}