Compare commits

..
14 Commits
Author SHA1 Message Date
alientim 1a13ce4c2a Version 2.8.44 / Lizenzserver 2.8.22: Client-Konfiguration am richtigen Ort, Fusszeile bleibt stehen 2026-09-17 11:24:18 +02:00
alientim 7f8f4f9de8 Version 2.8.42: sichtbare Pfeile an allem Aufklappbaren 2026-09-17 10:42:02 +02:00
alientim e098306aef Version 2.8.39: gescheitertes pip bricht ab, keine ==-Bindung mehr 2026-09-17 09:39:04 +02:00
alientim 68bda48d72 Version 2.8.38: eine Rechnung fuer die Resttage, Module benannt 2026-09-17 09:26:34 +02:00
alientim 50b212caec Version 2.8.37: eigene Sprachumgebung fuer die Skripte 2026-09-17 09:01:07 +02:00
alientim 0117489b5a Version 2.8.36: Lizenzabfrage fuer ein RMM auf der Maschine 2026-09-17 08:49:27 +02:00
alientim f0f0d05e19 Lizenzserver 2.8.16: kein Abgleich in die Vergangenheit 2026-09-16 22:47:00 +02:00
alientim 0024a116ce Lizenzserver 2.8.15: auch eine unbefristete Lizenz laesst sich zurueckholen 2026-09-16 22:40:32 +02:00
alientim 34a4923304 Lizenzserver 2.8.14: die Ticketgrenze wandert nicht mit den Revisionen 2026-09-16 22:26:30 +02:00
alientim 98c2b2aa14 Lizenzserver 2.8.13: Ticket um Tage verlaengern, Lizenz auf das Ticket setzen 2026-09-16 22:19:12 +02:00
alientim 641ea22b3c Lizenzserver 2.8.12: Ticket als Anspruch, Lizenz als Urkunde daraus 2026-09-16 22:00:59 +02:00
alientim b984e22074 Version 2.8.35: der reparierte venv bleibt fuer den Dienstbenutzer lesbar 2026-09-16 21:32:23 +02:00
alientim 1d4e22b082 Version 2.8.34: Skripte aus dem Baum ueber bash aufrufen 2026-09-16 21:23:54 +02:00
alientim 23c091e99f Version 2.8.33: Selbstreparatur der Python-Umgebung nach einem Release-Upgrade 2026-09-16 21:17:09 +02:00
30 changed files with 1048 additions and 81 deletions

No files matched your search

+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "tesm-license"
version = "2.8.8"
version = "2.8.22"
description = "TESM-Lizenzserver -- Kunden, Tickets, Lizenzen, Aktivierung"
requires-python = ">=3.11"
dependencies = [
@@ -78,6 +78,11 @@ def detail(license_id: str):
"license/license_detail.html",
license=row,
state=issuance.display_state(row),
# Bis wohin traegt das Ticket, und weicht die Lizenz davon ab? Die
# Seite zeigt es an, statt es erst in einer Fehlermeldung auftauchen zu
# lassen.
ticket_grenze=issuance.ticket_grenze(conn, license_id),
abweichung=issuance.ticket_abweichung(conn, license_id),
document=document,
document_json=json.dumps(document, indent=2, ensure_ascii=False, sort_keys=True),
deliveries=Database.all(
@@ -181,6 +186,53 @@ def download(license_id: str):
)
@bp.post("/<license_id>/align-ticket")
@permission_required("license_renew.execute")
@reauth_required
def align_ticket(license_id: str):
"""Bringt die Laufzeit der Lizenz mit ihrem Ticket zur Deckung.
In beide Richtungen. Verkuerzt sie, ist das eine Ruecknahme von Laufzeit --
deshalb nur auf ausdruecklichen Zugriff, mit Rueckfrage in der Oberflaeche
und mit Zeile im Aenderungsprotokoll.
"""
conn = db()
if issuance.get_license(conn, license_id) is None:
abort(404)
key = signing_key()
try:
with transaction():
neu = issuance.lizenz_auf_ticket_setzen(
conn, license_id, master_private_key=key.private_key
)
except (issuance.IssuanceError, ValueError) as exc:
flash(str(exc), "danger")
return redirect(url_for("licenses.detail", license_id=license_id))
if neu is None:
flash("Die Laufzeit entspricht bereits dem Ticket.", "info")
return redirect(url_for("licenses.detail", license_id=license_id))
log_event(
"license.resigned",
target=license_id[:12],
detail=(
f"Revision {neu['seq']}: Laufzeit auf die Ticketlaufzeit gesetzt "
f"({'unbefristet' if neu.get('lifetime') else neu.get('expires_at')})"
),
# Was etwas wegnimmt, steht als Warnung im Protokoll -- was etwas gibt,
# als Notiz. Beides ist derselbe Knopf, aber nicht dasselbe Ereignis.
severity="notice" if neu.get("lifetime") else "warning",
)
reporting.report_soon(core())
flash(
f"Neue Revision {neu['seq']}: Die Laufzeit entspricht jetzt dem Ticket. Der Client "
"erhält sie beim nächsten Heartbeat.",
"success",
)
return redirect(url_for("licenses.detail", license_id=license_id))
@bp.post("/<license_id>/resign")
@permission_required("license_renew.execute")
@reauth_required
@@ -18,6 +18,7 @@ from .common import (
master_endpoint,
module_labels,
role_labels,
signing_key,
type_labels,
)
@@ -131,7 +132,10 @@ def detail(ticket_id: str):
ticket=ticket,
ticket_modules=json.loads(str(ticket["modules"] or "[]")),
licenses=licenses,
# Zwei Spuren, zwei Auskuenfte: eine laufende Testlizenz darf die
# richtige Ausstellung nicht sperren und umgekehrt.
has_open_license=issuance.ticket_has_open_license(conn, ticket_id),
has_open_trial=issuance.ticket_has_open_license(conn, ticket_id, trial=True),
modules=lic.ALL_MODULES,
module_labels=module_labels(),
types=lic.LICENSE_TYPES,
@@ -166,6 +170,20 @@ def update(ticket_id: str):
lifetime=request.form.get("lifetime") == "1",
notes=request.form.get("notes", ""),
)
# Das Ticket ist der Anspruch, die Lizenz die Urkunde daraus. Wird der
# Anspruch laenger, muss die Urkunde nachziehen -- sonst stuende die
# Vereinbarung an einer Stelle und gaelte an einer anderen, und der
# Kunde merkte es an dem Tag, an dem seine Installation stehenbleibt.
#
# Nur verlaengern, nie verkuerzen: eine ausgestellte und signierte
# Zusage einseitig zu beschneiden waere etwas anderes als eine
# Korrektur. Eine Verkuerzung wirkt auf die naechste Ausstellung.
nachgezogen = None
if changes:
with transaction():
nachgezogen = issuance.lizenz_dem_ticket_nachziehen(
conn, ticket_id, master_private_key=signing_key().private_key
)
if changes:
log_event(
"ticket.updated",
@@ -175,6 +193,19 @@ def update(ticket_id: str):
scope="ticket",
)
reporting.report_soon(core())
if nachgezogen is not None:
log_event(
"license.resigned",
target=str(nachgezogen["license_id"])[:12],
detail=f"Revision {nachgezogen['seq']}: Laufzeit dem Ticket angeglichen",
severity="notice",
)
flash(
"Gespeichert. Die laufende Lizenz wurde mitverlängert (Revision "
f"{nachgezogen['seq']}) – der Client erhält sie beim nächsten Heartbeat.",
"success",
)
else:
flash(
"Gespeichert. Die Änderung wirkt auf die nächste Ausstellung."
if changes
@@ -186,6 +217,56 @@ def update(ticket_id: str):
return redirect(url_for("tickets.detail", ticket_id=ticket_id))
@bp.post("/<ticket_id>/extend")
@permission_required("tickets.write")
def extend(ticket_id: str):
"""Schlaegt Tage auf die Laufzeit des Tickets -- und zieht die Lizenz nach."""
conn = db()
if issuance.get_ticket(conn, ticket_id) is None:
abort(404)
try:
tage = int(request.form.get("days") or 0)
except ValueError:
flash("Die Verlängerung muss eine Zahl sein.", "danger")
return redirect(url_for("tickets.detail", ticket_id=ticket_id))
try:
with transaction():
neu = issuance.ticket_verlaengern(conn, ticket_id, tage)
nachgezogen = issuance.lizenz_dem_ticket_nachziehen(
conn, ticket_id, master_private_key=signing_key().private_key
)
except (issuance.IssuanceError, ValueError) as exc:
flash(str(exc), "danger")
return redirect(url_for("tickets.detail", ticket_id=ticket_id))
log_event(
"ticket.updated",
target=ticket_id[:12],
detail=f"Laufzeit +{tage} Tage (jetzt {neu})",
severity="notice",
scope="ticket",
)
if nachgezogen is not None:
log_event(
"license.resigned",
target=str(nachgezogen["license_id"])[:12],
detail=f"Revision {nachgezogen['seq']}: Laufzeit dem Ticket angeglichen",
severity="notice",
)
reporting.report_soon(core())
flash(
f"Ticket auf {neu} Tage verlängert."
+ (
f" Die laufende Lizenz wurde mitverlängert (Revision {nachgezogen['seq']})."
if nachgezogen is not None
else ""
),
"success",
)
return redirect(url_for("tickets.detail", ticket_id=ticket_id))
@bp.post("/<ticket_id>/lock")
@permission_required("tickets.write")
def toggle_block(ticket_id: str):
+38 -7
View File
@@ -105,12 +105,17 @@ CREATE INDEX IF NOT EXISTS idx_licenses_customer ON licenses(customer_id);
CREATE INDEX IF NOT EXISTS idx_licenses_status ON licenses(status);
CREATE INDEX IF NOT EXISTS idx_licenses_fingerprint ON licenses(fingerprint);
-- Hoechstens eine offene Lizenz pro Ticket -- auf Datenbankebene, nicht nur im
-- Python-Code. Damit ist der Doppelklick-Fall (zwei Tabs, zwei Anfragen) sauber
-- abgefangen: der verlierende INSERT scheitert und der Aufrufer gibt die
-- bereits erzeugte Lizenz zurueck.
CREATE UNIQUE INDEX IF NOT EXISTS idx_one_open_license_per_ticket
ON licenses(ticket_id) WHERE status IN ('issued','active');
-- Hoechstens eine offene Lizenz **je Art** pro Ticket -- auf Datenbankebene,
-- nicht nur im Python-Code. Damit ist der Doppelklick-Fall (zwei Tabs, zwei
-- Anfragen) sauber abgefangen: der verlierende INSERT scheitert und der
-- Aufrufer gibt die bereits erzeugte Lizenz zurueck.
--
-- "Je Art", weil ein Ticket beides tragen darf: eine Testlizenz zum Ansehen
-- und die richtige Lizenz. Vorher war es ein Index ueber ticket_id allein --
-- und damit schloss ein dreissigtaegiger Test die Ausstellung aus.
CREATE UNIQUE INDEX IF NOT EXISTS idx_one_open_license_per_ticket_art
ON licenses(ticket_id, CASE WHEN type = 'trial' THEN 1 ELSE 0 END)
WHERE status IN ('issued','active');
-- Verbrauchte Nonces des Aktivierungsprotokolls. Verhindert Replay.
CREATE TABLE IF NOT EXISTS license_nonces (
@@ -295,9 +300,35 @@ def uebergang_standardgruppen(conn) -> None: # type: ignore[no-untyped-def]
rbac.unberuehrte_vorlagen_entfernen(conn, PERMISSIONS, vorlagen)
#: Ein Ticket darf eine Testlizenz **und** eine Volllizenz tragen.
#:
#: Der alte Index stand ueber ``ticket_id`` allein: sobald irgendeine Lizenz
#: dieses Tickets lief, ging keine zweite mehr. Damit schloss ein
#: dreissigtaegiger Blick ins Produkt die richtige Ausstellung aus -- und wer
#: zuerst ausstellte, konnte nichts mehr testen lassen. Die Regel bleibt, sie
#: zaehlt nur die Art mit.
V5 = """
DROP INDEX IF EXISTS idx_one_open_license_per_ticket;
CREATE UNIQUE INDEX IF NOT EXISTS idx_one_open_license_per_ticket_art
ON licenses(ticket_id, CASE WHEN type = 'trial' THEN 1 ELSE 0 END)
WHERE status IN ('issued','active');
"""
LICENSE_MIGRATIONS: tuple[Migration, ...] = (
Migration(version=1, name="baseline", sql=V1, allow_integrity_error=True),
Migration(
version=1,
name="baseline",
sql=V1,
allow_integrity_error=True,
geaendert_weil=(
"Der eindeutige Index ueber offene Lizenzen zaehlt seit 2.8.12 die Art mit "
"(Testlizenz neben Volllizenz). Eine frische Installation soll ihn gleich "
"richtig anlegen und nicht erst ueber tesm_license/5 umbauen; auf "
"bestehenden Installationen erledigt das die Migration 5."
),
),
Migration(version=2, name="revision-timestamp", sql=V2),
Migration(version=3, name="funktionsgruppe-fuer-admins", fn=uebergang_funktionsgruppe),
Migration(version=4, name="standardgruppen-aufraeumen", fn=uebergang_standardgruppen),
Migration(version=5, name="testlizenz-neben-volllizenz", sql=V5),
)
@@ -7,7 +7,7 @@ import re
import secrets
import sqlite3
from dataclasses import dataclass
from datetime import datetime, timezone
from datetime import datetime, timedelta, timezone
from typing import Any, Iterable, Sequence
import tesm_licensing as lic
@@ -16,6 +16,28 @@ from tesm_core.db import Database
TICKET_TYPES = lic.LICENSE_TYPES
TICKET_STATES = ("open", "blocked")
#: Wann ist ein Ticket wirklich offen?
#:
#: Ein Ticket ist ein **Anspruch** auf eine Lizenz. Ist sie ausgestellt und
#: gilt sie, ist der Anspruch eingeloest -- offen ist dann nichts mehr. Die
#: Kachel auf der Startseite zaehlte trotzdem jedes Ticket mit ``status='open'``
#: und meldete "3 offene Tickets", waehrend zu allen dreien laengst eine aktive
#: Lizenz gehoerte. Wer danach sucht, sucht umsonst.
#:
#: Abgeleitet statt gespeichert, und das ist Absicht: wird eine Lizenz spaeter
#: widerrufen, ist der Anspruch von selbst wieder offen -- genau dann soll neu
#: ausgestellt werden. Ein dritter Ticketzustand muesste an derselben Stelle
#: von Hand zurueckgedreht werden, und wer das einmal vergisst, hat ein Ticket,
#: das erledigt aussieht und keines ist.
#: Eine **Testlizenz zaehlt nicht**: sie ist ein Blick ins Produkt, kein
#: eingeloester Anspruch. Jedes Ticket darf eine laufen lassen und bleibt
#: trotzdem offen -- sonst muesste man sich zwischen "ausprobieren" und
#: "bekommen" entscheiden.
OHNE_GUELTIGE_LIZENZ = (
"NOT EXISTS (SELECT 1 FROM licenses l "
"WHERE l.ticket_id = t.id AND l.status IN ('issued','active') AND l.type <> 'trial')"
)
LICENSE_STATES = ("issued", "active", "deactivated", "revoked")
#: Ampel-Schwellen der Anzeige. Bewusst getrennt von der Kulanzlogik in
@@ -321,11 +343,22 @@ def set_ticket_status(conn: sqlite3.Connection, ticket_id: str, status: str) ->
)
def ticket_has_open_license(conn: sqlite3.Connection, ticket_id: str) -> bool:
def ticket_has_open_license(
conn: sqlite3.Connection, ticket_id: str, *, trial: bool = False
) -> bool:
"""Laeuft zu diesem Ticket schon eine Lizenz -- und zwar dieser Art?
Zwei Spuren, absichtlich getrennt: **eine** Testlizenz und **eine**
Volllizenz je Ticket. Vorher war es eine gemeinsame Sperre, und damit
schloss ein dreissigtaegiger Blick ins Produkt die richtige Ausstellung
aus -- oder umgekehrt. Wer testen laesst, will danach ausstellen.
"""
vergleich = "=" if trial else "<>"
return (
Database.value(
conn,
"SELECT COUNT(*) FROM licenses WHERE ticket_id=? AND status IN ('issued','active')",
"SELECT COUNT(*) FROM licenses WHERE ticket_id=? AND status IN ('issued','active') "
f"AND type {vergleich} 'trial'",
(ticket_id,),
default=0,
)
@@ -333,8 +366,15 @@ def ticket_has_open_license(conn: sqlite3.Connection, ticket_id: str) -> bool:
) > 0
def ticket_has_any_open_license(conn: sqlite3.Connection, ticket_id: str) -> bool:
"""Irgendeine laufende Lizenz -- Test oder Voll. Fuers Loeschen zaehlt beides."""
return ticket_has_open_license(conn, ticket_id) or ticket_has_open_license(
conn, ticket_id, trial=True
)
def delete_ticket(conn: sqlite3.Connection, ticket_id: str) -> None:
if ticket_has_open_license(conn, ticket_id):
if ticket_has_any_open_license(conn, ticket_id):
raise IssuanceError(
"Zu diesem Ticket existiert noch eine offene Lizenz. Bitte zuerst widerrufen."
)
@@ -431,6 +471,17 @@ def issue_from_ticket(
"Eine Testlizenz gibt es nur für Kundeninstanzen, nicht für Lizenzserver."
)
# Zwei Spuren je Ticket: eine Testlizenz **und** eine Volllizenz. Vorher
# sperrten sie sich gegenseitig -- ein dreissigtaegiger Blick ins Produkt
# schloss die richtige Ausstellung aus, und wer zuerst ausstellte, konnte
# nichts mehr testen lassen.
if ticket_has_open_license(conn, ticket_id, trial=as_trial):
raise IssuanceError(
"Für dieses Ticket läuft bereits eine Testlizenz."
if as_trial
else "Für dieses Ticket läuft bereits eine Lizenz."
)
if as_trial:
license_type = "trial"
modules = sorted(lic.ALL_MODULES)
@@ -507,10 +558,11 @@ def issue_from_ticket(
# Der partielle Unique-Index hat zugeschlagen: ein zweiter Klick oder ein
# zweiter Tab war schneller. Wir geben die tatsaechlich erzeugte Lizenz
# zurueck statt einen Fehler zu zeigen.
vergleich = "=" if as_trial else "<>"
existing = Database.one(
conn,
"SELECT license_id, license_document FROM licenses "
"WHERE ticket_id=? AND status IN ('issued','active')",
f"WHERE ticket_id=? AND status IN ('issued','active') AND type {vergleich} 'trial'",
(ticket_id,),
)
if existing is None:
@@ -538,6 +590,7 @@ def delivery_bundle(conn: sqlite3.Connection, license_id: str, *, keystore: Any)
row = get_license(conn, license_id)
if row is None:
raise IssuanceError("Diese Lizenz existiert nicht.")
document = json.loads(str(row["license_document"]))
bundle: dict[str, Any] = {
"kind": "tesm-license-bundle",
@@ -585,12 +638,51 @@ def delete_license(conn: sqlite3.Connection, license_id: str) -> None:
conn.execute("DELETE FROM licenses WHERE license_id=?", (license_id,))
def ticket_grenze(conn: sqlite3.Connection, license_id: str) -> str | None:
"""Bis wann darf diese Lizenz laufen? ``None`` heisst: unbegrenzt.
Das Ticket ist der Anspruch, die Lizenz die Urkunde daraus -- und eine
Urkunde darf nicht mehr zusagen als der Anspruch hergibt. Vorher liess
sich eine Lizenz aus einem 25-Tage-Ticket beliebig weit verlaengern, ohne
dass am Ticket etwas davon zu sehen war: die Vereinbarung stand dann an
einer Stelle und galt an einer anderen.
Gerechnet wird ab der **Ausstellung** der Lizenz, nicht ab heute: wer ein
Ticket ueber 90 Tage hat, bekommt 90 Tage ab Ausstellung, nicht 90 weitere.
``None`` bei unbefristetem Ticket -- und auch dann, wenn zur Lizenz gar
kein Ticket gehoert (Altbestand, Wurzelinstanz): wo es keinen Anspruch
gibt, gibt es nichts zu begrenzen.
"""
row = get_license(conn, license_id)
if row is None or not row["ticket_id"]:
return None
ticket = get_ticket(conn, str(row["ticket_id"]))
if ticket is None or bool(ticket["lifetime"]):
return None
# Eine Testlizenz lebt nach ihren eigenen Regeln und nicht nach dem Ticket.
if str(row["type"]) == "trial":
return None
# Die Spalte, nicht das Dokument. ``resign_license`` setzt im Dokument bei
# **jeder** Revision ein neues ``issued_at`` -- eine Grenze, die darauf
# rechnet, wandert also mit jeder Neusignatur nach vorn. Wer oft genug neu
# signiert, verlaengert damit den Anspruch, ohne das Ticket anzufassen:
# genau die Luecke, die diese Funktion schliessen soll. Live gemessen --
# Zeile 12.09. 17:45, Dokument nach zwei Revisionen 16.09. 20:20.
#
# Die Spalte ``issued_at`` bleibt bei der ersten Ausstellung stehen.
return lic.iso(
lic.parse_iso(str(row["issued_at"])) + timedelta(days=int(ticket["valid_days"]))
)
def resign_license(
conn: sqlite3.Connection,
license_id: str,
*,
master_private_key: str,
changes: dict[str, Any],
ueber_ticket: bool = False,
) -> dict[str, Any]:
"""Signiert eine neue Revision -- z. B. nach einer Modulerweiterung.
@@ -600,6 +692,18 @@ def resign_license(
row = get_license(conn, license_id)
if row is None:
raise IssuanceError("Diese Lizenz existiert nicht.")
# Geprueft wird hier und nicht in der Route: es ist die eine Stelle, durch
# die jede Neusignatur laeuft -- auch die aus einer Ticketverlaengerung,
# die deshalb ausdruecklich vorbeidarf (``ueber_ticket``).
grenze = ticket_grenze(conn, license_id)
ziel = str(changes.get("expires_at") or "")
if grenze and ziel and not ueber_ticket and ziel > grenze:
raise IssuanceError(
"So lange deckt das Ticket diese Lizenz nicht: es reicht bis "
f"{lic.parse_iso(grenze).strftime('%d.%m.%Y')}. Verlängern Sie zuerst das "
"Ticket -- die Verlängerung wirkt dann auf diese Lizenz."
)
document = json.loads(str(row["license_document"]))
updated = licsrv.resign_license(
document, master_private_key_b64=master_private_key, changes=changes
@@ -675,6 +779,11 @@ def display_state(row: dict[str, Any], *, now: datetime | None = None) -> dict[s
Grenzen: mehr als 90 Tage = gruen, 30 bis 90 Tage = orange, unter 30 Tage
(auch abgelaufen) = rot. Unbefristete Lizenzen sind immer gruen.
Gezaehlt wird in **angefangenen** Tagen (``lic.tage_bis``) -- dieselbe
Rechnung, die auch der Client meldet. Die Vergleiche sind darauf
abgestimmt: 90 volle Tage und eine Stunde sind "mehr als 90" und damit
gruen, 29 Tage und eine Stunde sind "unter 30" und damit rot.
"""
moment = now or datetime.now(timezone.utc)
result = {
@@ -698,11 +807,18 @@ def display_state(row: dict[str, Any], *, now: datetime | None = None) -> dict[s
return {**result, "color": "red"}
delta = expires - moment
days_left = int(delta.total_seconds() // 86400)
# Dieselbe Rechnung wie im Client -- nicht eine zweite daneben. Hier stand
# eine abgerundete, und damit zeigte dieselbe Lizenz auf dieser Seite
# "21 Tage", waehrend der Abruf beim Kunden "22" meldete.
days_left = lic.tage_bis(expires, now=moment)
expired = delta.total_seconds() <= 0
if expired:
color = "red"
elif days_left < RED_BELOW_DAYS:
elif days_left <= RED_BELOW_DAYS:
# "<=", weil ``days_left`` **angefangene** Tage zaehlt: eine Lizenz mit
# 29 Tagen und einer Stunde steht als 30 da und liegt trotzdem unter
# der Schwelle von 30 vollen Tagen. Mit "<" waere sie orange -- die
# Warnung kaeme einen Tag zu spaet, und zwar genau am kritischen Rand.
color = "red"
elif days_left <= GREEN_ABOVE_DAYS:
color = "orange"
@@ -717,6 +833,189 @@ def display_state(row: dict[str, Any], *, now: datetime | None = None) -> dict[s
}
def ticket_verlaengern(conn: sqlite3.Connection, ticket_id: str, tage: int) -> int:
"""Schlaegt Tage auf die Laufzeit des Tickets auf. Rueckgabe: die neue Zahl.
Dasselbe Bedienmuster wie bei der Lizenz ("+ X Tage"), und aus demselben
Grund: wer verlaengert, rechnet in Tagen ab heute und nicht in einer neuen
Gesamtzahl, die er sich erst aus dem alten Wert zusammensuchen muss.
"""
ticket = get_ticket(conn, ticket_id)
if ticket is None:
raise IssuanceError("Dieses Ticket existiert nicht.")
if bool(ticket["lifetime"]):
raise IssuanceError("Dieses Ticket ist unbefristet -- da gibt es nichts zu verlängern.")
if int(tage) <= 0:
raise IssuanceError("Die Verlängerung muss eine Zahl größer als null sein.")
neu = int(ticket["valid_days"]) + int(tage)
conn.execute(
"UPDATE license_tickets SET valid_days=?, updated_at=? WHERE id=?",
(neu, _now(), ticket_id),
)
return neu
def lizenz_dem_ticket_nachziehen(
conn: sqlite3.Connection, ticket_id: str, *, master_private_key: str
) -> dict[str, Any] | None:
"""Zieht die laufende Lizenz eines Tickets auf dessen neue Laufzeit nach.
Gibt die neue Revision zurueck, oder ``None``, wenn es nichts zu tun gab.
**Nur verlaengern, nie verkuerzen.** Eine ausgestellte und signierte
Urkunde nachtraeglich zu beschneiden waere eine Zusage, die man einseitig
zurueckzieht -- und der Kunde erfuehre es erst, wenn seine Installation
stehenbleibt. Wird ein Ticket verkuerzt, gilt das fuer die naechste
Ausstellung.
"""
offene = Database.one(
conn,
"SELECT license_id FROM licenses WHERE ticket_id=? AND status IN ('issued','active') "
"AND type <> 'trial' LIMIT 1",
(ticket_id,),
)
if offene is None:
return None
license_id = str(offene["license_id"])
grenze = ticket_grenze(conn, license_id)
if grenze is None:
# Das Ticket ist unbefristet geworden -- dann auch die Lizenz.
row = get_license(conn, license_id)
if row is None or bool(row["lifetime"]):
return None
ticket = get_ticket(conn, ticket_id)
if ticket is None or not bool(ticket["lifetime"]):
return None
return resign_license(
conn,
license_id,
master_private_key=master_private_key,
changes={"lifetime": True},
ueber_ticket=True,
)
row = get_license(conn, license_id)
if row is None or bool(row["lifetime"]) or str(row["expires_at"]) >= grenze:
return None
return resign_license(
conn,
license_id,
master_private_key=master_private_key,
changes={"expires_at": grenze},
ueber_ticket=True,
)
def ticket_abweichung(conn: sqlite3.Connection, license_id: str) -> dict[str, Any] | None:
"""Weicht die Laufzeit dieser Lizenz von ihrem Ticket ab? Sonst ``None``.
Vier Faelle, und sie sind nicht symmetrisch:
=================== =================== ====================================
Ticket Lizenz Abgleich
=================== =================== ====================================
unbefristet unbefristet nichts zu tun
unbefristet befristet wird unbefristet -- ein Gewinn
befristet unbefristet wird befristet -- eine **Ruecknahme**
befristet befristet auf den Tag des Tickets
=================== =================== ====================================
``verkuerzt`` sagt, ob der Abgleich etwas wegnimmt. Die Oberflaeche faerbt
danach ihre Rueckfrage, und das Aenderungsprotokoll seine Stufe: eine
unbefristete Zusage zu befristen ist die schaerfste Form davon.
Testlizenzen bleiben aussen vor -- sie leben nach eigenen Regeln.
"""
row = get_license(conn, license_id)
if row is None or not row["ticket_id"] or str(row["type"]) == "trial":
return None
ticket = get_ticket(conn, str(row["ticket_id"]))
if ticket is None:
return None
ticket_unbefristet = bool(ticket["lifetime"])
lizenz_unbefristet = bool(row["lifetime"])
if ticket_unbefristet and lizenz_unbefristet:
return None
if ticket_unbefristet:
return {"grenze": None, "unbefristet": True, "verkuerzt": False}
grenze = ticket_grenze(conn, license_id)
if grenze is None:
return None
if not lizenz_unbefristet and str(row["expires_at"]) == grenze:
return None
return {
"grenze": grenze,
"unbefristet": False,
# Unbefristet zu befristen nimmt immer etwas weg; sonst entscheidet
# das Datum.
"verkuerzt": lizenz_unbefristet or grenze < str(row["expires_at"]),
}
def lizenz_auf_ticket_setzen(
conn: sqlite3.Connection, license_id: str, *, master_private_key: str
) -> dict[str, Any] | None:
"""Setzt die Laufzeit einer Lizenz auf das, was ihr Ticket hergibt.
In beide Richtungen -- das ist der Unterschied zum Nachziehen bei einer
Ticketverlaengerung, das nur verlaengert. Hier greift jemand ausdruecklich
zu, um Anspruch und Urkunde wieder zur Deckung zu bringen: eine Lizenz, die
laenger laeuft als ihr Ticket, ist eine Zusage ohne Grundlage.
**Das verkuerzt eine bereits ausgestellte Zusage.** Der Client uebernimmt
die neue Revision beim naechsten Heartbeat, und danach laeuft die
Installation frueher ab. Deshalb passiert es nur auf ausdruecklichen
Zugriff und nie nebenbei.
Rueckgabe: die neue Revision, oder ``None``, wenn nichts zu tun war.
"""
row = get_license(conn, license_id)
if row is None:
raise IssuanceError("Diese Lizenz existiert nicht.")
abweichung = ticket_abweichung(conn, license_id)
if abweichung is None:
return None
if abweichung["unbefristet"]:
# Das Ticket gewaehrt unbefristet, die Lizenz laeuft ab. Der Abgleich
# gibt etwas -- hier ist nichts zurueckzunehmen.
changes: dict[str, Any] = {"lifetime": True}
else:
# Ein Abgleich, der in die Vergangenheit zeigt, ist kein Abgleich: er
# laesst die Lizenz beim naechsten Heartbeat sofort ablaufen. Das mag
# gewollt sein -- dann heisst es Widerruf und hat einen eigenen Knopf,
# eine eigene Rueckfrage und einen eigenen Eintrag. Hier wird es
# abgewiesen, denn wer "auf die Ticketlaufzeit setzen" drueckt, will
# zwei Angaben zur Deckung bringen und nicht eine Installation
# stilllegen.
# Verglichen wird auf Zeitpunkten, nicht auf Zeichenketten: die
# Lizenzdaten enden auf "Z", ``_now()`` schreibt "+00:00" -- als Text
# sortiert sich das falsch herum.
if lic.parse_iso(str(abweichung["grenze"])) <= datetime.now(timezone.utc):
raise IssuanceError(
"Die Ticketlaufzeit ist bereits abgelaufen "
f"({lic.parse_iso(str(abweichung['grenze'])).strftime('%d.%m.%Y')}) -- ein "
"Abgleich würde diese Lizenz sofort ungültig machen. Verlängern Sie zuerst "
"das Ticket, oder widerrufen Sie die Lizenz bewusst."
)
# Auch aus einer unbefristeten Lizenz wird hier wieder eine befristete.
# Das ist die schaerfste Ruecknahme, die dieses System kennt, und
# genau deshalb hat sie einen eigenen Knopf mit eigener Rueckfrage --
# statt unmoeglich zu sein. Unmoeglich hiesse: eine unbefristete
# Zusage, die versehentlich entstanden ist, bleibt fuer immer stehen.
changes = {"lifetime": False, "expires_at": abweichung["grenze"]}
return resign_license(
conn,
license_id,
master_private_key=master_private_key,
changes=changes,
ueber_ticket=True,
)
def dashboard_summary(conn: sqlite3.Connection) -> dict[str, Any]:
rows = list_licenses(conn)
buckets: dict[str, list[dict[str, Any]]] = {"red": [], "orange": [], "green": []}
@@ -734,7 +1033,21 @@ def dashboard_summary(conn: sqlite3.Connection) -> dict[str, Any]:
),
"tickets_open": int(
Database.value(
conn, "SELECT COUNT(*) FROM license_tickets WHERE status='open'", default=0
conn,
"SELECT COUNT(*) FROM license_tickets t WHERE t.status='open' "
f"AND {OHNE_GUELTIGE_LIZENZ}",
default=0,
)
or 0
),
# Zur Einordnung unter der Kachel: was schon erledigt ist, soll man
# sehen, ohne die Liste zu oeffnen.
"tickets_redeemed": int(
Database.value(
conn,
"SELECT COUNT(*) FROM license_tickets t WHERE t.status='open' "
f"AND NOT {OHNE_GUELTIGE_LIZENZ}",
default=0,
)
or 0
),
@@ -122,14 +122,13 @@
informativ und werden bei jedem Bericht vollständig ersetzt.', 'info') }}
{% for entry in reported.customers %}
<details class="card">
<summary class="card__header" style="cursor:pointer">
<summary class="card__header">
<div class="flex-1">
<div class="card__title">{{ entry.name }}</div>
<div class="card__sub">
{{ entry.city or 'ohne Ort' }} &middot; {{ entry.tickets | length }} Ticket(s)
</div>
</div>
{{ icon('chevron-down', size=15) }}
</summary>
<div class="card__body">
{% for ticket in entry.tickets %}
@@ -16,7 +16,13 @@
{% else %}
<div class="grid grid--4">
{{ stat('Kunden', summary.counts.customers, href=url_for('customers.overview')) }}
{{ stat('Offene Tickets', summary.counts.tickets_open, href=url_for('tickets.overview')) }}
{# "Offen" heisst: noch nicht eingeloest. Ein Ticket, zu dem eine gueltige
Lizenz gehoert, ist erledigt -- es hier mitzuzaehlen schickte jemanden
auf die Suche nach Arbeit, die es nicht gibt. #}
{{ stat('Offene Tickets', summary.counts.tickets_open,
hint_text=(summary.counts.tickets_redeemed ~ ' eingelöst')
if summary.counts.tickets_redeemed else '',
href=url_for('tickets.overview')) }}
{{ stat('Aktive Lizenzen', summary.counts.licenses_active, kind='ok',
href=url_for('licenses.overview', status='active')) }}
{{ stat('Widerrufen', summary.counts.licenses_revoked, kind='muted',
@@ -35,14 +41,13 @@
{% set entries = summary.buckets[key] %}
{% if entries %}
<details class="card" data-persist="dash-{{ key }}" {% if key != 'green' %}open{% endif %}>
<summary class="card__header" style="cursor:pointer">
<summary class="card__header">
<div class="flex-1">
<div class="card__title">
{{ pill(entries | length, kind if kind != 'ok' else 'online') }} {{ label }}
</div>
<div class="card__sub">{{ hint }}</div>
</div>
{{ icon('chevron-down', size=16) }}
</summary>
<div class="card__body">
<div class="grid grid--tiles">
@@ -101,6 +101,8 @@
</div>
</section>
{# Die Laufzeit haengt am Ticket: weiter als dessen Anspruch reicht, geht
sie nicht. Das steht hier, damit es nicht erst die Fehlermeldung sagt. #}
{% if current_user.has('license_renew.execute') and license.status != 'revoked' %}
<section class="card">
<div class="card__header">
@@ -128,13 +130,47 @@
{% endif %}
{{ field('extend_days', 'Laufzeit verlängern (Tage)', type='number',
placeholder='0',
hint_text='Wird auf das aktülle Ablaufdatum aufgeschlagen.') }}
hint_text=('Höchstens bis zum Ende der Ticketlaufzeit (' ~ (ticket_grenze | dt) ~ ') -- weiter geht es nur über das Ticket.') if ticket_grenze else 'Wird auf das aktuelle Ablaufdatum aufgeschlagen.') }}
</div>
<div class="card__footer">
<button class="btn btn--primary" type="submit">Neue Revision signieren</button>
</div>
</form>
</section>
{# Anspruch und Urkunde wieder zur Deckung bringen. In beide Richtungen --
und wenn es verkürzt, ist das eine Rücknahme von Laufzeit: deshalb mit
Rückfrage und eigener Zeile im Änderungsprotokoll. #}
{% if abweichung %}
<section class="card">
<div class="card__header">
<div class="flex-1">
<div class="card__title">{{ icon('shield', size=17) }} Laufzeit weicht vom Ticket ab</div>
<div class="card__sub">
Diese Lizenz läuft
{% if license.lifetime %}<strong>unbefristet</strong>
{% else %}bis <strong>{{ license.expires_at | dt }}</strong>{% endif %},
das Ticket gewährt
{% if abweichung.unbefristet %}<strong>unbefristet</strong>
{% else %}bis <strong>{{ abweichung.grenze | dt }}</strong>{% endif %}.
</div>
</div>
</div>
<form method="post"
action="{{ url_for('licenses.align_ticket', license_id=license.license_id) }}">
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
<div class="card__footer">
<button class="btn" type="submit"
data-confirm="Die Laufzeit dieser Lizenz auf {% if abweichung.unbefristet %}unbefristet{% else %}{{ abweichung.grenze | dt }}{% endif %} setzen?{% if abweichung.verkuerzt %} Das nimmt bereits zugesagte Laufzeit zurück{% if license.lifetime %} -- aus einer unbefristeten Lizenz wird eine befristete{% endif %}. Der Client übernimmt es beim nächsten Heartbeat.{% endif %}"
data-confirm-title="Auf die Ticketlaufzeit setzen"
data-confirm-ok="Setzen"
data-confirm-style="{{ 'danger' if abweichung.verkuerzt else 'primary' }}">
Auf die Ticketlaufzeit setzen
</button>
</div>
</form>
</section>
{% endif %}
{% endif %}
<section class="card">
@@ -167,12 +203,11 @@
</section>
<details class="card" data-persist="license-document">
<summary class="card__header" style="cursor:pointer">
<summary class="card__header">
<div class="flex-1">
<div class="card__title">{{ icon('file-text', size=17) }} Signiertes Dokument</div>
<div class="card__sub">Genau so, wie es signiert wurde – jede Änderung bricht die Signatur</div>
</div>
{{ icon('chevron-down', size=16) }}
</summary>
<pre class="log-view">{{ document_json }}</pre>
</details>
@@ -19,7 +19,7 @@ werden bei jedem Bericht vollständig ersetzt.', 'info') }}
<div class="stack">
{% for entry in reported.customers %}
<details class="card" open>
<summary class="card__header" style="cursor:pointer">
<summary class="card__header">
<div class="flex-1">
<div class="card__title">{{ entry.name }}</div>
<div class="card__sub">
@@ -28,7 +28,6 @@ werden bei jedem Bericht vollständig ersetzt.', 'info') }}
&middot; {{ entry.tickets | length }} Ticket(s)
</div>
</div>
{{ icon('chevron-down', size=16) }}
</summary>
<div class="table-wrap">
<table class="table table--compact">
@@ -99,12 +99,11 @@
{% if history %}
<details class="card">
<summary class="card__header" style="cursor:pointer">
<summary class="card__header">
<div class="flex-1">
<div class="card__title">{{ icon('history', size=17) }} Früher ausgestellt</div>
<div class="card__sub">{{ history | length }} Einträge</div>
</div>
{{ icon('chevron-down', size=16) }}
</summary>
<div class="table-wrap">
<table class="table table--compact">
@@ -36,6 +36,8 @@
Es existiert bereits eine offene Lizenz – widerrufen Sie sie zuerst.
{% else %}
Erzeugt eine signierte Lizenzdatei mit den Werten dieses Tickets.
Die Laufzeit kommt aus dem Ticket und kann später nur mit ihm
verlängert werden.
{% endif %}
</div>
</div>
@@ -57,8 +59,11 @@
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
<input type="hidden" name="as_trial" value="1">
<button class="btn" type="submit"
{% if has_open_license or ticket.status == 'blocked' %}disabled{% endif %}
data-confirm="Testlizenz ausstellen? {{ trial_days }} Tage Laufzeit mit allen Modulen. Das Ticket selbst bleibt unverändert."
{# Eine laufende **Test**lizenz sperrt diesen Knopf, eine
Volllizenz nicht: ansehen und bekommen sind zweierlei,
und ein Ticket darf beides tragen. #}
{% if has_open_trial or ticket.status == 'blocked' %}disabled{% endif %}
data-confirm="Testlizenz ausstellen? {{ trial_days }} Tage Laufzeit mit allen Modulen. Das Ticket bleibt unverändert und offen -- eine Testlizenz löst es nicht ein."
data-confirm-title="Testlizenz" data-confirm-ok="Ausstellen"
data-confirm-style="primary">
Als Testlizenz ({{ trial_days }} Tage, alle Module)
@@ -184,6 +189,7 @@
</fieldset>
{% endif %}
{{ field('valid_days', 'Laufzeit (Tage)', value=ticket.valid_days, type='number',
hint_text='Gesamtlaufzeit ab Ausstellung. Aufschlagen geht unten.',
disabled=not current_user.has('tickets.write')) }}
{{ checkbox('lifetime', 'Unbefristet', checked=ticket.lifetime,
disabled=not current_user.has('tickets.write')) }}
@@ -203,6 +209,34 @@
</form>
</section>
{# Dasselbe Bedienmuster wie bei der Lizenz: wer verlängert, rechnet in
Tagen ab jetzt und nicht in einer neuen Gesamtzahl, die er sich erst aus
dem alten Wert zusammensuchen muss. Und weil das Ticket der Anspruch
ist, zieht die laufende Lizenz gleich mit. #}
{% if current_user.has('tickets.write') and not ticket.lifetime %}
<section class="card">
<div class="card__header">
<div class="flex-1">
<div class="card__title">{{ icon('clock', size=17) }} Laufzeit verlängern</div>
<div class="card__sub">
Schlägt Tage auf die {{ ticket.valid_days }} Tage dieses Tickets auf.
Eine laufende Lizenz wird dabei mitverlängert – sie erhält eine neue
Revision und übernimmt sie beim nächsten Heartbeat.
</div>
</div>
</div>
<form method="post" action="{{ url_for('tickets.extend', ticket_id=ticket.id) }}">
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
<div class="card__body">
{{ field('days', 'Verlängern um (Tage)', type='number', placeholder='30') }}
</div>
<div class="card__footer">
<button class="btn btn--primary" type="submit">Verlängern</button>
</div>
</form>
</section>
{% endif %}
{% if current_user.has('tickets.write') or current_user.has('tickets.delete') %}
<section class="card">
<div class="card__header">
@@ -62,7 +62,10 @@ Ausstellung -- bereits ausgestellte Lizenzen bleiben unverändert.', 'info') }}
{% else %}{{ ticket.valid_days }} Tage{% endif %}
</td>
<td>
{# Dieselbe Rechnung wie auf der Startseite, sonst widersprechen
sich die beiden Seiten: dort "0 offen", hier dreimal "offen". #}
{% if ticket.status == 'blocked' %}{{ pill('gesperrt', 'danger') }}
{% elif ticket.open_license_id %}{{ pill('eingelöst', '') }}
{% else %}{{ pill('offen', 'online') }}{% endif %}
</td>
<td class="fs-sm">
+33 -43
View File
@@ -26,6 +26,23 @@
# =============================================================================
set -euo pipefail
# Ein Lauf dieses Skripts gehoert in eine definierte Sprachumgebung -- nicht in
# die, die das Terminal des Administrators zufaellig ankuendigt. Per SSH kommt
# LC_ALL mit (sshd: "AcceptEnv LANG LC_*"), und ist diese Locale auf dem Ziel
# nicht erzeugt, warnt **jede neu gestartete Shell**:
#
# bash: warning: setlocale: LC_ALL: cannot change locale (en_US.UTF-8)
#
# Mitten in einer Aktualisierung sieht das nach einem Fehler aus und ist
# keiner. Nebenbei sind damit auch die Meldungen der Werkzeuge, die hier
# laufen, in einer Sprache -- ein Protokoll, das je nach Arbeitsplatz anders
# klingt, liest sich schlecht.
if locale -a 2>/dev/null | grep -qix 'c\.utf-\?8'; then
export LC_ALL=C.UTF-8 LANG=C.UTF-8
else
export LC_ALL=C LANG=C
fi
APP=""
INSTANCE=""
#: Verzeichnis der Umgebungsdateien. Ueberschreibbar, damit sich die
@@ -418,49 +435,16 @@ info "Dateien abgeglichen (Instanzdaten unter data/ bleiben unangetastet)"
# -- venv --------------------------------------------------------------------
step "Python-Umgebung"
# Ein venv haengt an der **Minor-Fassung**, mit der er gebaut wurde: die Pakete
# liegen unter lib/python3.X. Wechselt die Systemfassung -- ein Release-Upgrade
# von Ubuntu bringt genau das, 24.04 hat 3.12 und 26.04 hat 3.14 --, dann ist
# venv/bin/python3 weiter ausfuehrbar: der Verweis zeigt auf /usr/bin/python3,
# also auf den **neuen** Interpreter. Der venv sieht heil aus und ist es nicht.
# Er findet danach kein einziges Paket mehr, nicht einmal pip.
#
# Gemessen auf dem Testserver, mit verschobenem lib-Verzeichnis:
# [[ -x venv/bin/python3 ]] -> wahr
# venv/bin/python3 -m pip --version -> "No module named pip"
#
# Die alte Pruefung fragte nur nach der Ausfuehrbarkeit. Sie haette den toten
# venv behalten und waere eine Zeile spaeter an "python3 -m pip" gescheitert --
# mitten in einer Aktualisierung, auf einem Server, dessen Dienste ohnehin
# schon stehen. Gefragt wird deshalb, was gebraucht wird: laesst sich in
# diesem venv pip benutzen?
venv_traegt() {
[[ -x "$ROOT/venv/bin/python3" ]] || return 1
"$ROOT/venv/bin/python3" -c "import pip" >/dev/null 2>&1
}
if ! venv_traegt; then
# Ohne --clear: ein erneutes "venv" ergaenzt das fehlende lib-Verzeichnis und
# holt pip zurueck; die Abhaengigkeiten kommen gleich darunter ohnehin neu.
# Auch das ist gemessen und nicht angenommen.
python3 -m venv "$ROOT/venv"
info "Python-Umgebung aufgebaut ($(python3 -V 2>&1))"
fi
# Immer ueber "python3 -m pip" -- der pip-Shim im venv baeckt einen absoluten
# Pfad in seinen Shebang, der nach einer Umbenennung des Verzeichnisses bricht.
"$ROOT/venv/bin/python3" -m pip install --quiet --upgrade pip setuptools wheel
PAKETE=("$ROOT/packages/tesm-licensing" "$ROOT/packages/tesm-core")
# Die Serverhaelfte des Lizenzprotokolls -- ausstellen, erneuern, antworten --
# gehoert ausschliesslich zum Lizenzserver. Ein Client verifiziert Lizenzen und
# stellt Anfragen; den Code zum Ausstellen soll er nicht einmal mitbringen.
if [[ "$APP" == "tesm-license" ]]; then
[[ -d "$ROOT/packages/tesm-licensing-server" ]] ||
fail "packages/tesm-licensing-server fehlt -- ohne sie kann der Lizenzserver nichts ausstellen"
PAKETE+=("$ROOT/packages/tesm-licensing-server")
fi
"$ROOT/venv/bin/python3" -m pip install --quiet "${PAKETE[@]}" "$ROOT/apps/$APP" gunicorn
if [[ "$APP" == "tesm" ]]; then
"$ROOT/venv/bin/python3" -m pip install --quiet flask-sock
fi
# Aufbauen, pruefen und reparieren steht in genau einem Programm -- und
# dasselbe laeuft als ExecStartPre vor jedem Dienststart. Zwei getrennte
# Paketlisten waeren zwei Listen, die auseinanderlaufen: die Reparatur baute
# dann eine Umgebung, die nicht mehr der Installation entspricht.
# Ueber "bash" und nicht als Programm: Dateien im Baum tragen kein
# Ausfuehrungsrecht -- git kennt auf einer Windows-Buildmaschine keines, und
# install.sh, tesm-helper und tesm-motd werden aus demselben Grund ebenso
# aufgerufen oder mit ausdruecklichem Modus installiert. Ohne das brach die
# Aktualisierung mit "Permission denied" ab (und rollte zurueck).
bash "$ROOT/deploy/tesm-venv" "$ROOT" "$APP" --immer
info "Abhaengigkeiten installiert"
# -- Statische Dateien fuer nginx --------------------------------------------
@@ -490,6 +474,12 @@ install_if_changed() {
install_if_changed "$ROOT/deploy/tesm-helper" /usr/local/lib/tesm/tesm-helper 0755 && \
info "Helfer aktualisiert" || info "Helfer unveraendert"
# Derselbe Baustein, der oben die Umgebung gebaut hat: die Dienste rufen ihn
# vor jedem Start auf und reparieren damit, was ein Release-Upgrade des
# Betriebssystems ihnen unter den Fuessen weggezogen hat.
install_if_changed "$ROOT/deploy/tesm-venv" /usr/local/lib/tesm/tesm-venv 0755 >/dev/null \
&& info "Umgebungspfleger aktualisiert" || true
# visudo -c prueft die Datei, bevor sie aktiv wird -- eine kaputte sudoers-Datei
# waere sonst ein Totalausfall der Administration.
TMP_SUDO="$(mktemp)"
+12
View File
@@ -15,11 +15,23 @@ Environment=TESM_LICENSE_LOG_DIR=/var/log/tesm-license
Environment=TESM_LICENSE_WEB_PROCESS=1
Environment=TESM_LICENSE_COOKIE_SECURE=1
Environment=PYTHONUNBUFFERED=1
# Vor jedem Start: traegt die Python-Umgebung noch? Ein Release-Upgrade des
# Betriebssystems tauscht die Minor-Fassung von Python aus, und der venv haengt
# daran -- danach findet der Dienst kein einziges Paket mehr und scheitert mit
# "No module named gunicorn". Ist alles heil, kostet diese Zeile Millisekunden;
# sonst baut sie die Umgebung neu, einmal je Systemstart.
#
# Das "+" laesst den Schritt als root laufen. Noetig, weil der venv root
# gehoert, der Dienst aber als unprivilegierter Benutzer laeuft.
ExecStartPre=+/usr/local/lib/tesm/tesm-venv /srv/tesm-license tesm-license
ExecStart=/srv/tesm-license/venv/bin/gunicorn \
--workers 1 --worker-class gthread --threads 8 \
--bind 127.0.0.1:5001 \
--access-logfile - --error-logfile - \
tesm_license.wsgi:app
# Ein Neuaufbau der Python-Umgebung laedt Pakete und dauert Minuten --
# die Vorgabe von 90 Sekunden wuerde ihn mittendrin abschneiden.
TimeoutStartSec=900
Restart=always
RestartSec=5
+12
View File
@@ -12,7 +12,19 @@ Environment=TESM_BASE_DIR=/srv/tesm
Environment=TESM_DATA_DIR=/srv/tesm/data
Environment=TESM_LOG_DIR=/var/log/tesm
Environment=PYTHONUNBUFFERED=1
# Vor jedem Start: traegt die Python-Umgebung noch? Ein Release-Upgrade des
# Betriebssystems tauscht die Minor-Fassung von Python aus, und der venv haengt
# daran -- danach findet der Dienst kein einziges Paket mehr und scheitert mit
# "No module named gunicorn". Ist alles heil, kostet diese Zeile Millisekunden;
# sonst baut sie die Umgebung neu, einmal je Systemstart.
#
# Das "+" laesst den Schritt als root laufen. Noetig, weil der venv root
# gehoert, der Dienst aber als unprivilegierter Benutzer laeuft.
ExecStartPre=+/usr/local/lib/tesm/tesm-venv /srv/tesm tesm
ExecStart=/srv/tesm/venv/bin/tesm-monitor
# Ein Neuaufbau der Python-Umgebung laedt Pakete und dauert Minuten --
# die Vorgabe von 90 Sekunden wuerde ihn mittendrin abschneiden.
TimeoutStartSec=900
Restart=always
RestartSec=10
+12
View File
@@ -15,11 +15,23 @@ Environment=TESM_LOG_DIR=/var/log/tesm
Environment=TESM_WEB_PROCESS=1
Environment=TESM_COOKIE_SECURE=1
Environment=PYTHONUNBUFFERED=1
# Vor jedem Start: traegt die Python-Umgebung noch? Ein Release-Upgrade des
# Betriebssystems tauscht die Minor-Fassung von Python aus, und der venv haengt
# daran -- danach findet der Dienst kein einziges Paket mehr und scheitert mit
# "No module named gunicorn". Ist alles heil, kostet diese Zeile Millisekunden;
# sonst baut sie die Umgebung neu, einmal je Systemstart.
#
# Das "+" laesst den Schritt als root laufen. Noetig, weil der venv root
# gehoert, der Dienst aber als unprivilegierter Benutzer laeuft.
ExecStartPre=+/usr/local/lib/tesm/tesm-venv /srv/tesm tesm
ExecStart=/srv/tesm/venv/bin/gunicorn \
--workers 1 --worker-class gthread --threads 16 --timeout 0 \
--bind 127.0.0.1:5000 \
--access-logfile - --error-logfile - \
tesm.wsgi:app
# Ein Neuaufbau der Python-Umgebung laedt Pakete und dauert Minuten --
# die Vorgabe von 90 Sekunden wuerde ihn mittendrin abschneiden.
TimeoutStartSec=900
Restart=always
RestartSec=5
+4
View File
@@ -100,6 +100,10 @@ check_readable_path() {
case "$1" in
/var/log/kea/kea-dhcp4.log) ;;
/var/lib/kea/kea-leases4.csv) ;;
# Die eigene Kea-Konfiguration: 0640 root:_kea, also fuer den
# Dienstbenutzer nicht lesbar. Die Anwendung braucht sie, um zu sehen, ob
# der Dienst ueberhaupt mit dem laeuft, was sie geschrieben hat.
/etc/kea/kea-dhcp4.conf) ;;
/etc/netplan/90-*.yaml)
check_app_key "$(basename "$1" .yaml | sed 's/^90-//')" ;;
/etc/nginx/sites-available/*)
+173
View File
@@ -0,0 +1,173 @@
#!/usr/bin/env bash
# =============================================================================
# Die Python-Umgebung einer Installation -- aufbauen, pruefen, reparieren.
#
# tesm-venv /srv/tesm tesm # reparieren, falls noetig (Dienststart)
# tesm-venv /srv/tesm tesm --immer # neu bestuecken (Installation)
# tesm-venv /srv/tesm tesm --pruefen # nur melden: 0 heil, 1 kaputt
#
# **Warum es das gibt.** Ein venv haengt an der Minor-Fassung von Python: die
# Pakete liegen unter lib/python3.X. Ein Release-Upgrade des Betriebssystems
# tauscht diese Fassung aus (Ubuntu 24.04 bringt 3.12, 26.04 bringt 3.14) --
# und danach zeigt venv/bin/python3 weiter auf /usr/bin/python3, also auf den
# **neuen** Interpreter. Der venv ist ausfuehrbar und trotzdem tot: er findet
# kein einziges Paket mehr, nicht einmal pip. Jeder Dienst scheitert mit
# "ModuleNotFoundError: No module named 'gunicorn'", nginx meldet 502.
#
# Real passiert, auf beiden Testservern, am 16.09.2026.
#
# Dieses Programm laeuft deshalb als ExecStartPre vor jedem Dienststart. Ist
# alles heil, endet es nach Millisekunden. Sonst baut es die Umgebung neu --
# aus denselben Paketen, die auch die Installation einspielt, denn es ist
# dasselbe Programm: install.sh ruft es mit --immer auf. Zwei getrennte Listen
# waeren zwei Listen, die auseinanderlaufen.
#
# **Was es nicht tut:** die alten Pakete unter lib/python3.X uebernehmen.
# cryptography, argon2-cffi und bcrypt sind kompiliert und fuer die alte
# Fassung gebaut; ein halb uebernommener venv waere schlimmer als ein neuer.
# =============================================================================
set -euo pipefail
# **Der venv muss fuer den Dienstbenutzer lesbar sein.**
#
# Dieses Programm laeuft als ExecStartPre der Dienst-Unit -- und erbt damit
# deren UMask=0027. Alles, was es anlegt, gehoerte root und waere 0750: der
# Dienst selbst laeuft unprivilegiert und koennte sein eigenes site-packages
# nicht einmal betreten. Gemessen: nach der Reparatur ging "import gunicorn"
# als root, als Benutzer tesm nicht -- die Reparatur baute etwas, das genau
# der Prozess nicht benutzen konnte, fuer den sie gedacht war.
#
# Im venv liegt nichts Geheimes; Schluessel und Datenbank stehen woanders.
umask 022
ROOT="${1:-}"
APP="${2:-}"
MODUS="reparieren"
[[ "${3:-}" == "--immer" ]] && MODUS="immer"
[[ "${3:-}" == "--pruefen" ]] && MODUS="pruefen"
[[ -n "$ROOT" && -n "$APP" ]] || {
echo "Aufruf: tesm-venv <verzeichnis> <tesm|tesm-license> [--immer|--pruefen]" >&2
exit 2
}
[[ "$APP" == "tesm" || "$APP" == "tesm-license" ]] || {
echo "tesm-venv: unbekannte Anwendung '$APP'" >&2
exit 2
}
[[ -d "$ROOT" ]] || { echo "tesm-venv: $ROOT gibt es nicht" >&2; exit 2; }
NAME="$(basename "$ROOT")"
VENV="$ROOT/venv"
PY="$VENV/bin/python3"
MODUL="tesm"; [[ "$APP" == "tesm-license" ]] && MODUL="tesm_license"
LOG_DIR="${TESM_LOG_DIR:-/var/log/$NAME}"
#: Ein Versuch je Systemstart. /run ist ein tmpfs, der Merker verschwindet
#: also beim naechsten Start von selbst. Ohne ihn wuerde "Restart=always" nach
#: einem misslungenen Neuaufbau (kein Netz, kein PyPI) alle fuenf Sekunden
#: einen neuen pip-Lauf starten -- gegen einen Server, der ohnehin gerade
#: nicht erreichbar ist.
MERKER="/run/tesm-venv-$NAME.versucht"
melden() {
local stufe="$1" text="$2"
# Ins Journal: ExecStartPre-Ausgabe landet dort ohnehin.
printf 'tesm-venv: %s\n' "$text" >&2
# Und ins laufende Protokoll, damit es in der Oberflaeche sichtbar ist.
# Fehlschlaege hier duerfen den Start nicht kosten -- ein Protokoll ist
# kein Grund, einen Dienst nicht zu starten.
if [[ -d "$LOG_DIR" ]]; then
printf '%s %-7s %-10s %s\n' "$(date '+%Y-%m-%d %H:%M:%S')" "$stufe" "venv" "$text" \
>> "$LOG_DIR/live.log" 2>/dev/null || true
chown "$APP:$APP" "$LOG_DIR/live.log" 2>/dev/null || true
fi
}
#: Traegt diese Umgebung? Gefragt wird nicht nach der Versionsnummer, sondern
#: nach dem, was gebraucht wird -- so faellt auch ein venv auf, dem jemand ein
#: Paket herausgeloescht hat.
traegt() {
[[ -x "$PY" ]] || return 1
"$PY" -c "import pip" >/dev/null 2>&1 || return 1
# Beim allerersten Lauf ist die Anwendung noch nicht eingespielt -- dann
# zaehlt allein, dass pip benutzbar ist.
[[ "$MODUS" == "immer" ]] && return 0
"$PY" -c "import $MODUL" >/dev/null 2>&1
}
bestuecken() {
# **Jeder pip-Lauf wird einzeln geprueft.**
#
# "set -e" hilft hier nicht: diese Funktion wird in einer Bedingung
# aufgerufen ("if ! bestuecken"), und darin ist errexit fuer den **ganzen**
# Rumpf abgeschaltet. Ein gescheitertes pip lief deshalb einfach weiter bis
# zum "return 0" -- der Lauf meldete "Fertig", und im venv lagen weiter die
# alten Pakete.
#
# Real passiert: der Lizenzserver blieb ueber drei Auslieferungen hinweg auf
# 2.8.16 stehen, weil eine Abhaengigkeit nicht aufloesbar war. Gesehen hat es
# niemand, denn die Fehlerzeile von pip ging im Lauf unter und der
# Ausstiegscode war 0.
"$PY" -m pip install --quiet --upgrade pip setuptools wheel || return 1
local pakete=("$ROOT/packages/tesm-licensing" "$ROOT/packages/tesm-core")
if [[ "$APP" == "tesm-license" ]]; then
[[ -d "$ROOT/packages/tesm-licensing-server" ]] || {
echo "tesm-venv: packages/tesm-licensing-server fehlt" >&2
return 1
}
pakete+=("$ROOT/packages/tesm-licensing-server")
fi
"$PY" -m pip install --quiet "${pakete[@]}" "$ROOT/apps/$APP" gunicorn || return 1
# Die WebSocket-Bruecke braucht nur TESM (Terminal und Bildschirm).
if [[ "$APP" == "tesm" ]]; then
"$PY" -m pip install --quiet flask-sock || return 1
fi
return 0
}
# --------------------------------------------------------------------------- #
if [[ "$MODUS" == "pruefen" ]]; then
traegt && exit 0 || exit 1
fi
if traegt && [[ "$MODUS" != "immer" ]]; then
exit 0
fi
if [[ "$MODUS" != "immer" && -e "$MERKER" ]]; then
melden "WARNUNG" \
"Die Python-Umgebung von $NAME ist unbrauchbar, und der Neuaufbau ist seit dem letzten Systemstart schon einmal gescheitert. Erneuter Versuch: 'bash $ROOT/deploy/install.sh --app $APP --yes'."
exit 1
fi
touch "$MERKER" 2>/dev/null || true
if ! [[ -x "$PY" ]] || ! "$PY" -c "import pip" >/dev/null 2>&1; then
# Ohne --clear: ein erneutes "venv" ergaenzt das fehlende lib-Verzeichnis
# und holt pip zurueck. Gemessen, nicht angenommen.
python3 -m venv "$VENV"
[[ "$MODUS" == "immer" ]] ||
melden "HINWEIS" \
"Python-Umgebung von $NAME neu aufgebaut ($(python3 -V 2>&1)) -- das System hat seine Python-Fassung gewechselt."
fi
if ! bestuecken; then
melden "FEHLER" \
"Die Pakete fuer $NAME liessen sich nicht einspielen. Hat dieser Server Zugriff auf das Paketverzeichnis?"
exit 1
fi
# Und noch einmal ausdruecklich, fuer den Fall, dass ein frueherer Lauf mit
# strengerer Maske gebaut hat: was hier liegt, muss lesbar sein. Idempotent
# und in einer Sekunde durch.
chmod -R a+rX "$VENV" 2>/dev/null || true
# Vertrauen ist gut: nach dem Einspielen muss die Anwendung auch importierbar
# sein. Ohne diese Probe hiesse "Fertig" nur, dass pip nicht geschimpft hat.
if ! "$PY" -c "import $MODUL" >/dev/null 2>&1; then
melden "FEHLER" "Die Pakete fuer $NAME wurden eingespielt, aber '$MODUL' laesst sich nicht importieren."
exit 1
fi
[[ "$MODUS" == "immer" ]] || melden "INFO" "Python-Umgebung von $NAME ist wieder vollstaendig."
rm -f "$MERKER" 2>/dev/null || true
exit 0
+17
View File
@@ -29,6 +29,23 @@
# =============================================================================
set -euo pipefail
# Ein Lauf dieses Skripts gehoert in eine definierte Sprachumgebung -- nicht in
# die, die das Terminal des Administrators zufaellig ankuendigt. Per SSH kommt
# LC_ALL mit (sshd: "AcceptEnv LANG LC_*"), und ist diese Locale auf dem Ziel
# nicht erzeugt, warnt **jede neu gestartete Shell**:
#
# bash: warning: setlocale: LC_ALL: cannot change locale (en_US.UTF-8)
#
# Mitten in einer Aktualisierung sieht das nach einem Fehler aus und ist
# keiner. Nebenbei sind damit auch die Meldungen der Werkzeuge, die hier
# laufen, in einer Sprache -- ein Protokoll, das je nach Arbeitsplatz anders
# klingt, liest sich schlecht.
if locale -a 2>/dev/null | grep -qix 'c\.utf-\?8'; then
export LC_ALL=C.UTF-8 LANG=C.UTF-8
else
export LC_ALL=C LANG=C
fi
APP=""
INSTANCE=""
PURGE=0
+17
View File
@@ -18,6 +18,23 @@
# =============================================================================
set -euo pipefail
# Ein Lauf dieses Skripts gehoert in eine definierte Sprachumgebung -- nicht in
# die, die das Terminal des Administrators zufaellig ankuendigt. Per SSH kommt
# LC_ALL mit (sshd: "AcceptEnv LANG LC_*"), und ist diese Locale auf dem Ziel
# nicht erzeugt, warnt **jede neu gestartete Shell**:
#
# bash: warning: setlocale: LC_ALL: cannot change locale (en_US.UTF-8)
#
# Mitten in einer Aktualisierung sieht das nach einem Fehler aus und ist
# keiner. Nebenbei sind damit auch die Meldungen der Werkzeuge, die hier
# laufen, in einer Sprache -- ein Protokoll, das je nach Arbeitsplatz anders
# klingt, liest sich schlecht.
if locale -a 2>/dev/null | grep -qix 'c\.utf-\?8'; then
export LC_ALL=C.UTF-8 LANG=C.UTF-8
else
export LC_ALL=C LANG=C
fi
APP="${1:-}"
TAG="${2:-latest}"
+43
View File
@@ -383,6 +383,49 @@ sudo bash /srv/tesm/deploy/update.sh tesm
---
## 4bb. Release-Upgrade des Betriebssystems
Ein `do-release-upgrade` tauscht die Minor-Fassung von Python aus (24.04 bringt
3.12, 26.04 bringt 3.14). Der venv einer Installation hängt daran: seine Pakete
liegen unter `lib/python3.12`, während `venv/bin/python3` auf `/usr/bin/python3`
zeigt — also nach dem Upgrade auf den **neuen** Interpreter. Er ist ausführbar
und trotzdem tot; jeder Dienst scheitert mit `No module named gunicorn`, nginx
meldet 502.
**Das repariert TESM seit 2.8.33 von selbst.** Vor jedem Dienststart läuft
```
ExecStartPre=+/usr/local/lib/tesm/tesm-venv /srv/tesm tesm
```
Ist die Umgebung heil, kostet das Millisekunden. Sonst wird sie neu gebaut und
mit denselben Paketen bestückt, die auch die Installation einspielt — es ist
dasselbe Programm, `install.sh` ruft es mit `--immer` auf. Nach dem Neustart,
der auf jedes Release-Upgrade folgt, kommt der Dienst also von allein zurück;
im Live-Protokoll steht dann eine Zeile als Hinweis.
Von Hand prüfen oder anstoßen:
```bash
/usr/local/lib/tesm/tesm-venv /srv/tesm tesm --pruefen # 0 heil, 1 kaputt
/usr/local/lib/tesm/tesm-venv /srv/tesm tesm # reparieren
```
**Grenzen.** Der Neuaufbau lädt Pakete und braucht Zugriff auf das
Paketverzeichnis. Ohne Netz kann er nicht gelingen: dann wird **ein** Versuch je
Systemstart unternommen (Merker unter `/run`), und es bleibt bei einer klaren
Zeile im Journal und im Live-Protokoll. Ohne diese Bremse startete
`Restart=always` alle fünf Sekunden einen neuen `pip`-Lauf.
Die alten Pakete unter `lib/python3.<alt>` werden nicht übernommen —
`cryptography`, `argon2-cffi` und `bcrypt` sind kompiliert und für die alte
Fassung gebaut.
Empfohlene Reihenfolge bleibt trotzdem: erst TESM aktualisieren, dann
`do-release-upgrade`, dann prüfen.
---
## 4c. Installation entfernen
Das Gegenstück zu `install.sh`:
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "tesm-core"
version = "2.8.18"
version = "2.8.22"
description = "Gemeinsames Anwendungsgeruest (Auth, RBAC, Navigation, Design-System, Migrationen) fuer TESM und TESM-Lizenzserver"
requires-python = ">=3.11"
dependencies = [
@@ -162,6 +162,67 @@ class LicenseState:
return dict(self.status.vendor) if self.status else {}
#: Ausstiegscodes der Abfrage -- danach schlaegt ein RMM Alarm, nicht nach Text.
ABFRAGE_OK = 0
ABFRAGE_LAEUFT_AB = 1
ABFRAGE_UNGUELTIG = 2
#: Ab hier gilt eine Lizenz als "laeuft bald ab". Dieselbe Zahl wie in der
#: Oberflaeche -- zwei Schwellen fuer dieselbe Frage waeren eine zu viel.
ABFRAGE_WARNSCHWELLE_TAGE = 30
def zustand_als_auskunft(state: "LicenseState") -> dict[str, Any]:
"""Der Lizenzzustand als schlichtes Woerterbuch -- fuer JSON und Skripte.
Gedacht fuer ein RMM, das auf der Maschine selbst laeuft: kein Netz, kein
Token, keine Anmeldung. Was hier steht, beantwortet die eine Frage, die ein
Kunde wirklich hat -- wie lange laeuft die Lizenz noch --, und nichts
darueber hinaus: keine Schluessel, keine Kennungen anderer Installationen.
"""
status = state.status
return {
"valid": bool(state.valid),
"activated": bool(state.activated),
"type": str(status.type) if status else "",
"role": state.role,
"lifetime": bool(status.lifetime) if status else False,
"expires_at": lic.iso(status.expires_at) if status and status.expires_at else "",
# Bei einer unbefristeten Lizenz ist die Zahl bewusst ``null`` und
# keine grosse Zahl: "noch 99999 Tage" waere eine Behauptung.
"days_left": (status.days_left if status and not status.lifetime else None),
"expiring_soon": bool(status.expiring_soon) if status else False,
"grace_active": bool(status.grace_active) if status else False,
# Drei Angaben, weil zwei davon verschieden sein koennen und die
# Verwechslung teuer ist: "modules" ist, was die **Lizenz** freigibt --
# dieselbe Liste, die der Lizenzserver zeigt. Was davon vor Ort
# abgeschaltet wurde, steht daneben.
#
# Vorher stand hier allein das Laufende. Beim Kunden fehlte darin
# "dhcp", waehrend die Lizenzseite es auswies -- es war lediglich unter
# Verwaltung -> System abgeschaltet. Aus der Antwort ging das nicht
# hervor, und damit sah es nach einem Lizenzproblem aus.
"modules": sorted(state.modules),
"modules_active": sorted(state.modules_in_use),
"modules_off": sorted(state.modules_off),
"customer": state.customer_name,
"last_heartbeat_at": state.last_heartbeat_at,
"error": state.error,
}
def abfrage_ausstiegscode(auskunft: dict[str, Any]) -> int:
"""0 heil, 1 laeuft bald ab, 2 ungueltig -- die Sprache eines RMM."""
if not auskunft.get("valid"):
return ABFRAGE_UNGUELTIG
if auskunft.get("lifetime"):
return ABFRAGE_OK
tage = auskunft.get("days_left")
if tage is None or int(tage) <= ABFRAGE_WARNSCHWELLE_TAGE:
return ABFRAGE_LAEUFT_AB
return ABFRAGE_OK
class LicenseManager:
"""Haelt den Lizenzzustand und fuehrt die Handshakes durch."""
@@ -657,9 +657,19 @@
gap: var(--sp-5);
}
/* Bleibt stehen wie die Kopfzeile: Fassung und angemeldeter Benutzer sind
Angaben, nach denen man mitten in einer langen Seite fragt -- und bis
hierher zu scrollen, nur um zu sehen, welche Fassung laeuft, ist eine
Zumutung. Derselbe milchige Untergrund wie oben, damit Inhalt, der
darunter durchlaeuft, nicht mitliest. */
.page-footer {
padding: var(--sp-4) var(--sp-5);
position: sticky;
bottom: 0;
z-index: var(--z-sticky);
padding: var(--sp-3) var(--sp-5);
border-top: 1px solid var(--border-subtle);
background: color-mix(in srgb, var(--canvas) 82%, transparent);
backdrop-filter: blur(12px) saturate(1.4);
color: var(--text-faint);
font-size: var(--fs-xs);
display: flex;
@@ -1131,6 +1131,43 @@
white-space: pre-wrap;
word-break: break-word;
}
/* ---------------------------------------------------------------------- #
Aufklappbares
---------------------------------------------------------------------- #
Der native Marker eines <summary> verschwindet, sobald das Element ein
Flex-Container ist -- und genau das sind unsere Kartenkoepfe. Auf der
DHCP-Seite stand der Optionskatalog deshalb als Kasten da, dem man nicht
ansah, dass er sich oeffnen laesst.
Der Pfeil wird deshalb selbst gezeichnet: als Flex- bzw. Inline-Element,
das in beiden Faellen erscheint, in Akzentfarbe (das ist die Farbe, mit
der diese Oberflaeche "hier kannst du etwas tun" sagt) und mit einer
Drehung beim Oeffnen. Eine Stelle fuer alle Aufklapper -- Karten,
Vorschauen, Abschnitte. */
details > summary {
list-style: none;
cursor: pointer;
}
details > summary::-webkit-details-marker { display: none; }
details:not(.nav-tree) > summary::before {
content: "";
display: inline-block;
flex: 0 0 auto;
align-self: center;
width: 0;
height: 0;
margin-right: var(--sp-2);
border-left: 6px solid var(--accent);
border-top: 5px solid transparent;
border-bottom: 5px solid transparent;
transition: transform var(--dur) var(--ease);
}
details:not(.nav-tree)[open] > summary::before { transform: rotate(90deg); }
/* Der Navigationsbaum bringt seinen eigenen Winkel mit -- der bekommt
dieselbe Farbe, damit "aufklappbar" ueberall gleich aussieht. */
.nav-tree__chevron { color: var(--accent); }
.log-view mark { background: var(--warning-soft); color: var(--warning); border-radius: 3px; }
/* Stufen im laufenden Protokoll. Die Zuordnung der Farbrollen ist Absicht:
@@ -73,12 +73,11 @@ blieb dadurch über Jahre bestehen und lieferte alle statischen Dateien als 404.
</section>
<details class="card" data-persist="nginx-preview">
<summary class="card__header" style="cursor:pointer">
<summary class="card__header">
<div class="flex-1">
<div class="card__title">{{ icon('file-text', size=17) }} Erzeugte nginx-Konfiguration</div>
<div class="card__sub">Vorschau der Datei, die geschrieben würde</div>
</div>
{{ icon('chevron-down', size=16) }}
</summary>
<pre class="log-view">{{ preview }}</pre>
</details>
@@ -4,10 +4,16 @@ build-backend = "setuptools.build_meta"
[project]
name = "tesm-licensing-server"
version = "2.7.7"
version = "2.7.8"
description = "Serverhaelfte des TESM-Lizenzprotokolls: ausstellen, erneuern, antworten"
requires-python = ">=3.11"
dependencies = ["tesm-licensing==2.7.7", "cryptography>=41"]
# ``==`` war eine Falle: die Serverhaelfte war damit an genau eine Fassung des
# Protokolls genagelt. Sobald tesm-licensing eine neue Nummer bekam, konnte pip
# beides nicht mehr zugleich aufloesen -- und der Lizenzserver blieb still auf
# dem alten Stand stehen, waehrend der Lauf "Fertig" meldete. Untergrenze statt
# Nagel: beide Haelften liegen ohnehin im selben Paket und werden zusammen
# ausgeliefert.
dependencies = ["tesm-licensing>=2.7.7", "cryptography>=41"]
[tool.setuptools.packages.find]
where = ["src"]
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "tesm-licensing"
version = "2.7.7"
version = "2.7.8"
description = "Ed25519-basiertes Lizenz- und Aktivierungsprotokoll fuer TESM und TESM-Lizenzserver"
requires-python = ">=3.11"
dependencies = ["cryptography>=42"]
@@ -46,6 +46,7 @@ from .license import (
key_id,
license_status,
parse_iso,
tage_bis,
split_bundle,
utcnow,
verify_license,
@@ -103,6 +104,7 @@ __all__ = [
"machine_identity",
"new_nonce",
"parse_iso",
"tage_bis",
"sign",
"split_bundle",
"system_fingerprint",
@@ -221,6 +221,27 @@ def verify_license(
)
def tage_bis(expires_at: datetime, *, now: datetime | None = None) -> int:
"""Restlaufzeit in **angefangenen** Tagen.
Aufgerundet, und das ist eine Entscheidung: eine Lizenz, die in zwei
Stunden ablaeuft, hat "1 Tag" und nicht "0". Null bedeutet hier
ausschliesslich *abgelaufen* -- eine gueltige Lizenz, die mit "0 Tage"
dasteht, liest sich wie eine tote.
**Es gibt genau diese eine Rechnung.** Der Lizenzserver hatte eine zweite,
abgerundete: dieselbe Lizenz zeigte in seiner Oberflaeche "21 Tage" und im
Abruf beim Kunden "22". Zwei Zahlen fuer dieselbe Frage sind schlimmer als
eine ungenaue -- sie kosten jemanden einen Nachmittag mit der Suche nach
einem Fehler, den es nicht gibt.
"""
moment = now or utcnow()
sekunden = int((expires_at - moment).total_seconds())
if sekunden <= 0:
return 0
return -(-sekunden // 86400)
def license_status(
document: Mapping[str, Any],
*,
@@ -278,7 +299,7 @@ def license_status(
days_left = 0
days_since_expiry = int((moment - expires_at).total_seconds() // 86400)
else:
days_left = -(-int(delta.total_seconds()) // 86400)
days_left = tage_bis(expires_at, now=moment)
days_since_expiry = None
grace_active = bool(