Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1a13ce4c2a | ||
|
|
7f8f4f9de8 | ||
|
|
e098306aef | ||
|
|
68bda48d72 | ||
|
|
50b212caec | ||
|
|
0117489b5a | ||
|
|
f0f0d05e19 | ||
|
|
0024a116ce | ||
|
|
34a4923304 | ||
|
|
98c2b2aa14 | ||
|
|
641ea22b3c | ||
|
|
b984e22074 | ||
|
|
1d4e22b082 | ||
|
|
23c091e99f |
No files matched your search
@@ -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,17 +193,80 @@ def update(ticket_id: str):
|
||||
scope="ticket",
|
||||
)
|
||||
reporting.report_soon(core())
|
||||
flash(
|
||||
"Gespeichert. Die Änderung wirkt auf die nächste Ausstellung."
|
||||
if changes
|
||||
else "Keine Änderungen.",
|
||||
"success" if changes else "info",
|
||||
)
|
||||
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
|
||||
else "Keine Änderungen.",
|
||||
"success" if changes else "info",
|
||||
)
|
||||
except (issuance.IssuanceError, ValueError) as exc:
|
||||
flash(str(exc), "danger")
|
||||
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):
|
||||
|
||||
@@ -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' }} · {{ 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') }}
|
||||
· {{ 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
@@ -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)"
|
||||
|
||||
@@ -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,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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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/*)
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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}"
|
||||
|
||||
|
||||
@@ -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`:
|
||||
|
||||
@@ -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"]
|
||||
@@ -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(
|
||||
|
||||
Reference in new issue
Block a user