From cee5311b1d328741f816fb978a4df09bd1c9f8df Mon Sep 17 00:00:00 2001 From: alientim Date: Sun, 23 Aug 2026 15:51:20 +0200 Subject: [PATCH] Ticketverwaltung, Kundenstammdaten, Kundenportal, Lifetime-Lizenzen, Dashboard-Kacheln (v1.2.0) Reaktion auf mehrere Bug-Reports und Feature-Wuensche zum bestehenden Ticket-System (v1.1.0): 'Logs gehen nicht', 'Ticketverwaltung fehlt', 'Lizenzen koennen nicht vom Dashboard geloescht werden', plus eine Reihe konkreter neuer Anforderungen. ## Bugfixes - /logs: behob einen Absturz (jinja2.exceptions.UndefinedError: 'global_check_interval' is undefined) UND die eigentliche Ursache dahinter -- logs.html war unveraendert aus dem TESM-Fork uebernommen (PoE-Geraete-Polling-Text/-Farblogik, ein Template-Feld, das app.py nie befuellt hat) und ausserdem schrieb auf diesem Server ueberhaupt nichts in live.log (kein poe.sh-Aequivalent vorhanden). Live-Log zeigt jetzt echte Ticket-/Lizenzaktionen, mit passendem Hinweistext und Faerbung. - Lizenz-Loeschung war bereits vorhanden, aber nur ueber Ticket- -> Lizenz-Detailseite erreichbar -- jetzt zusaetzlich inline in der Lizenzhistorie jedes Tickets. ## Ticketverwaltung (neu: eigene Seite /tickets) - Volle CRUD: Liste mit Suche, Bearbeiten (Typ/Module/Laufzeit/Lifetime, wirkt nur auf die naechste Ausstellung), Loeschen (nur wenn keine offene Lizenz mehr besteht, dann inkl. Historie). - Dashboard (/) zeigt seitdem NICHT mehr die Ticket-Tabelle, sondern eine rein informative Kachel-Uebersicht der aktuell AKTIVEN Lizenzen, exakt im Kachel-Stil von TESMs eigenem Geraete-Dashboard: Ampelfarben (gruen >90 Tage, orange 30-90, rot <30/abgelaufen), gruppiert, Firma + Hostname der Kundeninstanz. Hostname kommt neu vom TESM-Client per Activate/ Heartbeat (srv/tesm/licensing.py, abwaerts-kompatibel: alte Clients ohne das Feld ueberschreiben nie einen bereits bekannten Hostnamen). ## Kundenstammdaten - Neue Felder: Strasse/Hausnummer/PLZ/Ort/Ansprechpartner. E-Mail ist beim Anlegen jetzt Pflicht; beim Bearbeiten bewusst nur validiert, wenn ausgefuellt (sonst waeren Bestandskunden aus der Zeit vor dieser Umstellung fuer JEDE Aenderung blockiert gewesen). ## Kundenportal (neu, per Magic-Link, kein Login) - /portal/: zeigt einem Kunden ALLE seine Tickets/Lizenzen auf einen Blick (ergaenzt den bestehenden Pro-Ticket-Link /self-service/) und erlaubt ihm, seine eigenen Kontakt-/ Anschriftdaten selbst zu pflegen -- Firmenname und kommerzielle Ticket-Eckdaten bleiben bewusst admin-verwaltet. token = eigene Spalte license_customers.portal_token, rueckwirkend fuer Bestandskunden erzeugt. ## Lifetime-Lizenzen - Neues Ticket-Flag 'Lifetime': ignoriert die Laufzeit fuer jede REGULAERE Ausstellung (Sentinel-Ablaufdatum 2099-12-31, licensing. LIFETIME_EXPIRES_AT) -- eine zusaetzlich erzeugte Trial-Lizenz aus demselben Ticket bleibt immer die normale 30-Tage-Variante. Trial ALS TICKET-GRUNDTYP + Lifetime wird serverseitig verhindert (haette eine dauerhafte, nie ablaufende Lizenz ganz ohne Module ergeben). ## Absicherung nach Review Ein Adversarial-Review-Durchlauf ueber den kompletten Diff deckte vor dem Deploy zusaetzlich auf und wurde behoben: - TOCTOU-Race: zwei nahezu gleichzeitige 'Lizenz erstellen'-Anfragen fuer dasselbe Ticket (z.B. Doppelklick im Self-Service) konnten beide durchkommen. Jetzt durch einen partiellen Unique-Index (idx_one_open_license_per_ticket) auf DB-Ebene ausgeschlossen; die zweite Anfrage bekommt sauber die bereits erstellte Lizenz zurueck statt eine zweite zu erzeugen. - Analoger Unique-Index auf license_customers.portal_token als Backstop fuer den neuen Magic-Link. - tickets.html nutzte noch die alte, feste '<=30 Tage'-Schwelle statt der gemeinsamen Ampelfarbe -- Lizenzen im 30-90-Tage-Bereich blieben dort unmarkiert, obwohl das Dashboard sie schon als orange zeigte. SCHEMA_VERSION 2 -> 3 (neue Spalten/Tabellen, ueber install.sh mit Backup+Health-Check abgesichert). Verifiziert: vollstaendige Migration gegen eine echte Kopie der Live-Datenbank, anschliessend 22 Funktionstests direkt gegen die echten Routen (Validierung, Lifetime-Ausstellung, Sperr-/Kaskadenregeln bei Loeschungen, Kundenportal inkl. ungueltiger Eingaben) sowie ein Playwright- Seitenladetest ohne Konsolenfehler -- beides nach den Review-Fixes erneut komplett durchlaufen, inkl. direktem Nachweis, dass der neue Unique-Index eine doppelte offene Lizenz pro Ticket tatsaechlich auf DB-Ebene ablehnt. Co-Authored-By: Claude Sonnet 5 --- srv/tesm-license/SCHEMA_VERSION | 2 +- srv/tesm-license/VERSION | 2 +- srv/tesm-license/app.py | 564 +++++++++++++++--- srv/tesm-license/create_db.py | 9 + srv/tesm-license/licensing.py | 27 +- .../templates/customer_portal.html | 92 +++ srv/tesm-license/templates/customers.html | 75 ++- srv/tesm-license/templates/index.html | 175 +++--- .../templates/license_detail.html | 9 +- srv/tesm-license/templates/logs.html | 14 +- srv/tesm-license/templates/self_service.html | 9 +- srv/tesm-license/templates/ticket_detail.html | 131 +++- srv/tesm-license/templates/ticket_new.html | 27 +- srv/tesm-license/templates/tickets.html | 190 ++++++ 14 files changed, 1129 insertions(+), 197 deletions(-) create mode 100644 srv/tesm-license/templates/customer_portal.html create mode 100644 srv/tesm-license/templates/tickets.html diff --git a/srv/tesm-license/SCHEMA_VERSION b/srv/tesm-license/SCHEMA_VERSION index 0cfbf08..00750ed 100644 --- a/srv/tesm-license/SCHEMA_VERSION +++ b/srv/tesm-license/SCHEMA_VERSION @@ -1 +1 @@ -2 +3 diff --git a/srv/tesm-license/VERSION b/srv/tesm-license/VERSION index 9084fa2..26aaba0 100644 --- a/srv/tesm-license/VERSION +++ b/srv/tesm-license/VERSION @@ -1 +1 @@ -1.1.0 +1.2.0 diff --git a/srv/tesm-license/app.py b/srv/tesm-license/app.py index 158d261..0454f3e 100644 --- a/srv/tesm-license/app.py +++ b/srv/tesm-license/app.py @@ -320,6 +320,7 @@ DEFAULT_GROUP_PERMISSIONS = [ NAV_ITEMS = [ {"key": "index", "label": "Dashboard", "icon": "grid", "endpoint": "index"}, + {"key": "tickets", "label": "Tickets", "icon": "key", "endpoint": "tickets_list"}, {"key": "customers", "label": "Kunden", "icon": "users", "endpoint": "customers"}, {"key": "users", "label": "Benutzer", "icon": "users", "endpoint": "users"}, {"key": "groups", "label": "Gruppen", "icon": "groups", "endpoint": "groups"}, @@ -343,6 +344,8 @@ NAV_ITEMS_BY_KEY = {item["key"]: item for item in NAV_ITEMS} def _nav_key_visible(key, user): if key == "index": return True + if key == "tickets": + return user.has_permission("licenses.view") if key == "customers": return user.can_manage_customers if key == "users": @@ -677,10 +680,41 @@ def _ensure_schema(): name TEXT NOT NULL, contact_email TEXT, contact_phone TEXT, + contact_person TEXT, + street TEXT, + house_number TEXT, + postal_code TEXT, + city TEXT, + portal_token TEXT, notes TEXT, created_at TEXT NOT NULL ) """) + customer_cols = {row["name"] for row in conn.execute("PRAGMA table_info(license_customers)").fetchall()} + # contact_person/street/house_number/postal_code/city/portal_token kamen + # nachtraeglich dazu (vorher nur Name+E-Mail+Telefon+Notizen) -- siehe + # Kachel-Dashboard/Kundenportal weiter unten, die eine vollstaendige + # Anschrift bzw. je Kunde einen eigenen Magic-Link-Token brauchen. + for _col, _ddl in ( + ("contact_person", "ALTER TABLE license_customers ADD COLUMN contact_person TEXT"), + ("street", "ALTER TABLE license_customers ADD COLUMN street TEXT"), + ("house_number", "ALTER TABLE license_customers ADD COLUMN house_number TEXT"), + ("postal_code", "ALTER TABLE license_customers ADD COLUMN postal_code TEXT"), + ("city", "ALTER TABLE license_customers ADD COLUMN city TEXT"), + ("portal_token", "ALTER TABLE license_customers ADD COLUMN portal_token TEXT"), + ): + if _col not in customer_cols: + conn.execute(_ddl) + # Jeder Kunde braucht einen eigenen, dauerhaften Portal-Token fuer den + # kundenweiten Magic-Link (Kundenportal-Login ohne Passwort, siehe + # customer_portal()) -- bestehende Kunden (vor Einfuehrung dieses + # Felds) bekommen hier rueckwirkend einen zugewiesen, statt bis zur + # naechsten manuellen Bearbeitung ohne Portal-Zugang dazustehen. + for _row in conn.execute("SELECT id FROM license_customers WHERE portal_token IS NULL OR portal_token=''").fetchall(): + conn.execute( + "UPDATE license_customers SET portal_token=? WHERE id=?", + (secrets.token_urlsafe(24), _row["id"]), + ) conn.execute(""" CREATE TABLE IF NOT EXISTS licenses ( id INTEGER PRIMARY KEY AUTOINCREMENT, @@ -694,6 +728,7 @@ def _ensure_schema(): license_file_json TEXT NOT NULL, status TEXT NOT NULL DEFAULT 'issued', fingerprint TEXT, + hostname TEXT, activated_at TEXT, deactivated_at TEXT, last_heartbeat_at TEXT, @@ -702,12 +737,17 @@ def _ensure_schema(): created_by TEXT, created_at TEXT NOT NULL, ticket_id TEXT, + lifetime INTEGER NOT NULL DEFAULT 0, FOREIGN KEY (customer_id) REFERENCES license_customers(id) ) """) license_cols = {row["name"] for row in conn.execute("PRAGMA table_info(licenses)").fetchall()} if "ticket_id" not in license_cols: conn.execute("ALTER TABLE licenses ADD COLUMN ticket_id TEXT") + if "hostname" not in license_cols: + conn.execute("ALTER TABLE licenses ADD COLUMN hostname TEXT") + if "lifetime" not in license_cols: + conn.execute("ALTER TABLE licenses ADD COLUMN lifetime INTEGER NOT NULL DEFAULT 0") # Tickets: die dauerhafte, dem Kunden gehoerende Berechtigung "eine # Lizenz mit diesen Eckdaten (Typ/Module/Laufzeit) zu halten" -- @@ -723,12 +763,57 @@ def _ensure_schema(): type TEXT NOT NULL, modules TEXT NOT NULL DEFAULT '[]', valid_days INTEGER NOT NULL, + lifetime INTEGER NOT NULL DEFAULT 0, status TEXT NOT NULL DEFAULT 'open', created_by TEXT, created_at TEXT NOT NULL, FOREIGN KEY (customer_id) REFERENCES license_customers(id) ) """) + ticket_cols = {row["name"] for row in conn.execute("PRAGMA table_info(license_tickets)").fetchall()} + if "lifetime" not in ticket_cols: + conn.execute("ALTER TABLE license_tickets ADD COLUMN lifetime INTEGER NOT NULL DEFAULT 0") + + # DB-seitiger Schutz gegen die Doppel-Ausstellung eines Tickets: ohne + # dieses Backstop koennten zwei nahezu gleichzeitige Anfragen (z.B. ein + # Doppelklick auf den Self-Service-"Lizenz erstellen"-Button, zwei + # offene Tabs) beide den vorherigen _ticket_has_open_license()-Check + # noch als "frei" sehen, bevor die jeweils andere ihre INSERT committet + # -- ein klassisches Time-of-check/time-of-use-Problem, das reine + # Python-Logik (_ticket_has_open_license) allein nicht zuverlaessig + # verhindern kann. Der partielle Unique-Index erzwingt "hoechstens eine + # offene (issued/active) Lizenz pro Ticket" direkt auf DB-Ebene; die + # zweite, verlierende INSERT schlaegt mit IntegrityError fehl, was + # _issue_license_from_ticket() abfaengt (siehe dort). + # Bewusst defensiv: sollte eine bestehende Installation (aus welchem + # Grund auch immer) bereits Bestandsdaten haben, die diese Regel + # verletzen, soll das den Dienst nicht am Start hindern -- lieber ein + # geloggter Fehler als ein kompletter Ausfall wegen eines Datenrands, + # den diese Migration selbst gerade erst einfuehrt. + try: + conn.execute( + "CREATE UNIQUE INDEX IF NOT EXISTS idx_one_open_license_per_ticket " + "ON licenses(ticket_id) WHERE status IN ('issued','active')" + ) + except sqlite3.IntegrityError: + app.logger.error( + "Konnte idx_one_open_license_per_ticket nicht anlegen -- vermutlich hat mindestens ein " + "Ticket bereits mehr als eine offene Lizenz. Bitte manuell bereinigen (siehe licenses-Tabelle)." + ) + # Analoger Backstop fuer den Kundenportal-Magic-Link: portal_token + # autorisiert komplett ohne Login (siehe customer_portal()), ein + # doppelt vergebener oder NULL-Token wuerde sonst stillschweigend die + # falsche Kundenzeile zurueckgeben bzw. den Kunden dauerhaft aussperren. + try: + conn.execute( + "CREATE UNIQUE INDEX IF NOT EXISTS idx_customers_portal_token " + "ON license_customers(portal_token) WHERE portal_token IS NOT NULL AND portal_token != ''" + ) + except sqlite3.IntegrityError: + app.logger.error( + "Konnte idx_customers_portal_token nicht anlegen -- vermutlich haben zwei Kunden denselben " + "portal_token. Bitte manuell bereinigen (siehe license_customers-Tabelle)." + ) # Rückwirkend ein Ticket für jede Lizenz nachbilden, die vor Einführung # des Ticket-Konzepts direkt ausgestellt wurde (ticket_id noch NULL) -- @@ -829,23 +914,34 @@ def _build_user(row): def _append_changes_log(ts, who, action, target, details): - """Spiegelt jeden Änderungslog-Eintrag zusätzlich in eine eigene, per - logrotate rotierte Datei (TESM_CHANGES_LOG_PATH) — die audit_log-Tabelle - bleibt die Quelle für die durchsuchbare/sortierbare Seite unter - Logs → Auditlog, wächst aber unbegrenzt weiter; die Datei bekommt - dieselbe wöchentliche Rotation/Aufbewahrung wie die anderen drei Logs. - Ein Schreibfehler hier darf den eigentlichen, DB-basierten Audit-Trail - nicht gefährden — daher bewusst best-effort mit breitem except.""" - try: - line = f"{ts} [{who}] {action}" - if target: - line += f" — {target}" - if details: - line += f": {details}" - with open(TESM_CHANGES_LOG_PATH, "a", encoding="utf-8") as f: - f.write(line + "\n") - except OSError: - pass + """Spiegelt jeden Änderungslog-Eintrag zusätzlich in zwei eigene, per + logrotate rotierte Dateien -- die audit_log-Tabelle bleibt die Quelle + für die durchsuchbare/sortierbare Seite unter Logs → Auditlog, wächst + aber unbegrenzt weiter. + + Anders als bei TESM selbst (wo live.log kontinuierlich von poe.sh mit + Geräte-Erreichbarkeit gefüllt wird) gibt es hier keinen Hintergrund- + prozess, der live.log von sich aus beschreibt -- ohne diese Zeile blieb + die Datei auf einem frischen Lizenzserver schlicht leer/nicht + vorhanden, wodurch Logs → Live dauerhaft "Keine Logfiles gefunden" + zeigte. Jede Ticket-/Lizenzaktion (Ausstellung, Aktivierung, Widerruf, + Heartbeat-Fehler, ...) landet daher jetzt sowohl in changes.log + (unverändert) als auch in live.log, damit Live sowie die davon + abgeleitete Verlauf-Seite (rotierte live.log.N-Stände) tatsächlich + Inhalt haben. Ein Schreibfehler hier darf den eigentlichen, DB- + basierten Audit-Trail nicht gefährden — daher bewusst best-effort mit + breitem except je Datei.""" + line = f"{ts} [{who}] {action}" + if target: + line += f" — {target}" + if details: + line += f": {details}" + for path in (TESM_CHANGES_LOG_PATH, TESM_LIVE_LOG_PATH): + try: + with open(path, "a", encoding="utf-8") as f: + f.write(line + "\n") + except OSError: + pass def log_action(action, target=None, details=None): @@ -1856,20 +1952,47 @@ def _license_row(conn, license_id): def _license_dict_for_display(row, now=None): """Reichert eine DB-Zeile aus 'licenses' um abgeleitete Anzeigefelder - an (Modul-Liste, Resttage) -- teilt sich Dashboard und Detailseite.""" + an (Modul-Liste, Resttage, Ampelfarbe) -- teilt sich Dashboard, + Ticket-/Lizenz-Detailseite und Self-Service. + + Lifetime-Lizenzen (row['lifetime']) haben zwar ein konkretes, weit in + der Zukunft liegendes expires_at (siehe licensing.LIFETIME_EXPIRES_AT), + zeigen aber bewusst weder eine Resttage-Zahl noch einen Ablaufhinweis + an -- days_left bleibt None, is_expired bleibt False, color ist immer + 'green'.""" now = now if now is not None else time.time() d = dict(row) try: d["modules_list"] = json.loads(d.get("modules") or "[]") except ValueError: d["modules_list"] = [] + is_lifetime = bool(d.get("lifetime")) + d["is_lifetime"] = is_lifetime + if is_lifetime: + d["days_left"] = None + d["is_expired"] = False + d["color"] = "green" + return d try: expires_ts = datetime.strptime(d["expires_at"], "%Y-%m-%dT%H:%M:%SZ").timestamp() - d["days_left"] = int((expires_ts - now) / 86400) + days_left = int((expires_ts - now) / 86400) + d["days_left"] = days_left d["is_expired"] = expires_ts < now + # Ampelfarbe fuer Dashboard-Kacheln und Tabellen (siehe + # index()-Dashboard): > 90 Tage gruen, 30..90 Tage orange, < 30 Tage + # (inkl. bereits abgelaufen) rot -- exakt die vom Nutzer + # vorgegebenen Schwellen (90 selbst zaehlt noch als orange, erst + # 91+ ist gruen). + if days_left < 30: + d["color"] = "red" + elif days_left <= 90: + d["color"] = "orange" + else: + d["color"] = "green" except (ValueError, TypeError): d["days_left"] = None d["is_expired"] = False + d["color"] = "red" return d @@ -1904,22 +2027,28 @@ def _ticket_has_open_license(conn, ticket_id): return bool(current and current["status"] in ("issued", "active")) -def _create_ticket(conn, customer_id, license_type, modules, valid_days, actor): +def _create_ticket(conn, customer_id, license_type, modules, valid_days, actor, lifetime=False): """Legt ein neues Ticket an -- die dauerhafte, dem Kunden gehoerende 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. + lifetime=True macht valid_days irrelevant fuer jede REGULAERE Lizenz + aus diesem Ticket (Trial-Ausstellungen bleiben davon unberuehrt, siehe + _issue_license_from_ticket) -- der Wert wird trotzdem gespeichert, + damit ein spaeteres Zurückschalten auf eine normale Laufzeit nicht bei + 0/NULL anfangen muss.""" ticket_id = secrets.token_urlsafe(24) now = datetime.now().strftime("%Y-%m-%d %H:%M:%S") conn.execute( - "INSERT INTO license_tickets (id, customer_id, type, modules, valid_days, status, created_by, created_at) " - "VALUES (?, ?, ?, ?, ?, 'open', ?, ?)", - (ticket_id, customer_id, license_type, json.dumps(sorted(modules)), valid_days, actor, now), + "INSERT INTO license_tickets (id, customer_id, type, modules, valid_days, lifetime, status, created_by, created_at) " + "VALUES (?, ?, ?, ?, ?, ?, 'open', ?, ?)", + (ticket_id, customer_id, license_type, json.dumps(sorted(modules)), valid_days, int(bool(lifetime)), actor, now), ) conn.commit() customer = _customer_row(conn, customer_id) + duration_label = "Lifetime" if lifetime else f"{valid_days} Tage" log_action_system( "ticket.create", customer["name"] if customer else str(customer_id), - f"{LICENSE_TYPE_LABELS.get(license_type, license_type)}, {valid_days} Tage (Akteur: {actor})", + f"{LICENSE_TYPE_LABELS.get(license_type, license_type)}, {duration_label} (Akteur: {actor})", ) return ticket_id @@ -1936,11 +2065,22 @@ def _issue_license_from_ticket(conn, ticket_id, actor, as_trial=False): Trial-Konditionen.""" ticket = _ticket_row(conn, ticket_id) if as_trial: - license_type, modules, valid_days = "trial", list(licensing.ALL_MODULES), TRIAL_DEFAULT_DAYS + # Trial ist immer ein zeitlich kurzer Override, unabhaengig davon + # ob das Ticket selbst als Lifetime konfiguriert ist -- siehe + # Modul-Docstring oben. + license_type, modules, valid_days, lifetime = "trial", list(licensing.ALL_MODULES), TRIAL_DEFAULT_DAYS, False else: license_type = ticket["type"] modules = json.loads(ticket["modules"] or "[]") valid_days = ticket["valid_days"] + # "Trial" als Ticket-GRUNDTYP + Lifetime waere eine dauerhafte, + # nie ablaufende Lizenz mit -- da Trial kein "custom"/"enterprise" + # ist -- null Modulen: ein unsinniger, so nie beabsichtigter + # Zustand. Lifetime gilt daher nur fuer "echte" Lizenztypen; bei + # Trial als Grundtyp wird es hier still ignoriert (zusaetzlich in + # ticket_new()/ticket_edit() bereits serverseitig unterbunden, + # diese Zeile ist die letzte Absicherung direkt vor der Ausstellung). + lifetime = bool(ticket["lifetime"]) and license_type != "trial" customer = _customer_row(conn, ticket["customer_id"]) license_file, license_pubkey = licensing.issue_license( @@ -1952,22 +2092,40 @@ def _issue_license_from_ticket(conn, ticket_id, actor, as_trial=False): master_public_key_b64=MASTER_PUBLIC_KEY, master_endpoint=_get_master_endpoint(), vendor=_get_vendor_info(), + lifetime=lifetime, ) now = datetime.now().strftime("%Y-%m-%d %H:%M:%S") - conn.execute( - "INSERT INTO licenses (license_id, customer_id, type, modules, issued_at, expires_at, " - "license_pubkey, license_file_json, status, role, ticket_id, created_by, created_at) " - "VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'issued', 'customer', ?, ?, ?)", - ( - license_file["license_id"], ticket["customer_id"], license_type, json.dumps(sorted(modules)), - license_file["issued_at"], license_file["expires_at"], license_pubkey, - json.dumps(license_file), ticket_id, actor, now, - ), - ) - conn.commit() + try: + conn.execute( + "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) " + "VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'issued', 'customer', ?, ?, ?, ?)", + ( + license_file["license_id"], ticket["customer_id"], license_type, json.dumps(sorted(modules)), + license_file["issued_at"], license_file["expires_at"], license_pubkey, + json.dumps(license_file), ticket_id, int(lifetime), actor, now, + ), + ) + conn.commit() + except sqlite3.IntegrityError: + # idx_one_open_license_per_ticket (siehe _ensure_schema) hat + # zugeschlagen: zwischen dem _ticket_has_open_license()-Check des + # Aufrufers und diesem INSERT hat eine ZWEITE, nahezu gleichzeitige + # Anfrage (z.B. Doppelklick auf den Self-Service-Button, zwei + # offene Tabs) bereits eine Lizenz fuer dieses Ticket angelegt. + # Statt zwei gleichzeitig gueltige Lizenzen aus einem Ticket zu + # erlauben, wird dieser zweite Versuch sauber abgebrochen -- der + # Aufrufer bekommt die inzwischen tatsaechlich vorhandene Lizenz + # zurueck, als waere er selbst nicht der Ausstellende gewesen. + conn.rollback() + existing = _latest_license_for_ticket(conn, ticket_id) + if existing: + return _license_dict_for_display(existing) + raise + expiry_label = "Lifetime" if lifetime else f"bis {license_file['expires_at']}" log_action_system( "license.issue", customer["name"] if customer else "?", - f"{LICENSE_TYPE_LABELS.get(license_type, license_type)}, bis {license_file['expires_at']} (Ticket, Akteur: {actor})", + f"{LICENSE_TYPE_LABELS.get(license_type, license_type)}, {expiry_label} (Ticket, Akteur: {actor})", ) return license_file @@ -2020,10 +2178,16 @@ def _process_activate(data): if row["status"] == "active" and row["fingerprint"] and row["fingerprint"] != fingerprint: conn.close() return {"error": "already_bound_elsewhere"}, 409 + # hostname ist rein informativ (Dashboard-Kacheln, siehe index()) und + # war frueher gar nicht Teil der Anfrage -- data.get(...) liefert bei + # aelteren, noch nicht aktualisierten Clients einfach "", das UPDATE + # ueberschreibt dann bewusst NICHT einen ggf. schon bekannten Hostnamen + # (siehe COALESCE unten). + hostname = data.get("hostname", "") now = datetime.now().strftime("%Y-%m-%d %H:%M:%S") conn.execute( - "UPDATE licenses SET status='active', fingerprint=?, activated_at=? WHERE license_id=?", - (fingerprint, now, license_id), + "UPDATE licenses SET status='active', fingerprint=?, hostname=COALESCE(NULLIF(?, ''), hostname), activated_at=? WHERE license_id=?", + (fingerprint, hostname, now, license_id), ) # Ein physisches System (= Fingerprint) kann immer nur GENAU eine # Lizenzdatei gleichzeitig halten -- wird hier eine neue aktiviert @@ -2109,8 +2273,12 @@ def _process_heartbeat(data): conn.close() return {"error": "not_active"}, 409 + hostname = data.get("hostname", "") now = datetime.now().strftime("%Y-%m-%d %H:%M:%S") - conn.execute("UPDATE licenses SET last_heartbeat_at=? WHERE license_id=?", (now, license_id)) + conn.execute( + "UPDATE licenses SET last_heartbeat_at=?, hostname=COALESCE(NULLIF(?, ''), hostname) WHERE license_id=?", + (now, hostname, license_id), + ) conn.commit() license_update = json.loads(row["license_file_json"]) conn.close() @@ -2176,6 +2344,28 @@ def license_manual_code(): return render_template("license_manual_code.html", result_code=result_code, processed_action=processed_action) +_EMAIL_RE = re.compile(r"^[^@\s]+@[^@\s]+\.[^@\s]+$") + + +def _customer_form_fields(form): + """Liest die Kunden-Formularfelder einheitlich aus (Anlegen UND + Bearbeiten nutzen dieselben Feldnamen) -- Anschrift ist bewusst in + Einzelfelder aufgeteilt (Straße/Hausnummer/PLZ/Ort statt eines + Freitextfelds), damit sie z.B. für Rechnungen sauber weiterverwendet + werden kann.""" + return { + "name": form.get("name", "").strip(), + "contact_email": form.get("contact_email", "").strip(), + "contact_phone": form.get("contact_phone", "").strip(), + "contact_person": form.get("contact_person", "").strip(), + "street": form.get("street", "").strip(), + "house_number": form.get("house_number", "").strip(), + "postal_code": form.get("postal_code", "").strip(), + "city": form.get("city", "").strip(), + "notes": form.get("notes", "").strip(), + } + + @app.route("/customers", methods=["GET", "POST"]) @login_required def customers(): @@ -2188,35 +2378,51 @@ def customers(): if not current_user.has_permission("customers.create"): flash("Keine Berechtigung, Kunden anzulegen.", "danger") else: - name = request.form.get("name", "").strip() - if not name: - flash("Name ist erforderlich.", "danger") + f = _customer_form_fields(request.form) + if not f["name"]: + flash("Firma/Name ist erforderlich.", "danger") + elif not _EMAIL_RE.match(f["contact_email"]): + flash("Eine gültige E-Mail-Adresse ist erforderlich.", "danger") else: conn.execute( - "INSERT INTO license_customers (name, contact_email, contact_phone, notes, created_at) " - "VALUES (?, ?, ?, ?, ?)", - (name, request.form.get("contact_email", "").strip(), - request.form.get("contact_phone", "").strip(), request.form.get("notes", "").strip(), + "INSERT INTO license_customers (name, contact_email, contact_phone, contact_person, " + "street, house_number, postal_code, city, portal_token, notes, created_at) " + "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", + (f["name"], f["contact_email"], f["contact_phone"], f["contact_person"], + f["street"], f["house_number"], f["postal_code"], f["city"], + secrets.token_urlsafe(24), f["notes"], datetime.now().strftime("%Y-%m-%d %H:%M:%S")), ) conn.commit() - log_action("customer.create", name) - flash(f"Kunde „{name}“ angelegt.", "success") + log_action("customer.create", f["name"]) + flash(f"Kunde „{f['name']}“ angelegt.", "success") elif "edit_customer" in request.form: if not current_user.has_permission("customers.edit"): flash("Keine Berechtigung, Kunden zu ändern.", "danger") else: customer_id = int(request.form["edit_customer"]) - name = request.form.get("name", "").strip() - conn.execute( - "UPDATE license_customers SET name=?, contact_email=?, contact_phone=?, notes=? WHERE id=?", - (name, request.form.get("contact_email", "").strip(), - request.form.get("contact_phone", "").strip(), request.form.get("notes", "").strip(), - customer_id), - ) - conn.commit() - log_action("customer.update", name) - flash("Kunde aktualisiert.", "success") + f = _customer_form_fields(request.form) + # Anders als beim Anlegen wird eine LEERE E-Mail beim + # Bearbeiten toleriert (nur ein nicht-leerer, aber + # ungueltiger Wert wird abgelehnt) -- sonst koennte ein + # Admin bei den Bestandskunden von vor dieser Umstellung + # (leere contact_email, damals noch kein Pflichtfeld) nicht + # einmal mehr Telefonnummer/Anschrift/Notizen aktualisieren, + # ohne zuerst zwingend eine E-Mail-Adresse nachzutragen. + if not f["name"]: + flash("Firma/Name ist erforderlich.", "danger") + elif f["contact_email"] and not _EMAIL_RE.match(f["contact_email"]): + flash("Die E-Mail-Adresse ist ungültig.", "danger") + else: + conn.execute( + "UPDATE license_customers SET name=?, contact_email=?, contact_phone=?, contact_person=?, " + "street=?, house_number=?, postal_code=?, city=?, notes=? WHERE id=?", + (f["name"], f["contact_email"], f["contact_phone"], f["contact_person"], + f["street"], f["house_number"], f["postal_code"], f["city"], f["notes"], customer_id), + ) + conn.commit() + log_action("customer.update", f["name"]) + flash("Kunde aktualisiert.", "success") elif "delete_customer" in request.form: if not current_user.has_permission("customers.edit"): flash("Keine Berechtigung, Kunden zu löschen.", "danger") @@ -2262,13 +2468,18 @@ def ticket_new(): customer_id = request.form.get("customer_id", "") if customer_id == "__new__": new_customer_name = request.form.get("new_customer_name", "").strip() + new_customer_email = request.form.get("new_customer_email", "").strip() if not new_customer_name: flash("Bitte einen Namen für den neuen Kunden angeben.", "danger") conn.close() return redirect(url_for("ticket_new")) + if not _EMAIL_RE.match(new_customer_email): + flash("Bitte eine gültige E-Mail-Adresse für den neuen Kunden angeben.", "danger") + conn.close() + return redirect(url_for("ticket_new")) cur = conn.execute( - "INSERT INTO license_customers (name, contact_email, created_at) VALUES (?, ?, ?)", - (new_customer_name, request.form.get("new_customer_email", "").strip(), + "INSERT INTO license_customers (name, contact_email, portal_token, created_at) VALUES (?, ?, ?, ?)", + (new_customer_name, new_customer_email, secrets.token_urlsafe(24), datetime.now().strftime("%Y-%m-%d %H:%M:%S")), ) conn.commit() @@ -2293,12 +2504,17 @@ def ticket_new(): modules = [m for m in request.form.getlist("modules") if m in licensing.ALL_MODULES] else: modules = [] + # Trial + Lifetime waere eine dauerhaft nie ablaufende Lizenz ohne + # jedes Modul (Trial ist nicht "custom"/"enterprise") -- ein + # Zustand, den niemand absichtlich will. Serverseitig unterbunden, + # nicht nur per JS ausgegraut (siehe ticket_new.html). + lifetime = request.form.get("lifetime") == "1" and license_type != "trial" try: valid_days = max(1, int(request.form.get("valid_days", "365"))) except ValueError: valid_days = 365 - ticket_id = _create_ticket(conn, customer_id, license_type, modules, valid_days, current_user.username) + ticket_id = _create_ticket(conn, customer_id, license_type, modules, valid_days, current_user.username, lifetime=lifetime) conn.close() flash("Ticket angelegt.", "success") return redirect(url_for("ticket_detail", ticket_id=ticket_id)) @@ -2364,13 +2580,117 @@ def ticket_detail(ticket_id): modules_list=json.loads(ticket["modules"] or "[]"), licenses=[_license_dict_for_display(r) for r in licenses_rows], has_open_license=any(l["status"] in ("issued", "active") for l in licenses_rows), + can_delete_ticket=not any(l["status"] in ("issued", "active") for l in licenses_rows), + license_types=licensing.LICENSE_TYPES, all_modules=licensing.ALL_MODULES, type_labels=LICENSE_TYPE_LABELS, module_labels=MODULE_LABELS, master_licensed=license_active(), self_service_url=url_for("self_service", ticket_id=ticket_id, _external=True), + portal_url=url_for("customer_portal", token=customer["portal_token"], _external=True) if customer and customer["portal_token"] else None, can_edit=current_user.has_permission("licenses.edit"), ) +@app.route("/tickets//edit", methods=["POST"]) +@login_required +def ticket_edit(ticket_id): + """Aendert die Eckdaten eines bestehenden Tickets (Typ/Module/ + Laufzeit/Lifetime) -- WIRKT SICH NICHT auf bereits ausgestellte + Lizenzen aus (deren Typ/Module/Ablaufdatum wurden bei Ausstellung fest + in die jeweilige Zeile kopiert, siehe _issue_license_from_ticket), + sondern nur auf die naechste Lizenz, die aus diesem Ticket erzeugt + wird. Der Kunde selbst bleibt unveraendert -- eine Neuzuordnung ist + hier bewusst nicht vorgesehen (das Self-Service-Ticket ist an genau + diesen Kunden gebunden, u.a. weil er ueber dessen Portal-Token + erreichbar ist).""" + if not current_user.has_permission("licenses.edit"): + flash("Keine Berechtigung, Tickets zu bearbeiten.", "danger") + return redirect(url_for("tickets_list")) + conn = get_db_connection() + ticket = _ticket_row(conn, ticket_id) + if not ticket: + conn.close() + flash("Ticket nicht gefunden.", "danger") + return redirect(url_for("tickets_list")) + + license_type = request.form.get("license_type", ticket["type"]) + if license_type not in licensing.LICENSE_TYPES: + conn.close() + flash("Ungültiger Lizenztyp.", "danger") + return redirect(url_for("ticket_detail", ticket_id=ticket_id)) + if license_type == "enterprise": + modules = list(licensing.ALL_MODULES) + elif license_type == "custom": + modules = [m for m in request.form.getlist("modules") if m in licensing.ALL_MODULES] + else: + modules = [] + # Siehe ticket_new() -- Trial + Lifetime ist kein sinnvoller Zustand. + lifetime = request.form.get("lifetime") == "1" and license_type != "trial" + try: + valid_days = max(1, int(request.form.get("valid_days", ticket["valid_days"]))) + except ValueError: + valid_days = ticket["valid_days"] + + conn.execute( + "UPDATE license_tickets SET type=?, modules=?, valid_days=?, lifetime=? WHERE id=?", + (license_type, json.dumps(sorted(modules)), valid_days, int(lifetime), ticket_id), + ) + conn.commit() + customer = _customer_row(conn, ticket["customer_id"]) + conn.close() + duration_label = "Lifetime" if lifetime else f"{valid_days} Tage" + log_action( + "ticket.update", customer["name"] if customer else ticket_id, + f"{LICENSE_TYPE_LABELS.get(license_type, license_type)}, {duration_label}", + ) + flash("Ticket aktualisiert -- gilt für die nächste daraus erstellte Lizenz.", "success") + return redirect(url_for("ticket_detail", ticket_id=ticket_id)) + + +@app.route("/tickets//delete", methods=["POST"]) +@login_required +def ticket_delete(ticket_id): + """Loescht ein Ticket vollstaendig -- nur erlaubt, wenn es aktuell + KEINE aktive oder ausgestellte (noch nicht aktivierte) Lizenz haelt + (sonst wuerde der Self-Service-/Admin-Zugriff auf ein gerade genutztes + System verschwinden). Etwaige bereits widerrufene/deaktivierte + Lizenzen darunter sind reine Historie und werden mitgeloescht, sonst + blieben verwaiste Zeilen mit einer toten ticket_id zurueck.""" + if not current_user.has_permission("licenses.edit"): + flash("Keine Berechtigung, Tickets zu löschen.", "danger") + return redirect(url_for("tickets_list")) + conn = get_db_connection() + ticket = _ticket_row(conn, ticket_id) + if not ticket: + conn.close() + flash("Ticket nicht gefunden.", "danger") + return redirect(url_for("tickets_list")) + if _ticket_has_open_license(conn, ticket_id): + conn.close() + flash("Ticket hat noch eine aktive oder ausgestellte Lizenz -- erst widerrufen, dann löschen.", "danger") + return redirect(url_for("ticket_detail", ticket_id=ticket_id)) + customer = _customer_row(conn, ticket["customer_id"]) + # Bewusst in Kauf genommenes Restrisiko: zwischen dem Check oben und + # dem DELETE hier koennte -- nur ueber den separaten Self-Service-/ + # Portal-Link des Kunden, nicht ueber diese Admin-Oberflaeche selbst -- + # in einem extrem engen Zeitfenster eine neue Lizenz fuer dieses Ticket + # entstehen, die dann mitgeloescht wuerde. Ein vollstaendiger Schutz + # bräuchte eine explizite BEGIN IMMEDIATE-Transaktion um beide + # Operationen; angesichts des sehr seltenen Zusammentreffens (Admin + # loescht genau in dem Moment, in dem der Kunde unabhaengig davon + # eine neue Lizenz erstellt) und des insgesamt seltenen Admin-Traffics + # dieser internen Anwendung wird das hier nicht weiter verhaertet -- + # die deutlich wahrscheinlichere Doppel-Ausstellung (Doppelklick auf + # denselben Button) ist bereits ueber idx_one_open_license_per_ticket + # auf DB-Ebene ausgeschlossen (siehe _ensure_schema). + conn.execute("DELETE FROM licenses WHERE ticket_id=?", (ticket_id,)) + conn.execute("DELETE FROM license_tickets WHERE id=?", (ticket_id,)) + conn.commit() + conn.close() + log_action("ticket.delete", customer["name"] if customer else ticket_id) + flash("Ticket gelöscht.", "success") + return redirect(url_for("tickets_list")) + + @app.route("/licenses/") @login_required def license_detail(license_id): @@ -2510,12 +2830,16 @@ def license_send_email(license_id): 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" ) + if customer["portal_token"]: + portal_url = url_for("customer_portal", token=customer["portal_token"], _external=True) + self_service_note += f"\nEine Übersicht all Ihrer Tickets/Lizenzen finden Sie hier:\n{portal_url}\n" + valid_until = "unbegrenzt (Lifetime)" if row["lifetime"] else row["expires_at"] try: _graph_send_mail( customer["contact_email"], f"Ihre TESM-Lizenz ({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"Lizenz-ID: {license_id}\nTyp: {LICENSE_TYPE_LABELS.get(row['type'], row['type'])}\nGültig bis: {row['expires_at']}\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}", attachment_bytes=row["license_file_json"].encode("utf-8"), attachment_name=f"license-{license_id}.json", @@ -2603,6 +2927,7 @@ def self_service(ticket_id): license=_license_dict_for_display(current) if current else None, can_issue_new=can_issue_new, type_labels=LICENSE_TYPE_LABELS, module_labels=MODULE_LABELS, + portal_url=url_for("customer_portal", token=customer["portal_token"]) if customer and customer["portal_token"] else None, ) @@ -2668,6 +2993,58 @@ def self_service_issue(ticket_id): return redirect(url_for("self_service", ticket_id=ticket_id)) +@app.route("/portal/", methods=["GET", "POST"]) +def customer_portal(token): + """Kundenweites Self-Service-Portal -- anders als /self-service/ + (ein Magic-Link PRO TICKET) ist dieser Link PRO KUNDE: zeigt alle + eigenen Tickets samt aktueller Lizenz auf einen Blick und erlaubt dem + Kunden, seine eigenen Kontakt-/Anschriftdaten selbst zu pflegen. + Bewusst NICHT bearbeitbar: Firmenname sowie die kommerziellen + Ticket-Eckdaten (Typ/Module/Laufzeit) -- das bleibt Sache des Anbieters. + Fuer die eigentlichen Lizenzaktionen (herunterladen/widerrufen/neu + erstellen) verlinkt jede Ticket-Zeile auf deren bestehende + /self-service/-Seite, statt die Logik hier zu duplizieren. + Kein Login noetig -- Autorisierung ausschliesslich durch Besitz des + unerratbaren Tokens (siehe license_customers.portal_token).""" + conn = get_db_connection() + customer = conn.execute("SELECT * FROM license_customers WHERE portal_token=?", (token,)).fetchone() + if not customer: + conn.close() + abort(404) + + if request.method == "POST": + contact_email = request.form.get("contact_email", "").strip() + if not _EMAIL_RE.match(contact_email): + flash("Bitte eine gültige E-Mail-Adresse angeben.", "danger") + else: + conn.execute( + "UPDATE license_customers SET contact_person=?, contact_phone=?, contact_email=?, " + "street=?, house_number=?, postal_code=?, city=? WHERE id=?", + (request.form.get("contact_person", "").strip(), request.form.get("contact_phone", "").strip(), + contact_email, request.form.get("street", "").strip(), request.form.get("house_number", "").strip(), + request.form.get("postal_code", "").strip(), request.form.get("city", "").strip(), customer["id"]), + ) + conn.commit() + log_action_system("customer.update", customer["name"], "Kunde hat eigene Kontaktdaten im Portal aktualisiert") + flash("Ihre Daten wurden aktualisiert.", "success") + conn.close() + return redirect(url_for("customer_portal", token=token)) + + now = time.time() + tickets_view = [] + for t in _tickets_for_customer(conn, customer["id"]): + 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) + conn.close() + return render_template( + "customer_portal.html", customer=dict(customer), tickets=tickets_view, + type_labels=LICENSE_TYPE_LABELS, module_labels=MODULE_LABELS, + ) + + def _audit_archive_loop(): """Prüft einmal täglich (siehe AUDIT_ARCHIVE_CHECK_INTERVAL_SECONDS), ob die audit_log-Tabelle den Schwellenwert überschritten hat, und @@ -3041,14 +3418,54 @@ def inject_license_topbar(): @app.route("/") def index(): - """Kunden-/Ticket-/Lizenzübersicht -- das Dashboard des Master- - Lizenzservers. Zeigt Tickets (die dauerhafte Berechtigung des Kunden - auf eine Lizenz mit bestimmten Eckdaten) statt roher Lizenzzeilen, - jeweils mit ihrer aktuellen Lizenz (falls schon erstellt). Ersetzt die - geräte-/PoE-spezifische Kachelansicht von TESM (siehe Modul-Docstring - oben) komplett.""" + """Dashboard des Master-Lizenzservers -- eine rein informative Kachel- + uebersicht der gerade AKTIVEN Lizenzen (analog zur Geraete-Kachel- + ansicht auf dem TESM-Dashboard selbst, siehe _dashboard_tiles.html + dort), gruppiert nach Ablauf-Ampel (rot < 30 Tage/abgelaufen, orange + 30-90 Tage, gruen > 90 Tage oder Lifetime). Zeigt bewusst NUR Lizenzen + mit status='active' -- alles andere (Tickets anlegen/bearbeiten/ + loeschen, Lizenzen ausstellen/widerrufen/loeschen) ist Verwaltung und + lebt seit der Kachel-Umstellung auf der eigenen Tickets-Seite + (tickets_list()).""" if not current_user.is_authenticated or not current_user.has_permission("licenses.view"): - return render_template("index.html", tickets=None) + return render_template("index.html", buckets=None) + conn = get_db_connection() + active_rows = conn.execute(""" + SELECT licenses.*, license_customers.name AS customer_name + FROM licenses + JOIN license_customers ON license_customers.id = licenses.customer_id + WHERE licenses.status = 'active' + ORDER BY licenses.expires_at ASC + """).fetchall() + now = time.time() + buckets = {"red": [], "orange": [], "green": []} + for row in active_rows: + d = _license_dict_for_display(row, now) + d["customer_name"] = row["customer_name"] + buckets[d["color"]].append(d) + stats = { + "red": len(buckets["red"]), "orange": len(buckets["orange"]), + "green": len(buckets["green"]), "total": len(active_rows), + } + conn.close() + return render_template( + "index.html", buckets=buckets, stats=stats, + type_labels=LICENSE_TYPE_LABELS, module_labels=MODULE_LABELS, + ) + + +@app.route("/tickets") +@login_required +def tickets_list(): + """Ticketverwaltung -- Anlegen/Bearbeiten/Loeschen von Tickets sowie + Einstieg in die Lizenzausstellung/-verwaltung je Ticket. Das war bis + v1.1.0 der Inhalt des Dashboards selbst; seit der Umstellung des + Dashboards auf eine rein informative Kacheluebersicht (siehe index()) + lebt die eigentliche Verwaltung hier, eigenstaendig ueber die + Navigation erreichbar.""" + if not current_user.has_permission("licenses.view"): + flash("Keine Berechtigung, Tickets anzusehen.", "danger") + return redirect(url_for("index")) conn = get_db_connection() filter_customer_id = request.args.get("customer_id", type=int) if filter_customer_id: @@ -3073,11 +3490,14 @@ def index(): 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 + d["can_delete"] = not (current and current["status"] in ("issued", "active")) tickets_view.append(d) conn.close() return render_template( - "index.html", tickets=tickets_view, type_labels=LICENSE_TYPE_LABELS, module_labels=MODULE_LABELS, + "tickets.html", tickets=tickets_view, type_labels=LICENSE_TYPE_LABELS, module_labels=MODULE_LABELS, + all_modules=licensing.ALL_MODULES, license_types=licensing.LICENSE_TYPES, can_create=current_user.has_permission("licenses.create"), + can_edit=current_user.has_permission("licenses.edit"), ) diff --git a/srv/tesm-license/create_db.py b/srv/tesm-license/create_db.py index 809c68e..d5c244b 100644 --- a/srv/tesm-license/create_db.py +++ b/srv/tesm-license/create_db.py @@ -115,6 +115,12 @@ CREATE TABLE IF NOT EXISTS license_customers ( name TEXT NOT NULL, contact_email TEXT, contact_phone TEXT, + contact_person TEXT, + street TEXT, + house_number TEXT, + postal_code TEXT, + city TEXT, + portal_token TEXT, notes TEXT, created_at TEXT NOT NULL ); @@ -133,6 +139,7 @@ CREATE TABLE IF NOT EXISTS licenses ( license_file_json TEXT NOT NULL, status TEXT NOT NULL DEFAULT 'issued', fingerprint TEXT, + hostname TEXT, activated_at TEXT, deactivated_at TEXT, last_heartbeat_at TEXT, @@ -141,6 +148,7 @@ CREATE TABLE IF NOT EXISTS licenses ( created_by TEXT, created_at TEXT NOT NULL, ticket_id TEXT, + lifetime INTEGER NOT NULL DEFAULT 0, FOREIGN KEY (customer_id) REFERENCES license_customers(id) ); """) @@ -152,6 +160,7 @@ CREATE TABLE IF NOT EXISTS license_tickets ( type TEXT NOT NULL, modules TEXT NOT NULL DEFAULT '[]', valid_days INTEGER NOT NULL, + lifetime INTEGER NOT NULL DEFAULT 0, status TEXT NOT NULL DEFAULT 'open', created_by TEXT, created_at TEXT NOT NULL, diff --git a/srv/tesm-license/licensing.py b/srv/tesm-license/licensing.py index 0c6cf46..a0c03e7 100644 --- a/srv/tesm-license/licensing.py +++ b/srv/tesm-license/licensing.py @@ -103,14 +103,30 @@ def _parse_iso(s: str) -> float: return datetime.datetime.strptime(s, "%Y-%m-%dT%H:%M:%SZ").replace(tzinfo=datetime.timezone.utc).timestamp() +# Sentinel-Ablaufdatum fuer "Lifetime"-Lizenzen. Bewusst ein fixes, weit in +# der Zukunft liegendes Datum statt eines Sonderwerts wie None/0 fuer +# expires_at -- dadurch bleibt JEDE bestehende Datumsvergleichs-/ +# Differenzrechnung (hier, im Master-Dashboard, UND im TESM-Client) ohne +# jede Sonderbehandlung korrekt: "in > 90 Tagen faellig" o.ae. ist fuer ein +# Datum im Jahr 2099 schlicht immer wahr. Ob eine Lizenz als "Lifetime" +# geflaggt ist, wird zusaetzlich explizit in der Master-DB gespeichert +# (licenses.lifetime) -- dieses Sentinel-Datum ist nur die tatsaechlich in +# die signierte Lizenzdatei eingebettete, fuer den Client sichtbare Reprae- +# sentation davon. +LIFETIME_EXPIRES_AT = "2099-12-31T00:00:00Z" + + # ================================================================ Lizenz == def issue_license(*, customer, license_type, modules, valid_days, master_private_key_b64, master_public_key_b64, - master_endpoint, vendor, license_id=None, now=None): + master_endpoint, vendor, license_id=None, now=None, lifetime=False): """Vom MASTER aufgerufen: erzeugt eine neue, signierte Lizenz samt frischem Pro-Lizenz-Schlüsselpaar. + lifetime=True ignoriert valid_days komplett und setzt stattdessen das + feste Sentinel-Datum LIFETIME_EXPIRES_AT -- siehe dortigen Kommentar. + Rückgabe: (license_file, license_pubkey) -- license_file ist die komplette, an den Kunden auszuhändigende Datei (inkl. dem PRIVATEN Lizenzschlüssel); license_pubkey ist NUR für die Master-Datenbank @@ -130,7 +146,7 @@ def issue_license(*, customer, license_type, modules, valid_days, "type": license_type, "modules": sorted(modules), "issued_at": _iso(now), - "expires_at": _iso(now + valid_days * 86400), + "expires_at": LIFETIME_EXPIRES_AT if lifetime else _iso(now + valid_days * 86400), "license_pubkey": license_pub, "master_pubkey": master_public_key_b64, "master_endpoint": master_endpoint, @@ -225,6 +241,13 @@ def build_client_request(action, license_file, nonce=None) -> dict: payload = { "license_id": license_file["license_id"], "fingerprint": system_fingerprint(), + # Rein informativ fuer die Dashboard-Kacheln des Masters (siehe + # dortiges index()) -- fliesst NICHT in irgendeine Sicherheits- + # entscheidung ein (die bleibt allein Sache des Fingerprints), wird + # aber wie alle anderen Felder mitsigniert, damit ein Angreifer + # ohne den privaten Lizenzschluessel keinen falschen Hostnamen + # unterschieben kann. + "hostname": socket.gethostname(), "action": action, "nonce": nonce or uuid.uuid4().hex, "timestamp": _iso(time.time()), diff --git a/srv/tesm-license/templates/customer_portal.html b/srv/tesm-license/templates/customer_portal.html new file mode 100644 index 0000000..5c8b6cb --- /dev/null +++ b/srv/tesm-license/templates/customer_portal.html @@ -0,0 +1,92 @@ + + + + + +Mein Konto · TESM-Lizenzserver + + + + + + + + + + diff --git a/srv/tesm-license/templates/customers.html b/srv/tesm-license/templates/customers.html index 91567be..7cac4fd 100644 --- a/srv/tesm-license/templates/customers.html +++ b/srv/tesm-license/templates/customers.html @@ -27,17 +27,26 @@
- + + {% for c in customers %} - + - - + + + {% else %} - + {% endfor %}
KundeFirmaAnschrift Kontakt Tickets Aktionen
{{ c.name }}{{ c.contact_email or '—' }}{% if c.contact_phone %} · {{ c.contact_phone }}{% endif %}{{ c.ticket_count }} + {% if c.street or c.city %} + {{ c.street }} {{ c.house_number }}{% if c.street %}
{% endif %}{{ c.postal_code }} {{ c.city }} + {% else %}—{% endif %} +
+ {{ c.contact_email or '—' }}{% if c.contact_phone %} · {{ c.contact_phone }}{% endif %} + {% if c.contact_person %}
{{ c.contact_person }}{% endif %} +
{{ c.ticket_count }}
{% if can_edit %} @@ -64,7 +73,7 @@
Noch keine Kunden angelegt.
Noch keine Kunden angelegt.
@@ -73,7 +82,7 @@ {% for c in customers %}