Compare commits

2 Commits
Author SHA1 Message Date
alientimandClaude Sonnet 5 c130c85b17 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>
2026-08-23 22:45:17 +02:00
alientimandClaude Sonnet 5 ed2ed00a4e Sub-Lizenzserver: Lizenzen fuer Lizenzserver ausstellbar (role='license_server')
Der Master kann jetzt ueber den normalen Kunde->Ticket->Lizenz-Flow
Lizenzen ausstellen, die eine ANDERE Lizenzserver-Instanz voll
funktionsfaehig machen (Kunden/Tickets/Lizenzen fuer eigene Kunden
ausstellen, Benutzer/Gruppen/Einstellungen verwalten) -- mit genau
einer strukturellen Einschraenkung: der so lizenzierte Sub-Lizenzserver
kann selbst KEINE weiteren Lizenzserver-Lizenzen ausstellen. Die
eigene Lizenz DIESES Masters bleibt weiterhin ausschliesslich per
create_master_license.py erzeugbar, nie ueber die Ticket-UI.

- licensing.py: neues, signiertes role-Feld (master/license_server/
  customer), siehe separates TESM-Release fuer die synchronisierte
  Kopie (harte Invariante: beide Dateien bleiben identisch).
- create_master_license.py: setzt role='master' beim Selbstausstellen.
- Neue STRUKTURELLE Sperre can_issue_license_server_licenses(): True
  NUR wenn die eigene, aktive Lizenz dieser Instanz role=='master' ist
  -- haengt an der Instanz-Rolle, nicht an einer vergebbaren Permission,
  daher von lokalen Admins eines Sub-Lizenzservers nicht aufhebbar.
  Kombiniert mit current_user.is_admin an jeder Aufrufstelle (nur echte
  Admins duerfen es je nutzen, wie gewuenscht -- kein delegierbares
  Recht).
- license_tickets.role-Spalte (Migration, Default 'customer').
  license_tickets.role bestimmt, welche Rolle eine daraus ausgestellte
  Lizenz traegt -- nach Anlage unveraendert wie customer_id.
- ticket_new(): neue, nur fuer can_issue_license_server_licenses()+Admin
  sichtbare Option 'Dies ist eine Lizenzserver-Lizenz'. Jeder andere
  Versuch (manipuliertes Formular, Sub-Lizenzserver, Nicht-Admin) wird
  serverseitig still auf 'customer' zurueckgestuft. Module werden fuer
  role!='customer' erzwungen auf [] (TESM-Client-Konzept, fuer einen
  Lizenzserver bedeutungslos).
- Self-Service (unauthentifiziert, nur ueber die Ticket-ID) ist fuer
  role!='customer'-Tickets komplett gesperrt (404) -- Lizenzserver-
  Lizenzen sind ausschliesslich admin-verwaltet, kein Kundenweg. Trial-
  Ausstellung ('30 Tage, alle Module') ebenfalls nur fuer role=customer
  moeglich (ValueError sonst, sauber abgefangen statt 500er).
- license_send_email(): ueberspringt Self-Service-/Kundenportal-Hinweise
  fuer role='license_server'-Lizenzen (waeren ohnehin blockiert).
- UI: 'Lizenzserver'-Badge auf Tickets-/Lizenzen-Liste und Ticket-
  Detailseite.

Getestet: licensing.py isoliert (role signiert+verifiziert, Manipulation
bricht die Signatur, Altformat ohne role faellt auf 'customer' zurueck),
can_issue_license_server_licenses()-Logik (6 Kombinationen), Schema-
Migration (Alt-DB ohne role-Spalte, idempotent), Ende-zu-Ende Ticket->
Lizenz-Ausstellung gegen echte create_db.py-DB. ZUSAETZLICH gegen einen
echten, lokal laufenden Flask-Server (native Windows-Python, WSL-
Netzwerksandbox in dieser Umgebung unzuverlaessig) voll durchgespielt:
Checkbox erscheint nur fuer role=master, Ticket+Lizenz-Ausstellung mit
role=license_server funktioniert Ende-zu-Ende (signierte Datei, Module
leer, Self-Service liefert 404), UND der Sicherheitstest: nach Wechsel
der eigenen Lizenz auf role=license_server verschwindet die Checkbox
UND ein direkter manipulierter POST mit role=license_server wird
serverseitig zuverlaessig auf 'customer' zurueckgestuft.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-23 22:08:19 +02:00
11 changed files with 690 additions and 42 deletions
+1 -1
View File
@@ -1 +1 @@
1.2.5 1.2.7
+389 -31
View File
@@ -804,6 +804,7 @@ def _ensure_schema():
valid_days INTEGER NOT NULL, valid_days INTEGER NOT NULL,
lifetime INTEGER NOT NULL DEFAULT 0, lifetime INTEGER NOT NULL DEFAULT 0,
status TEXT NOT NULL DEFAULT 'open', status TEXT NOT NULL DEFAULT 'open',
role TEXT NOT NULL DEFAULT 'customer',
created_by TEXT, created_by TEXT,
created_at TEXT NOT NULL, created_at TEXT NOT NULL,
FOREIGN KEY (customer_id) REFERENCES license_customers(id) FOREIGN KEY (customer_id) REFERENCES license_customers(id)
@@ -812,6 +813,13 @@ def _ensure_schema():
ticket_cols = {row["name"] for row in conn.execute("PRAGMA table_info(license_tickets)").fetchall()} ticket_cols = {row["name"] for row in conn.execute("PRAGMA table_info(license_tickets)").fetchall()}
if "lifetime" not in ticket_cols: if "lifetime" not in ticket_cols:
conn.execute("ALTER TABLE license_tickets ADD COLUMN lifetime INTEGER NOT NULL DEFAULT 0") conn.execute("ALTER TABLE license_tickets ADD COLUMN lifetime INTEGER NOT NULL DEFAULT 0")
if "role" not in ticket_cols:
# Rolle der aus diesem Ticket auszustellenden Lizenz (siehe
# licensing.LICENSE_ROLES) -- "customer" (Default) fuer gewoehnliche
# TESM-Kundeninstanzen, "license_server" fuer einen damit
# auszustellenden Sub-Lizenzserver. Bestehende Tickets bleiben
# unveraendert "customer".
conn.execute("ALTER TABLE license_tickets ADD COLUMN role TEXT NOT NULL DEFAULT 'customer'")
# DB-seitiger Schutz gegen die Doppel-Ausstellung eines Tickets: ohne # DB-seitiger Schutz gegen die Doppel-Ausstellung eines Tickets: ohne
# dieses Backstop koennten zwei nahezu gleichzeitige Anfragen (z.B. ein # dieses Backstop koennten zwei nahezu gleichzeitige Anfragen (z.B. ein
@@ -873,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 # Rückwirkend ein Ticket für jede Lizenz nachbilden, die vor Einführung
# des Ticket-Konzepts direkt ausgestellt wurde (ticket_id noch NULL) -- # des Ticket-Konzepts direkt ausgestellt wurde (ticket_id noch NULL) --
# sonst würden genau diese Lizenzen (auf einer echten Installation z.B. # sonst würden genau diese Lizenzen (auf einer echten Installation z.B.
@@ -1887,6 +1953,24 @@ def module_licensed(module_key):
return license_active() and module_key in _license_state["status"]["modules"] return license_active() and module_key in _license_state["status"]["modules"]
def can_issue_license_server_licenses():
"""STRUKTURELLE Sperre (siehe licensing.LICENSE_ROLES-Kommentar), keine
vergebbare Permission: True nur, wenn die EIGENE, gerade aktive Lizenz
dieser Instanz role=='master' ist -- also ausschließlich auf der
tatsächlichen Wurzel-Instanz (deren Lizenz per create_master_license.py
lokal erzeugt wurde), niemals auf einem per role='license_server'
lizenzierten Sub-Lizenzserver. Ein Sub-Lizenzserver liefert hier immer
False zurück, unabhängig davon, welche Gruppenrechte seine lokalen
Admins dort einrichten -- diese Sperre lässt sich also NICHT durch
lokale Rechtevergabe aufheben. Aufrufer müssen zusätzlich
current_user.is_admin selbst prüfen (siehe ticket_new()) -- diese
Funktion prüft nur die Instanz-Rolle, keine Benutzerrechte."""
return bool(
license_active()
and _license_state["status"]["role"] == "master"
)
def _require_module(module_key): def _require_module(module_key):
"""Serverseitiger Guard für die drei komplett ausgeblendeten Vollmodule """Serverseitiger Guard für die drei komplett ausgeblendeten Vollmodule
(dhcp/fileshare/maintenance) -- ein direkter URL-Aufruf ohne Lizenz (dhcp/fileshare/maintenance) -- ein direkter URL-Aufruf ohne Lizenz
@@ -1936,6 +2020,56 @@ def _license_api_post(endpoint, path, payload, timeout=10):
return json.loads(resp.read().decode("utf-8")) 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(): def _send_heartbeat():
"""Ein einzelner Heartbeat-Versuch gegen den in der Lizenz hinterlegten """Ein einzelner Heartbeat-Versuch gegen den in der Lizenz hinterlegten
master_endpoint. Bei Erfolg: license_last_heartbeat_at aktualisieren master_endpoint. Bei Erfolg: license_last_heartbeat_at aktualisieren
@@ -1950,7 +2084,14 @@ def _send_heartbeat():
endpoint = license_file.get("master_endpoint", "") endpoint = license_file.get("master_endpoint", "")
if not endpoint: if not endpoint:
return 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: try:
response_payload = _license_api_post(endpoint, "/api/heartbeat", request_payload) response_payload = _license_api_post(endpoint, "/api/heartbeat", request_payload)
except Exception as exc: except Exception as exc:
@@ -2160,7 +2301,7 @@ def _ticket_has_open_license(conn, ticket_id):
return bool(current and current["status"] in ("issued", "active")) return bool(current and current["status"] in ("issued", "active"))
def _create_ticket(conn, customer_id, license_type, modules, valid_days, actor, lifetime=False): def _create_ticket(conn, customer_id, license_type, modules, valid_days, actor, lifetime=False, role="customer"):
"""Legt ein neues Ticket an -- die dauerhafte, dem Kunden gehoerende """Legt ein neues Ticket an -- die dauerhafte, dem Kunden gehoerende
Berechtigung auf eine Lizenz mit diesen Eckdaten. Erzeugt für sich Berechtigung auf eine Lizenz mit diesen Eckdaten. Erzeugt für sich
genommen noch KEINE Lizenzdatei, siehe _issue_license_from_ticket. genommen noch KEINE Lizenzdatei, siehe _issue_license_from_ticket.
@@ -2168,21 +2309,27 @@ def _create_ticket(conn, customer_id, license_type, modules, valid_days, actor,
aus diesem Ticket (Trial-Ausstellungen bleiben davon unberuehrt, siehe aus diesem Ticket (Trial-Ausstellungen bleiben davon unberuehrt, siehe
_issue_license_from_ticket) -- der Wert wird trotzdem gespeichert, _issue_license_from_ticket) -- der Wert wird trotzdem gespeichert,
damit ein spaeteres Zurückschalten auf eine normale Laufzeit nicht bei damit ein spaeteres Zurückschalten auf eine normale Laufzeit nicht bei
0/NULL anfangen muss.""" 0/NULL anfangen muss. role (siehe licensing.LICENSE_ROLES) bestimmt,
welche Rolle eine aus diesem Ticket ausgestellte Lizenz traegt --
Aufrufer MUESSEN role='license_server' bereits selbst gegen
can_issue_license_server_licenses() + current_user.is_admin geprueft
haben (siehe ticket_new()), diese Funktion vertraut dem Aufrufer."""
ticket_id = secrets.token_urlsafe(24) ticket_id = secrets.token_urlsafe(24)
now = datetime.now().strftime("%Y-%m-%d %H:%M:%S") now = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
conn.execute( conn.execute(
"INSERT INTO license_tickets (id, customer_id, type, modules, valid_days, lifetime, status, created_by, created_at) " "INSERT INTO license_tickets (id, customer_id, type, modules, valid_days, lifetime, role, status, created_by, created_at) "
"VALUES (?, ?, ?, ?, ?, ?, 'open', ?, ?)", "VALUES (?, ?, ?, ?, ?, ?, ?, 'open', ?, ?)",
(ticket_id, customer_id, license_type, json.dumps(sorted(modules)), valid_days, int(bool(lifetime)), actor, now), (ticket_id, customer_id, license_type, json.dumps(sorted(modules)), valid_days, int(bool(lifetime)), role, actor, now),
) )
conn.commit() conn.commit()
customer = _customer_row(conn, customer_id) customer = _customer_row(conn, customer_id)
duration_label = "Lifetime" if lifetime else f"{valid_days} Tage" duration_label = "Lifetime" if lifetime else f"{valid_days} Tage"
role_label = " -- LIZENZSERVER" if role == "license_server" else ""
log_action_system( log_action_system(
"ticket.create", customer["name"] if customer else str(customer_id), "ticket.create", customer["name"] if customer else str(customer_id),
f"{LICENSE_TYPE_LABELS.get(license_type, license_type)}, {duration_label} (Akteur: {actor})", f"{LICENSE_TYPE_LABELS.get(license_type, license_type)}, {duration_label}{role_label} (Akteur: {actor})",
) )
_report_to_master_async()
return ticket_id return ticket_id
@@ -2197,7 +2344,14 @@ def _issue_license_from_ticket(conn, ticket_id, actor, as_trial=False):
Laufzeit) bleibt dabei unverändert, nur DIESE eine Ausstellung nutzt Laufzeit) bleibt dabei unverändert, nur DIESE eine Ausstellung nutzt
Trial-Konditionen.""" Trial-Konditionen."""
ticket = _ticket_row(conn, ticket_id) ticket = _ticket_row(conn, ticket_id)
ticket_role = ticket["role"] if "role" in ticket.keys() else "customer"
if as_trial: if as_trial:
if ticket_role != "customer":
# Trial ("30 Tage, alle TESM-Module") ist ein reines
# Kunden-Konzept -- ALL_MODULES (dhcp/fileshare/maintenance)
# ist fuer eine Lizenzserver-Lizenz bedeutungslos. Klarer
# Fehler statt einer stillschweigend falsch befuellten Lizenz.
raise ValueError("Trial-Ausstellung ist nur fuer Kunden-Tickets moeglich.")
# Trial ist immer ein zeitlich kurzer Override, unabhaengig davon # Trial ist immer ein zeitlich kurzer Override, unabhaengig davon
# ob das Ticket selbst als Lifetime konfiguriert ist -- siehe # ob das Ticket selbst als Lifetime konfiguriert ist -- siehe
# Modul-Docstring oben. # Modul-Docstring oben.
@@ -2226,17 +2380,18 @@ def _issue_license_from_ticket(conn, ticket_id, actor, as_trial=False):
master_endpoint=_get_master_endpoint(), master_endpoint=_get_master_endpoint(),
vendor=_get_vendor_info(), vendor=_get_vendor_info(),
lifetime=lifetime, lifetime=lifetime,
role=ticket_role,
) )
now = datetime.now().strftime("%Y-%m-%d %H:%M:%S") now = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
try: try:
conn.execute( conn.execute(
"INSERT INTO licenses (license_id, customer_id, type, modules, issued_at, expires_at, " "INSERT INTO licenses (license_id, customer_id, type, modules, issued_at, expires_at, "
"license_pubkey, license_file_json, status, role, ticket_id, lifetime, created_by, created_at) " "license_pubkey, license_file_json, status, role, ticket_id, lifetime, created_by, created_at) "
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'issued', 'customer', ?, ?, ?, ?)", "VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'issued', ?, ?, ?, ?, ?)",
( (
license_file["license_id"], ticket["customer_id"], license_type, json.dumps(sorted(modules)), license_file["license_id"], ticket["customer_id"], license_type, json.dumps(sorted(modules)),
license_file["issued_at"], license_file["expires_at"], license_pubkey, license_file["issued_at"], license_file["expires_at"], license_pubkey,
json.dumps(license_file), ticket_id, int(lifetime), actor, now, json.dumps(license_file), ticket_role, ticket_id, int(lifetime), actor, now,
), ),
) )
conn.commit() conn.commit()
@@ -2260,6 +2415,7 @@ def _issue_license_from_ticket(conn, ticket_id, actor, as_trial=False):
"license.issue", customer["name"] if customer else "?", "license.issue", customer["name"] if customer else "?",
f"{LICENSE_TYPE_LABELS.get(license_type, license_type)}, {expiry_label} (Ticket, Akteur: {actor})", f"{LICENSE_TYPE_LABELS.get(license_type, license_type)}, {expiry_label} (Ticket, Akteur: {actor})",
) )
_report_to_master_async()
return license_file return license_file
@@ -2280,6 +2436,7 @@ def _revoke_license_row(conn, license_row, actor):
"license.revoke", customer["name"] if customer else license_row["license_id"], "license.revoke", customer["name"] if customer else license_row["license_id"],
f"Akteur: {actor}", f"Akteur: {actor}",
) )
_report_to_master_async()
# --- Aktivierungs-/Deaktivierungs-/Heartbeat-Verarbeitung ------------------- # --- Aktivierungs-/Deaktivierungs-/Heartbeat-Verarbeitung -------------------
@@ -2395,6 +2552,64 @@ def _process_deactivate(data):
return licensing.build_master_response("deactivated", license_id, fingerprint, MASTER_PRIVATE_KEY, status="ok"), 200 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): def _process_heartbeat(data):
license_id = data.get("license_id") license_id = data.get("license_id")
conn = get_db_connection() conn = get_db_connection()
@@ -2427,6 +2642,14 @@ def _process_heartbeat(data):
(now, hostname, license_id), (now, hostname, license_id),
) )
conn.commit() 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"]) license_update = json.loads(row["license_file_json"])
conn.close() conn.close()
return licensing.build_master_response( return licensing.build_master_response(
@@ -2583,11 +2806,17 @@ def customers():
log_action("customer.delete", str(customer_id)) log_action("customer.delete", str(customer_id))
flash("Kunde gelöscht.", "success") flash("Kunde gelöscht.", "success")
conn.close() 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")) return redirect(url_for("customers"))
customers_rows = conn.execute(""" customers_rows = conn.execute("""
SELECT license_customers.*, COUNT(DISTINCT license_tickets.id) AS ticket_count, 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 FROM license_customers
LEFT JOIN license_tickets ON license_tickets.customer_id = license_customers.id LEFT JOIN license_tickets ON license_tickets.customer_id = license_customers.id
LEFT JOIN licenses ON licenses.customer_id = license_customers.id LEFT JOIN licenses ON licenses.customer_id = license_customers.id
@@ -2602,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"]) @app.route("/tickets/new", methods=["GET", "POST"])
@login_required @login_required
def ticket_new(): def ticket_new():
@@ -2642,12 +2950,31 @@ def ticket_new():
conn.close() conn.close()
return redirect(url_for("ticket_new")) return redirect(url_for("ticket_new"))
# role='license_server' darf NUR ein echter Admin auf einer
# tatsaechlichen "master"-Instanz waehlen -- fest einprogrammiert,
# nicht ueber ein delegierbares Recht (siehe
# can_issue_license_server_licenses()-Docstring). Jeder andere
# Versuch (manipuliertes Formular, Sub-Lizenzserver, Nicht-Admin)
# wird still auf 'customer' zurueckgestuft statt nur die UI
# auszublenden -- Verteidigung in der Tiefe.
role = request.form.get("role", "customer")
if role == "license_server" and not (current_user.is_admin and can_issue_license_server_licenses()):
flash("Keine Berechtigung, Lizenzserver-Lizenzen auszustellen.", "danger")
role = "customer"
if role not in licensing.LICENSE_ROLES or role == "master":
role = "customer"
license_type = request.form.get("license_type", "standard") license_type = request.form.get("license_type", "standard")
if license_type not in licensing.LICENSE_TYPES: if license_type not in licensing.LICENSE_TYPES:
flash("Ungültiger Lizenztyp.", "danger") flash("Ungültiger Lizenztyp.", "danger")
conn.close() conn.close()
return redirect(url_for("ticket_new")) return redirect(url_for("ticket_new"))
if license_type == "enterprise": if role != "customer":
# Module (dhcp/fileshare/maintenance) sind ein reines
# TESM-Client-Konzept -- fuer eine Lizenzserver-Lizenz
# bedeutungslos.
modules = []
elif license_type == "enterprise":
modules = list(licensing.ALL_MODULES) modules = list(licensing.ALL_MODULES)
elif license_type == "custom": elif license_type == "custom":
modules = [m for m in request.form.getlist("modules") if m in licensing.ALL_MODULES] modules = [m for m in request.form.getlist("modules") if m in licensing.ALL_MODULES]
@@ -2663,7 +2990,10 @@ def ticket_new():
except ValueError: except ValueError:
valid_days = 365 valid_days = 365
ticket_id = _create_ticket(conn, customer_id, license_type, modules, valid_days, current_user.username, lifetime=lifetime) ticket_id = _create_ticket(
conn, customer_id, license_type, modules, valid_days, current_user.username,
lifetime=lifetime, role=role,
)
conn.close() conn.close()
flash("Ticket angelegt.", "success") flash("Ticket angelegt.", "success")
return redirect(url_for("ticket_detail", ticket_id=ticket_id)) return redirect(url_for("ticket_detail", ticket_id=ticket_id))
@@ -2674,6 +3004,7 @@ def ticket_new():
"ticket_new.html", customers=customers_rows, license_types=licensing.LICENSE_TYPES, "ticket_new.html", customers=customers_rows, license_types=licensing.LICENSE_TYPES,
all_modules=licensing.ALL_MODULES, module_labels=MODULE_LABELS, type_labels=LICENSE_TYPE_LABELS, all_modules=licensing.ALL_MODULES, module_labels=MODULE_LABELS, type_labels=LICENSE_TYPE_LABELS,
preselect_customer_id=request.args.get("customer_id", type=int), preselect_customer_id=request.args.get("customer_id", type=int),
can_issue_license_server=current_user.is_admin and can_issue_license_server_licenses(),
) )
@@ -2706,10 +3037,20 @@ def ticket_detail(ticket_id):
flash("Dieses Ticket hat bereits eine aktive oder ausgestellte Lizenz.", "danger") flash("Dieses Ticket hat bereits eine aktive oder ausgestellte Lizenz.", "danger")
else: else:
as_trial = request.form.get("issue_license") == "trial" as_trial = request.form.get("issue_license") == "trial"
license_file = _issue_license_from_ticket(conn, ticket_id, current_user.username, as_trial=as_trial) try:
conn.close() license_file = _issue_license_from_ticket(conn, ticket_id, current_user.username, as_trial=as_trial)
flash("Lizenz ausgestellt.", "success") except ValueError as exc:
return redirect(url_for("license_detail", license_id=license_file["license_id"])) # z.B. Trial-Override auf einem Lizenzserver-Ticket
# versucht (siehe _issue_license_from_ticket) -- im
# normalen UI-Fluss nicht erreichbar (Button wird für
# role != 'customer' gar nicht erst angezeigt), aber
# eine manipulierte Anfrage soll eine klare Meldung statt
# eines internen Serverfehlers bekommen.
flash(str(exc), "danger")
else:
conn.close()
flash("Lizenz ausgestellt.", "success")
return redirect(url_for("license_detail", license_id=license_file["license_id"]))
elif "toggle_block" in request.form: elif "toggle_block" in request.form:
if not current_user.has_permission("tickets.edit"): if not current_user.has_permission("tickets.edit"):
flash("Keine Berechtigung, Tickets zu sperren/entsperren.", "danger") flash("Keine Berechtigung, Tickets zu sperren/entsperren.", "danger")
@@ -2799,6 +3140,7 @@ def ticket_edit(ticket_id):
"ticket.update", customer["name"] if customer else ticket_id, "ticket.update", customer["name"] if customer else ticket_id,
f"{LICENSE_TYPE_LABELS.get(license_type, license_type)}, {duration_label}", 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") flash("Ticket aktualisiert -- gilt für die nächste daraus erstellte Lizenz.", "success")
return redirect(url_for("ticket_detail", ticket_id=ticket_id)) return redirect(url_for("ticket_detail", ticket_id=ticket_id))
@@ -2844,6 +3186,7 @@ def ticket_delete(ticket_id):
conn.commit() conn.commit()
conn.close() conn.close()
log_action("ticket.delete", customer["name"] if customer else ticket_id) log_action("ticket.delete", customer["name"] if customer else ticket_id)
_report_to_master_async()
flash("Ticket gelöscht.", "success") flash("Ticket gelöscht.", "success")
return redirect(url_for("tickets_list")) return redirect(url_for("tickets_list"))
@@ -2981,22 +3324,28 @@ def license_send_email(license_id):
flash("Für diesen Kunden ist keine E-Mail-Adresse hinterlegt.", "danger") flash("Für diesen Kunden ist keine E-Mail-Adresse hinterlegt.", "danger")
return redirect(url_for("license_detail", license_id=license_id)) return redirect(url_for("license_detail", license_id=license_id))
self_service_note = "" self_service_note = ""
if row["ticket_id"]: if row["role"] == "customer":
self_service_url = _customer_facing_url("self_service", ticket_id=row["ticket_id"]) # Self-Service-Link und Kundenportal-Hinweis sind reine
self_service_note = ( # Kunden-Konzepte -- fuer eine Lizenzserver-Lizenz irrelevant (der
f"\nÜber diesen Link können Sie Ihre Lizenz jederzeit selbst herunterladen, " # Self-Service-Link wuerde dort ohnehin mit 404 abgewiesen, siehe
f"widerrufen oder (z.B. nach einem Systemwechsel) neu erstellen:\n{self_service_url}\n" # self_service()).
if row["ticket_id"]:
self_service_url = _customer_facing_url("self_service", ticket_id=row["ticket_id"])
self_service_note = (
f"\nÜber diesen Link können Sie Ihre Lizenz jederzeit selbst herunterladen, "
f"widerrufen oder (z.B. nach einem Systemwechsel) neu erstellen:\n{self_service_url}\n"
)
portal_request_url = _customer_facing_url("portal_request")
self_service_note += (
f"\nEine Übersicht all Ihrer Tickets/Lizenzen können Sie sich jederzeit unter folgendem Link per "
f"E-Mail zuschicken lassen:\n{portal_request_url}\n"
) )
portal_request_url = _customer_facing_url("portal_request")
self_service_note += (
f"\nEine Übersicht all Ihrer Tickets/Lizenzen können Sie sich jederzeit unter folgendem Link per "
f"E-Mail zuschicken lassen:\n{portal_request_url}\n"
)
valid_until = "unbegrenzt (Lifetime)" if row["lifetime"] else row["expires_at"] valid_until = "unbegrenzt (Lifetime)" if row["lifetime"] else row["expires_at"]
subject_prefix = "Ihre TESM-Lizenzserver-Lizenz" if row["role"] == "license_server" else "Ihre TESM-Lizenz"
try: try:
_graph_send_mail( _graph_send_mail(
customer["contact_email"], customer["contact_email"],
f"Ihre TESM-Lizenz ({LICENSE_TYPE_LABELS.get(row['type'], row['type'])})", f"{subject_prefix} ({LICENSE_TYPE_LABELS.get(row['type'], row['type'])})",
f"Anbei Ihre Lizenzdatei. Bitte auf der Zielinstanz unter Einstellungen → Lizenz hochladen und aktivieren.\n\n" f"Anbei Ihre Lizenzdatei. Bitte auf der Zielinstanz unter Einstellungen → Lizenz hochladen und aktivieren.\n\n"
f"Lizenz-ID: {license_id}\nTyp: {LICENSE_TYPE_LABELS.get(row['type'], row['type'])}\nGültig bis: {valid_until}\n" f"Lizenz-ID: {license_id}\nTyp: {LICENSE_TYPE_LABELS.get(row['type'], row['type'])}\nGültig bis: {valid_until}\n"
f"{self_service_note}", f"{self_service_note}",
@@ -3054,6 +3403,7 @@ def license_delete(license_id):
conn.commit() conn.commit()
conn.close() conn.close()
log_action("license.delete", customer["name"] if customer else license_id, f"Status war: {row['status']}") 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") flash("Lizenz gelöscht.", "success")
if ticket_id: if ticket_id:
return redirect(url_for("ticket_detail", ticket_id=ticket_id)) return redirect(url_for("ticket_detail", ticket_id=ticket_id))
@@ -3073,7 +3423,15 @@ def license_delete(license_id):
def self_service(ticket_id): def self_service(ticket_id):
conn = get_db_connection() conn = get_db_connection()
ticket = _ticket_row(conn, ticket_id) ticket = _ticket_row(conn, ticket_id)
if not ticket: if not ticket or ticket["role"] != "customer":
# Self-Service ist ein KUNDENWEG (unauthentifiziert, Zugriff allein
# ueber die unratbare Ticket-ID) -- Lizenzserver-Lizenzen werden
# ausschliesslich bewusst durch einen Admin auf der Ticket-
# Detailseite ausgestellt/verwaltet, nie ueber diesen Weg. Behandelt
# eine Lizenzserver-Ticket-ID hier bewusst wie "nicht gefunden",
# nicht wie "keine Berechtigung" -- sonst liesse sich allein aus der
# Fehlermeldung ablesen, dass eine geratene/erlangte ID zu einem
# existierenden Lizenzserver-Ticket gehoert.
conn.close() conn.close()
abort(404) abort(404)
customer = _customer_row(conn, ticket["customer_id"]) customer = _customer_row(conn, ticket["customer_id"])
@@ -3094,7 +3452,7 @@ def self_service(ticket_id):
def self_service_download(ticket_id): def self_service_download(ticket_id):
conn = get_db_connection() conn = get_db_connection()
ticket = _ticket_row(conn, ticket_id) ticket = _ticket_row(conn, ticket_id)
if not ticket: if not ticket or ticket["role"] != "customer":
conn.close() conn.close()
abort(404) abort(404)
current = _latest_license_for_ticket(conn, ticket_id) current = _latest_license_for_ticket(conn, ticket_id)
@@ -3112,7 +3470,7 @@ def self_service_download(ticket_id):
def self_service_revoke(ticket_id): def self_service_revoke(ticket_id):
conn = get_db_connection() conn = get_db_connection()
ticket = _ticket_row(conn, ticket_id) ticket = _ticket_row(conn, ticket_id)
if not ticket: if not ticket or ticket["role"] != "customer":
conn.close() conn.close()
abort(404) abort(404)
current = _latest_license_for_ticket(conn, ticket_id) current = _latest_license_for_ticket(conn, ticket_id)
@@ -3130,7 +3488,7 @@ def self_service_revoke(ticket_id):
def self_service_issue(ticket_id): def self_service_issue(ticket_id):
conn = get_db_connection() conn = get_db_connection()
ticket = _ticket_row(conn, ticket_id) ticket = _ticket_row(conn, ticket_id)
if not ticket: if not ticket or ticket["role"] != "customer":
conn.close() conn.close()
abort(404) abort(404)
if not license_active(): if not license_active():
+44
View File
@@ -162,6 +162,7 @@ CREATE TABLE IF NOT EXISTS license_tickets (
valid_days INTEGER NOT NULL, valid_days INTEGER NOT NULL,
lifetime INTEGER NOT NULL DEFAULT 0, lifetime INTEGER NOT NULL DEFAULT 0,
status TEXT NOT NULL DEFAULT 'open', status TEXT NOT NULL DEFAULT 'open',
role TEXT NOT NULL DEFAULT 'customer',
created_by TEXT, created_by TEXT,
created_at TEXT NOT NULL, created_at TEXT NOT NULL,
FOREIGN KEY (customer_id) REFERENCES license_customers(id) FOREIGN KEY (customer_id) REFERENCES license_customers(id)
@@ -179,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")) 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",)) cur = c.execute("INSERT OR IGNORE INTO groups (name, is_default, is_system) VALUES (?, 1, 1)", ("Benutzer",))
@@ -74,6 +74,12 @@ def main():
master_public_key_b64=master_pub, master_public_key_b64=master_pub,
master_endpoint="", master_endpoint="",
vendor={"name": "", "phone": "", "email": "", "address": "", "logo_base64": ""}, vendor={"name": "", "phone": "", "email": "", "address": "", "logo_base64": ""},
# role="master": markiert dies als die Wurzel-Instanz -- NUR dieses
# Skript darf role="master" setzen (siehe LICENSE_ROLES in
# licensing.py). app.py bietet role="master" an keiner Stelle über
# die normale Ticket-/Ausstellungs-UI an; das ist die einzige Rolle,
# die selbst weitere role="license_server"-Lizenzen ausstellen darf.
role="master",
) )
with open(LICENSE_PATH, "w", encoding="utf-8") as f: with open(LICENSE_PATH, "w", encoding="utf-8") as f:
json.dump(license_file, f, indent=2) json.dump(license_file, f, indent=2)
+62 -3
View File
@@ -39,6 +39,31 @@ from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey,
LICENSE_TYPES = ("trial", "standard", "custom", "enterprise") LICENSE_TYPES = ("trial", "standard", "custom", "enterprise")
ALL_MODULES = ("dhcp", "fileshare", "maintenance") ALL_MODULES = ("dhcp", "fileshare", "maintenance")
# Rolle der lizenzierten Instanz, eingebettet in JEDE ausgestellte
# Lizenzdatei (signiert, siehe issue_license) -- NICHT nur DB-seitige
# Buchfuehrung wie das gleichnamige, bislang ungenutzte licenses.role-Feld
# in der Master-DB vorher. Ermoeglicht einer lizenzierten Instanz, anhand
# ihrer EIGENEN, geladenen Lizenz strukturell zu erkennen, was sie darf:
# "master" -- die Wurzel-Instanz. Wird AUSSCHLIESSLICH lokal per
# create_master_license.py erzeugt, NIE ueber die
# normale Ticket-/Ausstellungs-UI -- das waere sonst
# eine Moeglichkeit, sich selbst zur Wurzel zu
# erklaeren. Einzige Rolle, die weitere
# "license_server"-Lizenzen ausstellen darf.
# "license_server" -- ein vom Master (oder einer anderen "master"-Instanz)
# ausgestellter Lizenzserver ("Sub-Lizenzserver").
# Darf alles, was ein Lizenzserver koennen soll
# (Kunden/Tickets/Lizenzen fuer eigene TESM-Kunden
# ausstellen, Benutzer/Gruppen/Einstellungen
# verwalten) -- AUSSER selbst weitere
# "license_server"-Lizenzen ausstellen. Diese Sperre
# ist strukturell (haengt an der eigenen Rolle, nicht
# an einem vergebbaren Recht) und daher auch von einem
# lokalen Admin dieser Instanz nicht aufhebbar.
# "customer" -- eine gewoehnliche TESM-Kundeninstanz (Default,
# unveraendertes Verhalten).
LICENSE_ROLES = ("master", "license_server", "customer")
GRACE_PERIOD_DAYS = 30 # nach Ablauf, bevor lizenzpflichtige Funktionen tatsächlich abgeschaltet werden 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 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) HEARTBEAT_WARNING_DAYS = 14 # Nichterreichbarkeits-Hinweis (rein informativ, schaltet nie etwas ab)
@@ -120,13 +145,23 @@ LIFETIME_EXPIRES_AT = "2099-12-31T00:00:00Z"
def issue_license(*, customer, license_type, modules, valid_days, def issue_license(*, customer, license_type, modules, valid_days,
master_private_key_b64, master_public_key_b64, master_private_key_b64, master_public_key_b64,
master_endpoint, vendor, license_id=None, now=None, lifetime=False): master_endpoint, vendor, license_id=None, now=None, lifetime=False,
role="customer"):
"""Vom MASTER aufgerufen: erzeugt eine neue, signierte Lizenz samt """Vom MASTER aufgerufen: erzeugt eine neue, signierte Lizenz samt
frischem Pro-Lizenz-Schlüsselpaar. frischem Pro-Lizenz-Schlüsselpaar.
lifetime=True ignoriert valid_days komplett und setzt stattdessen das lifetime=True ignoriert valid_days komplett und setzt stattdessen das
feste Sentinel-Datum LIFETIME_EXPIRES_AT -- siehe dortigen Kommentar. feste Sentinel-Datum LIFETIME_EXPIRES_AT -- siehe dortigen Kommentar.
role (siehe LICENSE_ROLES): "customer" (Default, unverändertes
Verhalten) für gewöhnliche TESM-Kundeninstanzen, "license_server" für
einen vom Aufrufer ausgestellten Sub-Lizenzserver. role="master" darf
NUR von create_master_license.py verwendet werden (lokal, ohne diese
Funktion über die normale Ticket-UI aufzurufen) -- diese Funktion prüft
das nicht selbst, das Verbot ist stattdessen dadurch durchgesetzt, dass
die App-seitige Ausstellungsfunktion role="master" gar nicht erst als
Option anbietet (siehe app.py can_issue_license_server_licenses()).
Rückgabe: (license_file, license_pubkey) -- license_file ist die Rückgabe: (license_file, license_pubkey) -- license_file ist die
komplette, an den Kunden auszuhändigende Datei (inkl. dem PRIVATEN komplette, an den Kunden auszuhändigende Datei (inkl. dem PRIVATEN
Lizenzschlüssel); license_pubkey ist NUR für die Master-Datenbank Lizenzschlüssel); license_pubkey ist NUR für die Master-Datenbank
@@ -134,6 +169,8 @@ def issue_license(*, customer, license_type, modules, valid_days,
dieser Lizenz) und wird nicht an den Kunden weitergegeben.""" dieser Lizenz) und wird nicht an den Kunden weitergegeben."""
if license_type not in LICENSE_TYPES: if license_type not in LICENSE_TYPES:
raise ValueError(f"Unbekannter Lizenztyp: {license_type!r} (erlaubt: {LICENSE_TYPES})") raise ValueError(f"Unbekannter Lizenztyp: {license_type!r} (erlaubt: {LICENSE_TYPES})")
if role not in LICENSE_ROLES:
raise ValueError(f"Unbekannte Rolle: {role!r} (erlaubt: {LICENSE_ROLES})")
unknown = set(modules) - set(ALL_MODULES) unknown = set(modules) - set(ALL_MODULES)
if unknown: if unknown:
raise ValueError(f"Unbekannte Module: {sorted(unknown)} (erlaubt: {ALL_MODULES})") raise ValueError(f"Unbekannte Module: {sorted(unknown)} (erlaubt: {ALL_MODULES})")
@@ -151,6 +188,7 @@ def issue_license(*, customer, license_type, modules, valid_days,
"master_pubkey": master_public_key_b64, "master_pubkey": master_public_key_b64,
"master_endpoint": master_endpoint, "master_endpoint": master_endpoint,
"vendor": vendor, "vendor": vendor,
"role": role,
} }
signature = sign_payload(payload, master_private_key_b64) signature = sign_payload(payload, master_private_key_b64)
license_file = {**payload, "license_privkey": license_priv, "signature": signature} license_file = {**payload, "license_privkey": license_priv, "signature": signature}
@@ -190,6 +228,12 @@ def license_status(license_file: dict, now=None) -> dict:
"type": license_file.get("type"), "type": license_file.get("type"),
"customer": license_file.get("customer"), "customer": license_file.get("customer"),
"expires_at": license_file.get("expires_at"), "expires_at": license_file.get("expires_at"),
# .get(..., "customer"): aeltere, vor Einfuehrung von role signierte
# Lizenzdateien haben dieses Feld noch nicht -- fehlend bedeutet
# unveraendert "gewoehnliche Kundeninstanz", nie "master"/
# "license_server" (sonst koennte eine alte Datei rueckwirkend
# unbeabsichtigt privilegiert erscheinen).
"role": license_file.get("role", "customer"),
} }
@@ -231,11 +275,24 @@ def system_fingerprint():
# gebunden ist, siehe Plan-Abschnitt "Warum das die Doppelnutzung wirksam # gebunden ist, siehe Plan-Abschnitt "Warum das die Doppelnutzung wirksam
# verhindert". # 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: """Vom CLIENT aufgerufen: erzeugt eine signierte Anfrage (action:
"activate"/"deactivate"/"heartbeat"). Das Ergebnis ist unverändert für "activate"/"deactivate"/"heartbeat"). Das Ergebnis ist unverändert für
einen Online-API-Aufruf nutzbar; für den Offline-Fall wird es 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"): if action not in ("activate", "deactivate", "heartbeat"):
raise ValueError(f"Unbekannte Aktion: {action!r}") raise ValueError(f"Unbekannte Aktion: {action!r}")
payload = { payload = {
@@ -252,6 +309,8 @@ def build_client_request(action, license_file, nonce=None) -> dict:
"nonce": nonce or uuid.uuid4().hex, "nonce": nonce or uuid.uuid4().hex,
"timestamp": _iso(time.time()), "timestamp": _iso(time.time()),
} }
if report is not None:
payload["report"] = report
signature = sign_payload(payload, license_file["license_privkey"]) signature = sign_payload(payload, license_file["license_privkey"])
return {**payload, "signature": signature} 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 %} {% 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 }}"> <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> <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"> <td class="text-dim">
{% if c.street or c.city %} {% if c.street or c.city %}
{{ c.street }} {{ c.house_number }}{% if c.street %}<br>{% endif %}{{ c.postal_code }} {{ c.city }} {{ c.street }} {{ c.house_number }}{% if c.street %}<br>{% endif %}{{ c.postal_code }} {{ c.city }}
+4 -1
View File
@@ -45,7 +45,10 @@
data-sort-issued="{{ l.issued_at }}" data-sort-expires="{{ l.expires_at }}" data-sort-issued="{{ l.issued_at }}" data-sort-expires="{{ l.expires_at }}"
data-sort-status="{{ l.status }}" data-sort-heartbeat="{{ l.last_heartbeat_at or '' }}"> data-sort-status="{{ l.status }}" data-sort-heartbeat="{{ l.last_heartbeat_at or '' }}">
<tr> <tr>
<td class="cell-name">{{ l.customer_name }}</td> <td class="cell-name">
{{ l.customer_name }}
{% if l.role == 'license_server' %}<span class="pill admin" style="margin-left:6px;">Lizenzserver</span>{% endif %}
</td>
<td class="mono text-dim">{{ l.license_id[:8] }}…</td> <td class="mono text-dim">{{ l.license_id[:8] }}…</td>
<td>{{ type_labels.get(l.type, l.type) }}</td> <td>{{ type_labels.get(l.type, l.type) }}</td>
<td class="text-dim">{{ l.issued_at[:10] }}</td> <td class="text-dim">{{ l.issued_at[:10] }}</td>
+21 -3
View File
@@ -10,14 +10,29 @@
<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><h2 style="font-size:16px;">Ticket</h2></div> <div><h2 style="font-size:16px;">Ticket</h2></div>
<span class="pill {{ 'online' if ticket.status == 'open' else 'offline' }}"> <div class="flex gap-2">
{{ 'Offen' if ticket.status == 'open' else 'Gesperrt' }} {% if ticket.role == 'license_server' %}
</span> <span class="pill admin">Lizenzserver</span>
{% endif %}
<span class="pill {{ 'online' if ticket.status == 'open' else 'offline' }}">
{{ 'Offen' if ticket.status == 'open' else 'Gesperrt' }}
</span>
</div>
</div> </div>
{% if ticket.role == 'license_server' %}
<div class="notice-banner notice-banner--warning" style="margin-bottom:14px;">
Lizenzserver-Ticket: eine daraus ausgestellte Lizenz macht die Zielinstanz zu einem vollwertigen Sub-Lizenzserver
(darf eigene Kunden/Tickets/Lizenzen ausstellen, Benutzer/Gruppen/Einstellungen verwalten) — außer selbst weitere
Lizenzserver-Lizenzen ausstellen. Kein Self-Service-Zugang für diesen Ticket-Typ.
</div>
{% endif %}
<div class="field"><label>Kunde</label><input type="text" value="{{ customer.name if customer else '?' }}" disabled></div> <div class="field"><label>Kunde</label><input type="text" value="{{ customer.name if customer else '?' }}" disabled></div>
<div class="field"><label>Lizenztyp</label><input type="text" value="{{ type_labels.get(ticket.type, ticket.type) }}" disabled></div> <div class="field"><label>Lizenztyp</label><input type="text" value="{{ type_labels.get(ticket.type, ticket.type) }}" disabled></div>
{% if ticket.role == 'customer' %}
<div class="field"><label>Module</label><input type="text" value="{{ modules_list|join(', ') if modules_list else 'keine' }}" disabled></div> <div class="field"><label>Module</label><input type="text" value="{{ modules_list|join(', ') if modules_list else 'keine' }}" disabled></div>
{% endif %}
<div class="field"><label>Gültigkeitsdauer</label><input type="text" value="{{ 'Lifetime (unbegrenzt)' if ticket.lifetime else ticket.valid_days ~ ' Tage' }}" disabled></div> <div class="field"><label>Gültigkeitsdauer</label><input type="text" value="{{ 'Lifetime (unbegrenzt)' if ticket.lifetime else ticket.valid_days ~ ' Tage' }}" disabled></div>
{% if ticket.role == 'customer' %}
<div class="field"> <div class="field">
<label>Self-Service-Link (dieses Ticket) {{ hi.hint_icon("Diesen Link an den Kunden weitergeben -- damit kann er seine Lizenz selbst herunterladen, widerrufen und (mit diesen Eckdaten) neu erstellen, ohne Login.", "Self-Service-Link") }}</label> <label>Self-Service-Link (dieses Ticket) {{ hi.hint_icon("Diesen Link an den Kunden weitergeben -- damit kann er seine Lizenz selbst herunterladen, widerrufen und (mit diesen Eckdaten) neu erstellen, ohne Login.", "Self-Service-Link") }}</label>
<input type="text" class="mono" value="{{ self_service_url }}" readonly onclick="this.select()"> <input type="text" class="mono" value="{{ self_service_url }}" readonly onclick="this.select()">
@@ -26,6 +41,7 @@
Übersicht über ALLE Tickets des Kunden {{ hi.hint_icon("Es gibt bewusst keinen dauerhaften Kundenportal-Link mehr -- der Kunde fordert sich unter " ~ portal_request_url ~ " bei Bedarf selbst einen zeitlich begrenzten Zugang an.", "Kundenportal") }}: Übersicht über ALLE Tickets des Kunden {{ hi.hint_icon("Es gibt bewusst keinen dauerhaften Kundenportal-Link mehr -- der Kunde fordert sich unter " ~ portal_request_url ~ " bei Bedarf selbst einen zeitlich begrenzten Zugang an.", "Kundenportal") }}:
der Kunde fordert ihn selbst über <a href="{{ portal_request_url }}" target="_blank" rel="noopener">{{ portal_request_url }}</a> an (zeitlich begrenzter Zugang, keine dauerhafte Freigabe). der Kunde fordert ihn selbst über <a href="{{ portal_request_url }}" target="_blank" rel="noopener">{{ portal_request_url }}</a> an (zeitlich begrenzter Zugang, keine dauerhafte Freigabe).
</div> </div>
{% endif %}
{% if can_edit_ticket %} {% if can_edit_ticket %}
<div class="flex gap-2" style="margin-top:14px;"> <div class="flex gap-2" style="margin-top:14px;">
@@ -77,11 +93,13 @@
{{ type_labels.get(ticket.type, ticket.type) }}-Lizenz erstellen ({{ 'Lifetime' if ticket.lifetime else ticket.valid_days ~ ' Tage' }}) {{ type_labels.get(ticket.type, ticket.type) }}-Lizenz erstellen ({{ 'Lifetime' if ticket.lifetime else ticket.valid_days ~ ' Tage' }})
</button> </button>
</form> </form>
{% if ticket.role == 'customer' %}
<form method="post"> <form method="post">
<input type="hidden" name="issue_license" value="trial"> <input type="hidden" name="issue_license" value="trial">
<button type="submit" class="btn btn-secondary btn-block">Stattdessen Trial erstellen (30 Tage, alle Module)</button> <button type="submit" class="btn btn-secondary btn-block">Stattdessen Trial erstellen (30 Tage, alle Module)</button>
</form> </form>
{% endif %} {% endif %}
{% endif %}
</div> </div>
<div class="card card-pad" style="grid-column: 1 / -1;"> <div class="card card-pad" style="grid-column: 1 / -1;">
+18 -1
View File
@@ -35,6 +35,15 @@
<div class="hint" style="margin-bottom:14px;">Weitere Angaben (Anschrift, Ansprechpartner, Telefon) lassen sich anschließend unter <a href="{{ url_for('customers') }}">Kunden</a> ergänzen.</div> <div class="hint" style="margin-bottom:14px;">Weitere Angaben (Anschrift, Ansprechpartner, Telefon) lassen sich anschließend unter <a href="{{ url_for('customers') }}">Kunden</a> ergänzen.</div>
</div> </div>
{% if can_issue_license_server %}
<div class="field">
<label class="check-row">
<input type="checkbox" name="role" value="license_server" id="role_license_server" onchange="toggleLicenseServerRole()">
Dies ist eine Lizenzserver-Lizenz {{ hi.hint_icon("Für einen Sub-Lizenzserver, nicht für eine gewöhnliche TESM-Kundeninstanz. Die daraus ausgestellte Lizenz macht die Zielinstanz zu einem vollwertigen Lizenzserver -- eigene Kunden/Tickets/Lizenzen ausstellen, Benutzer/Gruppen/Einstellungen verwalten -- außer selbst weitere Lizenzserver-Lizenzen ausstellen (das bleibt strukturell dieser Instanz vorbehalten). Kein Self-Service-Zugang, Module sind bedeutungslos.", "Lizenzserver-Lizenz") }}
</label>
</div>
{% endif %}
<div class="field"> <div class="field">
<label for="license_type">Lizenztyp {{ hi.hint_icon("Trial: zeitlich begrenzt, voller Zugriff. Standard: alle lizenzpflichtigen Basis-Funktionen, keine Zusatzmodule. Custom: Standard + einzeln wählbare Module. Enterprise: alles. Unabhängig vom hier gewählten Typ kann aus jedem Ticket zusätzlich jederzeit eine separate Trial-Lizenz erzeugt werden.", "Lizenztyp") }}</label> <label for="license_type">Lizenztyp {{ hi.hint_icon("Trial: zeitlich begrenzt, voller Zugriff. Standard: alle lizenzpflichtigen Basis-Funktionen, keine Zusatzmodule. Custom: Standard + einzeln wählbare Module. Enterprise: alles. Unabhängig vom hier gewählten Typ kann aus jedem Ticket zusätzlich jederzeit eine separate Trial-Lizenz erzeugt werden.", "Lizenztyp") }}</label>
<select name="license_type" id="license_type" onchange="toggleModuleFields()" required> <select name="license_type" id="license_type" onchange="toggleModuleFields()" required>
@@ -90,7 +99,12 @@ function toggleNewCustomerFields() {
} }
function toggleModuleFields() { function toggleModuleFields() {
const type = document.getElementById("license_type").value; const type = document.getElementById("license_type").value;
document.getElementById("moduleFields").classList.toggle("hidden", type !== "custom"); const roleBox = document.getElementById("role_license_server");
const isLicenseServer = roleBox && roleBox.checked;
// Module sind bei einer Lizenzserver-Lizenz bedeutungslos (siehe
// ticket_new() in app.py, erzwingt dort ohnehin modules=[]) -- Feld
// bleibt unabhaengig vom Lizenztyp ausgeblendet.
document.getElementById("moduleFields").classList.toggle("hidden", isLicenseServer || type !== "custom");
// Trial + Lifetime waere eine dauerhaft nie ablaufende Lizenz ohne // Trial + Lifetime waere eine dauerhaft nie ablaufende Lizenz ohne
// jedes Modul -- serverseitig ohnehin unterbunden (siehe ticket_new() // jedes Modul -- serverseitig ohnehin unterbunden (siehe ticket_new()
// in app.py), hier nur als klares UI-Signal ausgegraut. // in app.py), hier nur als klares UI-Signal ausgegraut.
@@ -101,6 +115,9 @@ function toggleModuleFields() {
toggleLifetime(); toggleLifetime();
} }
} }
function toggleLicenseServerRole() {
toggleModuleFields();
}
function toggleLifetime() { function toggleLifetime() {
const isLifetime = document.getElementById("lifetime").checked; const isLifetime = document.getElementById("lifetime").checked;
document.getElementById("validDaysField").classList.toggle("hidden", isLifetime); document.getElementById("validDaysField").classList.toggle("hidden", isLifetime);
+4 -1
View File
@@ -45,7 +45,10 @@
data-sort-status="{{ t.license.status if t.license else '' }}" data-sort-status="{{ t.license.status if t.license else '' }}"
data-sort-heartbeat="{{ t.license.last_heartbeat_at if t.license and t.license.last_heartbeat_at else '' }}"> data-sort-heartbeat="{{ t.license.last_heartbeat_at if t.license and t.license.last_heartbeat_at else '' }}">
<tr> <tr>
<td class="cell-name">{{ t.customer_name }}</td> <td class="cell-name">
{{ t.customer_name }}
{% if t.role == 'license_server' %}<span class="pill admin" style="margin-left:6px;">Lizenzserver</span>{% endif %}
</td>
<td>{{ type_labels.get(t.type, t.type) }}</td> <td>{{ type_labels.get(t.type, t.type) }}</td>
<td class="text-dim">{{ t.modules_list|join(', ') if t.modules_list else '—' }}</td> <td class="text-dim">{{ t.modules_list|join(', ') if t.modules_list else '—' }}</td>
<td class="text-dim">{{ 'Lifetime' if t.lifetime else t.valid_days ~ ' Tage' }}</td> <td class="text-dim">{{ 'Lifetime' if t.lifetime else t.valid_days ~ ' Tage' }}</td>