TESM-Lizenzserver 2.0.0 -- Neubau

Vollstaendiger Neubau der Anwendung. Der vorherige Stand bleibt unveraendert
im Zweig SONNET5 erhalten.

Aufbau: apps/tesm-license (Anwendung), packages/tesm-core (gemeinsamer Kern),
packages/tesm-licensing (Lizenzprotokoll), deploy (Installation, systemd,
privilegierter Helfer), docs, tests. Die verwaltete Anwendung liegt in ihrem
eigenen Repository; beide Repositorien bringen die gemeinsamen Pakete mit,
damit sich jedes allein installieren laesst.

Die wichtigsten Unterschiede zum Vorgaenger:

* Keine doppelte licensing.py -- ein Paket, das beide Anwendungen
  installieren, statt zweier Dateien, die byte-identisch bleiben sollen.
* Der Webprozess laeuft unprivilegiert; alles, was Root braucht, geht ueber
  einen einzigen Helfer mit Positivlisten fuer jedes Argument.
* CSRF-Schutz ueberhaupt -- der Vorgaenger hatte keinen.
* Rechte werden serverseitig geprueft, nicht nur im Template ausgeblendet.
* Keine Lizenz ohne master_endpoint: eine Ausstellung ohne Endpunkt wird
  abgelehnt statt eine Lizenz zu erzeugen, die sich nie aktivieren kann.
* Offline-Aktivierung in beide Richtungen; die Lizenz bleibt als
  "Aktivierung offen" markiert, bis sie zurueckkommt.
* Getrennte Signaturkontexte je Nachrichtenart, Nonce gegen Wiedereinspielung,
  seq gegen das Zurueckrollen auf eine aeltere Lizenz.
* Kein Hostname im Maschinen-Fingerabdruck.
* Verschachtelte Datenbankverbindungen sind ein Fehler, kein Deadlock.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-02 18:01:23 +02:00
co-authored by Claude Opus 5
parent c130c85b17
commit f7805a2180
211 changed files with 31099 additions and 14759 deletions
+27
View File
@@ -0,0 +1,27 @@
[build-system]
requires = ["setuptools>=68"]
build-backend = "setuptools.build_meta"
[project]
name = "tesm-license"
version = "2.0.0"
description = "TESM-Lizenzserver -- Kunden, Tickets, Lizenzen, Aktivierung"
requires-python = ">=3.11"
dependencies = [
"tesm-core>=2.0.0",
"tesm-licensing>=2.0.0",
"PyYAML>=6.0",
"ldap3>=2.9",
]
[project.optional-dependencies]
server = ["gunicorn>=23.0"]
[project.scripts]
tesm-license-admin = "tesm_license.cli:main"
[tool.setuptools.packages.find]
where = ["src"]
[tool.setuptools.package-data]
tesm_license = ["templates/**/*.html", "static/**/*"]
@@ -0,0 +1,194 @@
"""TESM-Lizenzserver -- Kunden, Tickets, Lizenzen, Aktivierung.
Kein Fork von TESM: beide Anwendungen bauen auf demselben Paket ``tesm_core``
auf. Der Vorgaenger kopierte die gesamte Codebasis und entfernte danach, was er
nicht brauchte -- mit der Folge, dass jede Korrektur zweimal gemacht werden
musste (und ``licensing.py`` byte-identisch gehalten werden *musste*).
"""
from __future__ import annotations
import logging
from pathlib import Path
from typing import Any
from flask import Flask
from tesm_core.app_factory import create_base_app
from tesm_core.config import load_core_config
from tesm_core.extension import core
from tesm_core.views import trash as trash_views
from .navigation import NAVIGATION
from .permissions import PERMISSIONS, ROLE_PRESETS
from .schema import LICENSE_MIGRATIONS
from .settings_spec import LICENSE_SERVER_SETTINGS
__version__ = "2.0.0"
APP_KEY = "tesm-license"
APP_NAME = "TESM-Lizenzserver"
APP_SHORT = "TL"
APP_EDITION = "Lizenzverwaltung"
ENV_PREFIX = "TESM_LICENSE_"
logger = logging.getLogger(__name__)
def master_key_path(config: Any) -> Path:
return Path(config.data_dir) / "master_signing_key.json"
def create_app(**overrides: Any) -> Flask:
config = load_core_config(
app_key=APP_KEY,
app_name=APP_NAME,
env_prefix=ENV_PREFIX,
default_bind="127.0.0.1:5001",
)
app = create_base_app(
config=config,
permissions=PERMISSIONS,
nav=NAVIGATION,
role_presets=ROLE_PRESETS,
app_migrations={"tesm_license": LICENSE_MIGRATIONS},
settings_specs=LICENSE_SERVER_SETTINGS,
import_name="tesm_license",
app_short=APP_SHORT,
app_edition=APP_EDITION,
app_version=__version__,
enable_license=True,
)
_register_blueprints(app)
_register_registry(app)
_startup_tasks(app)
if overrides:
app.config.update(overrides)
return app
def _register_blueprints(app: Flask) -> None:
from .blueprints import (
api,
backup,
customers,
dashboard,
licenses,
own_license,
portal,
selfservice,
settings,
tickets,
)
app.register_blueprint(dashboard.bp)
app.register_blueprint(customers.bp)
app.register_blueprint(tickets.bp)
app.register_blueprint(licenses.bp)
app.register_blueprint(api.bp)
app.register_blueprint(portal.bp)
app.register_blueprint(selfservice.bp)
app.register_blueprint(own_license.bp)
app.register_blueprint(settings.bp)
app.register_blueprint(backup.bp)
# Die Uebersicht ist die Startseite. Anders als bei TESM verlangt sie eine
# Anmeldung: das hier ist eine interne Verwaltungsanwendung, kein
# Informationsboard fuer das Netz.
app.add_url_rule("/", "index", dashboard.index_view)
def _register_registry(app: Flask) -> None:
from tesm_core.system import hostnet # noqa: F401 - haelt das Modul geladen
from .services import issuance, mail, reporting
from .services.monitorlog import LiveLog
extension = core(app)
# Beide Anwendungen koennen auf demselben Host laufen -- eigener Port je Dienst.
extension.registry["upstream"] = extension.config.bind
extension.registry["static_root"] = str(extension.config.base_dir / "static")
extension.registry["websocket_path"] = ""
extension.registry["master_key_path"] = master_key_path(extension.config)
extension.registry["live_log"] = LiveLog(extension.config.log_dir / "live.log")
extension.registry["status_pill_builder"] = _status_pills
extension.registry["reencrypt_secrets"] = _reencrypt_secrets
# Als Sub-Lizenzserver meldet diese Instanz ihren Bestand an ihren Master.
if extension.license is not None:
extension.license.set_report_builder(reporting.build_own_report)
def _reencrypt_secrets(conn: Any, keystore: Any) -> int:
"""Verschluesselt gespeicherte Geheimnisse unter dem neuen Datenschluessel neu."""
from tesm_core.auth.directory import AAD_LDAP_BIND, SERVICE_PURPOSE as LDAP_PURPOSE
from tesm_core.db import Database
from tesm_core.keystore import DecryptionError
from .services.mail import AAD_GRAPH_SECRET, SERVICE_PURPOSE as MAIL_PURPOSE
migrated = 0
for purpose, aad in ((LDAP_PURPOSE, AAD_LDAP_BIND), (MAIL_PURPOSE, AAD_GRAPH_SECRET)):
token = Database.value(
conn, "SELECT password_encrypted FROM service_accounts WHERE purpose=?", (purpose,)
)
if not token or not keystore.needs_reencrypt(str(token)):
continue
try:
plain = keystore.decrypt(str(token), aad=aad)
except DecryptionError:
continue
conn.execute(
"UPDATE service_accounts SET password_encrypted=? WHERE purpose=?",
(keystore.encrypt(plain, aad=aad), purpose),
)
migrated += 1
return migrated
def _status_pills(user: Any) -> list[dict[str, str]]:
if not user.is_authenticated or not user.has_any(("licenses.view",)):
return []
from tesm_core.extension import db
from .services import issuance
summary = issuance.dashboard_summary(db())
critical = len(summary["buckets"]["red"])
if not critical:
return []
return [
{
"label": f"{critical} Lizenz(en) kritisch",
"css_class": "status-pill--danger",
"dot": "1",
"dot_class": "status-pill__dot--pulse",
"title": "Weniger als 30 Tage Restlaufzeit oder bereits abgelaufen.",
}
]
def _startup_tasks(app: Flask) -> None:
extension = core(app)
if not extension.config.web_process:
logger.info("Startaufgaben uebersprungen -- kein Webprozess.")
return
from .services import activation
with extension.database.session() as conn, extension.database.transaction(conn):
removed = activation.prune_nonces(conn)
if removed:
logger.info("%s abgelaufene Protokoll-Nonces entfernt", removed)
# Auch der Lizenzserver hat eine eigene Lizenz (Rolle master oder
# license_server) und meldet sich bei einem Master, falls es einen gibt.
if extension.license is not None:
import socket
extension.license.start_heartbeat(hostname=socket.gethostname())
app = None # wird von wsgi.py gesetzt
@@ -0,0 +1 @@
"""Blueprints des Lizenzservers."""
@@ -0,0 +1,115 @@
"""Maschinen-API: Aktivierung, Deaktivierung, Heartbeat.
Diese Endpunkte werden nicht ueber Cookies autorisiert, sondern ueber die
Ed25519-Signatur der Anfrage -- darum sind sie vom CSRF-Schutz ausgenommen. Das
ist die einzige Ausnahme in der ganzen Anwendung, und sie ist es nur, weil hier
gar kein Cookie im Spiel ist.
Zusaetzlich greift eine Ratenbegrenzung pro Absenderadresse: ohne sie waere der
Endpunkt ein kostenloser Weg, Argon2- und Ed25519-Rechenzeit zu verbrennen.
"""
from __future__ import annotations
import logging
from flask import Blueprint, jsonify, request
from tesm_core.extension import core, db, transaction
from tesm_core.security import csrf, ratelimit
from ..services import activation
from .common import signing_key
logger = logging.getLogger(__name__)
bp = Blueprint("api", __name__, url_prefix="/api/v1/licenses")
RATE_BUCKET = "api:license"
RATE_LIMIT = 60
RATE_WINDOW_MINUTES = 5
MAX_BODY_BYTES = 512 * 1024
def _client_ip() -> str:
from tesm_core.auth.service import client_ip
return client_ip() or "unbekannt"
def _rate_limited() -> bool:
conn = db()
verdict = ratelimit.check(
conn,
RATE_BUCKET,
_client_ip(),
limit=RATE_LIMIT,
window_minutes=RATE_WINDOW_MINUTES,
)
with transaction():
ratelimit.record(conn, RATE_BUCKET, _client_ip())
return verdict.blocked
def _handle(action: str):
if _rate_limited():
return (
jsonify({"error": "rate_limited", "message": "Zu viele Anfragen."}),
429,
{"Retry-After": str(RATE_WINDOW_MINUTES * 60)},
)
if request.content_length and request.content_length > MAX_BODY_BYTES:
return jsonify({"error": "too_large", "message": "Anfrage zu gross."}), 413
payload = request.get_json(silent=True)
if not isinstance(payload, dict):
return jsonify({"error": "bad_request", "message": "JSON-Objekt erwartet."}), 400
processor = activation.PROCESSORS[action]
key = signing_key()
conn = db()
with transaction():
outcome = processor(conn, payload, master_private_key=key.private_key)
live = core().registry.get("live_log")
if live is not None:
target = str(payload.get("license_id") or "unbekannt")[:12]
if outcome.ok:
live.info("api", f"{action} von {target} -- {outcome.response.get('status')}")
else:
live.warning(
"api", f"{action} von {target} abgewiesen: {outcome.response.get('error')}"
)
return jsonify(outcome.response), outcome.status_code
@bp.post("/activate")
@csrf.exempt
def activate():
return _handle("activate")
@bp.post("/deactivate")
@csrf.exempt
def deactivate():
return _handle("deactivate")
@bp.post("/heartbeat")
@csrf.exempt
def heartbeat():
return _handle("heartbeat")
@bp.get("/hinweis")
def info():
"""Kurze, oeffentliche Selbstbeschreibung -- ohne verwertbare Details."""
import tesm_licensing as lic
return jsonify(
{
"service": "tesm-license",
"protocol": lic.FORMAT_VERSION,
"actions": list(lic.ACTIONS),
}
)
@@ -0,0 +1,201 @@
"""Sicherung und Uebertragung des Lizenzservers."""
from __future__ import annotations
from datetime import datetime
from flask import Blueprint, Response, flash, redirect, render_template, request, session, url_for
from tesm_core import audit
from tesm_core.auth.models import current_user, permission_required, reauth_required
from tesm_core.auth.service import client_ip
from tesm_core.extension import core, db, transaction
from ..services import transfer
from .common import log_event
bp = Blueprint("backup", __name__, url_prefix="/sicherung")
MAX_IMPORT_BYTES = 64 * 1024 * 1024
PREVIEW_SESSION_KEY = "_import_token"
def _allowed_categories() -> list[transfer.Category]:
allowed: list[transfer.Category] = []
for category in transfer.CATEGORIES:
if category.admin_only and not current_user.is_admin:
continue
if category.sensitive and not current_user.has("backup.secrets"):
continue
allowed.append(category)
return allowed
@bp.get("/")
@permission_required("backup.view", "backup.export", "backup.execute", mode="any")
def overview():
conn = db()
preview = None
token = session.get(PREVIEW_SESSION_KEY, "")
if token:
try:
payload = transfer.load_preview(conn, token, actor=current_user.username)
preview = {"token": token, "summary": transfer.summarize(payload)}
except transfer.TransferError:
session.pop(PREVIEW_SESSION_KEY, None)
return render_template(
"license/backup.html",
categories=_allowed_categories(),
all_categories=transfer.CATEGORIES,
preview=preview,
min_passphrase=transfer.MIN_PASSPHRASE_LENGTH,
preview_ttl=transfer.PREVIEW_TTL_MINUTES,
can_secrets=current_user.has("backup.secrets"),
signing_key_path=str(core().registry["master_key_path"]),
)
@bp.post("/export")
@permission_required("backup.export")
@reauth_required
def export():
conn = db()
allowed = {category.key for category in _allowed_categories()}
chosen = [key for key in request.form.getlist("categories") if key in allowed]
if not chosen:
flash("Es wurde keine Kategorie ausgewaehlt.", "warning")
return redirect(url_for("backup.overview"))
passphrase = request.form.get("passphrase") or ""
if passphrase != (request.form.get("passphrase_repeat") or ""):
flash("Die beiden Passphrasen stimmen nicht ueberein.", "danger")
return redirect(url_for("backup.overview"))
try:
envelope = transfer.seal(transfer.build_export(conn, categories=chosen), passphrase)
except transfer.TransferError as exc:
flash(str(exc), "danger")
return redirect(url_for("backup.overview"))
log_event(
"backup.exported",
detail=f"Kategorien: {', '.join(chosen)}",
severity="warning",
scope="sicherung",
)
stamp = datetime.now().strftime("%Y%m%d-%H%M")
return Response(
envelope.encode("utf-8"),
mimetype="application/json",
headers={
"Content-Disposition": f'attachment; filename="tesm-lizenzserver-{stamp}.json"',
"Cache-Control": "no-store",
},
)
@bp.post("/import/vorschau")
@permission_required("backup.execute")
@reauth_required
def import_preview():
conn = db()
upload = request.files.get("backup_file")
if upload is None or not upload.filename:
flash("Keine Datei ausgewaehlt.", "danger")
return redirect(url_for("backup.overview"))
raw = upload.stream.read(MAX_IMPORT_BYTES + 1)
if len(raw) > MAX_IMPORT_BYTES:
flash("Die Datei ist zu gross.", "danger")
return redirect(url_for("backup.overview"))
try:
payload, header = transfer.unseal(raw, request.form.get("passphrase") or "")
except transfer.TransferError as exc:
flash(str(exc), "danger")
return redirect(url_for("backup.overview"))
with transaction():
token = transfer.store_preview(conn, payload, actor=current_user.username)
session[PREVIEW_SESSION_KEY] = token
log_event(
"backup.import_previewed",
detail=f"Erstellt {header.get('created_at')}",
severity="notice",
scope="sicherung",
)
flash(
f"Datei geoeffnet. Sie haben {transfer.PREVIEW_TTL_MINUTES} Minuten Zeit, die "
"Uebernahme zu bestaetigen.",
"success",
)
return redirect(url_for("backup.overview"))
@bp.post("/import/uebernehmen")
@permission_required("backup.execute")
@reauth_required
def import_apply():
extension = core()
conn = db()
token = session.get(PREVIEW_SESSION_KEY, "")
if not token:
flash("Es liegt keine geoeffnete Sicherung vor.", "danger")
return redirect(url_for("backup.overview"))
allowed = {category.key for category in _allowed_categories()}
chosen = [key for key in request.form.getlist("categories") if key in allowed]
if not chosen:
flash("Es wurde keine Kategorie ausgewaehlt.", "warning")
return redirect(url_for("backup.overview"))
try:
payload = transfer.load_preview(conn, token, actor=current_user.username)
except transfer.TransferError as exc:
session.pop(PREVIEW_SESSION_KEY, None)
flash(str(exc), "danger")
return redirect(url_for("backup.overview"))
with transaction():
report = transfer.apply_import(
conn,
payload,
categories=chosen,
set_setting=lambda key, value: extension.settings.set(
conn, key, value, actor=current_user.username
),
)
transfer.drop_preview(conn, token)
audit.log(
conn,
actor=current_user.username,
actor_id=current_user.id,
action="backup.imported",
detail="; ".join(
f"{key}: {counts['created']} neu, {counts['updated']} aktualisiert, "
f"{counts['skipped']} uebersprungen"
for key, counts in report.items()
),
ip=client_ip(),
severity="warning",
)
session.pop(PREVIEW_SESSION_KEY, None)
lines = [
f"{transfer.CATEGORY_BY_KEY[key].label}: {counts['created']} neu, "
f"{counts['updated']} aktualisiert"
+ (f", {counts['skipped']} uebersprungen" if counts["skipped"] else "")
for key, counts in report.items()
if key in transfer.CATEGORY_BY_KEY
]
flash("Uebernahme abgeschlossen. " + " | ".join(lines), "success")
return redirect(url_for("backup.overview"))
@bp.post("/import/verwerfen")
@permission_required("backup.execute")
def import_discard():
token = session.pop(PREVIEW_SESSION_KEY, "")
if token:
with transaction() as conn:
transfer.drop_preview(conn, token)
flash("Zwischenstand verworfen.", "info")
return redirect(url_for("backup.overview"))
@@ -0,0 +1,135 @@
"""Gemeinsame Helfer der Lizenzserver-Blueprints."""
from __future__ import annotations
import json
from typing import Any
from urllib.parse import urljoin
from flask import url_for
from tesm_core import audit
from tesm_core.auth.models import current_user
from tesm_core.auth.service import client_ip
from tesm_core.extension import core, db, transaction
import tesm_licensing as lic
from ..services import master_key
def log_event(
action: str,
*,
target: str = "",
detail: str = "",
severity: str = "info",
scope: str = "lizenz",
) -> None:
"""Schreibt ins Aenderungsprotokoll **und** ins laufende Protokoll."""
extension = core()
with transaction() as conn:
audit.log(
conn,
actor=current_user.username or "system",
actor_id=getattr(current_user, "id", None) or None,
action=action,
target=target,
detail=detail,
ip=client_ip(),
severity=severity,
)
live = extension.registry.get("live_log")
if live is not None:
text = f"{action} {target}".strip() + (f" -- {detail}" if detail else "")
if severity in {"warning", "critical"}:
live.warning(scope, text)
else:
live.info(scope, text)
def signing_key() -> master_key.MasterKey:
"""Signaturschluessel dieser Installation (erzeugt ihn beim ersten Aufruf)."""
return master_key.ensure(core().registry["master_key_path"])
def vendor_info() -> dict[str, Any]:
"""Anbieterdaten, wie sie in jede Lizenz eingebettet werden."""
values = core().settings.get_many(
db(),
[
"vendor_name",
"vendor_email",
"vendor_phone",
"vendor_address",
"vendor_logo_base64",
],
)
return {
"name": str(values["vendor_name"] or ""),
"email": str(values["vendor_email"] or ""),
"phone": str(values["vendor_phone"] or ""),
"address": str(values["vendor_address"] or ""),
"logo_base64": str(values["vendor_logo_base64"] or ""),
}
def master_endpoint() -> str:
return str(core().settings.get(db(), "master_endpoint") or "").rstrip("/")
def customer_facing_url(endpoint: str, **values: Any) -> str:
"""Baut Links, die ein Kunde tatsaechlich oeffnen soll.
``url_for(..., _external=True)`` nimmt den Host-Header der aktuellen
Anfrage. Bei einem intern angemeldeten Administrator ist das oft eine interne
IP -- fuer den Browser oder das Mailprogramm eines Kunden unbrauchbar. Darum
kommt die Basis aus der Einstellung ``master_endpoint``; nur wenn die leer
ist (frische Installation), faellt es auf das Host-Header-Verhalten zurueck.
"""
base = master_endpoint()
if not base:
return url_for(endpoint, _external=True, **values)
return urljoin(base + "/", url_for(endpoint, **values).lstrip("/"))
def can_issue_license_server_licenses() -> bool:
"""Darf diese Instanz Lizenzen fuer weitere Lizenzserver ausstellen?
Das haengt an der Rolle der **eigenen** Lizenzdatei, nicht an einem
Gruppenrecht. Ein Gruppenrecht koennte sich ein lokaler Administrator eines
Sub-Lizenzservers selbst einraeumen; eine von woanders signierte Lizenzdatei
nicht. Zusaetzlich verlangt es echte Administratorrechte -- das Ausstellen
einer Lizenzserver-Lizenz erzeugt eine neue Vertrauensgrenze und ist damit
keine delegierbare Alltagsaufgabe.
"""
manager = core().license
if manager is None:
return bool(getattr(current_user, "is_admin", False))
return bool(
getattr(current_user, "is_admin", False)
and manager.active()
and manager.state.role == "master"
)
def module_labels() -> dict[str, str]:
return dict(lic.MODULE_LABELS)
def type_labels() -> dict[str, str]:
return dict(lic.LICENSE_TYPE_LABELS)
def role_labels() -> dict[str, str]:
return dict(lic.ROLE_LABELS)
def license_filename(license_id: str, customer_name: str) -> str:
safe = "".join(
char if char.isalnum() or char in "-_" else "-" for char in (customer_name or "kunde")
).strip("-")
return f"tesm-lizenz-{safe.lower() or 'kunde'}-{license_id[:8]}.json"
def bundle_bytes(bundle: dict[str, Any]) -> bytes:
return (json.dumps(bundle, indent=2, ensure_ascii=False, sort_keys=True) + "\n").encode("utf-8")
@@ -0,0 +1,126 @@
"""Kunden: Liste, Detailueberblick, Pflege."""
from __future__ import annotations
from flask import Blueprint, abort, flash, redirect, render_template, request, url_for
from tesm_core.auth.models import current_user, permission_required
from tesm_core.extension import db, transaction
from ..services import issuance, reporting
from .common import customer_facing_url, log_event
bp = Blueprint("customers", __name__, url_prefix="/kunden")
FIELDS = (
"name",
"contact_email",
"contact_phone",
"contact_person",
"street",
"house_number",
"postal_code",
"city",
"notes",
)
def _form() -> dict[str, str]:
return {key: request.form.get(key, "") for key in FIELDS}
@bp.get("/")
@permission_required("customers.view")
def overview():
return render_template("license/customers.html", customers=issuance.list_customers(db()))
@bp.get("/<int:customer_id>")
@permission_required("customers.view")
def detail(customer_id: int):
conn = db()
customer = issuance.get_customer(conn, customer_id)
if customer is None:
abort(404)
tickets = issuance.list_tickets(conn, customer_id=customer_id)
licenses = [
{**row, **issuance.display_state(row)}
for row in issuance.list_licenses(conn, customer_id=customer_id)
]
reported = (
reporting.reported_for_customer(conn, customer_id)
if current_user.has("reports.view")
else {"customers": [], "source_license_id": ""}
)
return render_template(
"license/customer_detail.html",
customer=customer,
tickets=tickets,
licenses=licenses,
reported=reported,
portal_url=customer_facing_url("portal.request_link"),
)
@bp.post("/neu")
@permission_required("customers.create")
def create():
try:
with transaction() as conn:
customer_id = issuance.create_customer(
conn, actor=current_user.username, **_form()
)
log_event(
"customer.created",
target=request.form.get("name", ""),
severity="notice",
scope="kunde",
)
flash("Kunde angelegt.", "success")
return redirect(url_for("customers.detail", customer_id=customer_id))
except issuance.IssuanceError as exc:
flash(str(exc), "danger")
return redirect(url_for("customers.overview"))
@bp.post("/<int:customer_id>")
@permission_required("customers.edit")
def update(customer_id: int):
try:
with transaction() as conn:
changes = issuance.update_customer(conn, customer_id, **_form())
if changes:
log_event(
"customer.updated",
target=request.form.get("name", str(customer_id)),
detail=", ".join(changes),
severity="notice",
scope="kunde",
)
flash("Aenderungen gespeichert." if changes else "Keine Aenderungen.",
"success" if changes else "info")
except issuance.IssuanceError as exc:
flash(str(exc), "danger")
return redirect(url_for("customers.detail", customer_id=customer_id))
@bp.post("/<int:customer_id>/loeschen")
@permission_required("customers.delete")
def delete(customer_id: int):
conn = db()
customer = issuance.get_customer(conn, customer_id)
if customer is None:
abort(404)
try:
with transaction():
issuance.delete_customer(conn, customer_id)
log_event(
"customer.deleted",
target=str(customer["name"]),
severity="warning",
scope="kunde",
)
flash("Kunde geloescht.", "success")
return redirect(url_for("customers.overview"))
except issuance.IssuanceError as exc:
flash(str(exc), "danger")
return redirect(url_for("customers.detail", customer_id=customer_id))
@@ -0,0 +1,40 @@
"""Uebersicht -- die Startseite des Lizenzservers.
Anders als TESMs Geraete-Dashboard verlangt sie eine Anmeldung: das hier ist
eine interne Verwaltungsanwendung. Die Anmeldeseite ist der einzige sinnvolle
erste Einstiegspunkt.
"""
from __future__ import annotations
from flask import Blueprint, render_template
from tesm_core.auth.models import current_user, login_required
from tesm_core.extension import core, db
from ..services import issuance, reporting
bp = Blueprint("dashboard", __name__)
@bp.get("/uebersicht")
@login_required
def index():
conn = db()
extension = core()
if not current_user.has("licenses.view"):
# Leere Huelle statt 403: die Startseite soll fuer jeden erreichbar
# bleiben, auch fuer ein Konto ohne Lizenzrechte.
return render_template("license/dashboard.html", summary=None, sources=[])
summary = issuance.dashboard_summary(conn)
return render_template(
"license/dashboard.html",
summary=summary,
sources=reporting.reporting_sources(conn) if current_user.has("reports.view") else [],
own_role=(extension.license.state.role if extension.license else "master"),
type_labels=dict(),
)
def index_view():
return index()
@@ -0,0 +1,453 @@
"""Lizenzen: ausstellen, herunterladen, versenden, widerrufen, Offline-Codes."""
from __future__ import annotations
import json
from flask import (
Blueprint,
Response,
abort,
flash,
redirect,
render_template,
request,
url_for,
)
from tesm_core.auth.models import (
admin_required,
current_user,
permission_required,
reauth_required,
)
from tesm_core.extension import core, db, transaction
import tesm_licensing as lic
from ..services import activation, issuance, mail, reporting
from .common import (
bundle_bytes,
customer_facing_url,
license_filename,
log_event,
master_endpoint,
module_labels,
role_labels,
signing_key,
type_labels,
vendor_info,
)
bp = Blueprint("licenses", __name__, url_prefix="/lizenzen")
@bp.get("/")
@permission_required("licenses.view")
def overview():
conn = db()
rows = [
{**row, **issuance.display_state(row)}
for row in issuance.list_licenses(
conn,
customer_id=request.args.get("kunde", type=int),
ticket_id=request.args.get("ticket"),
status=request.args.get("status"),
)
]
return render_template(
"license/licenses.html",
licenses=rows,
type_labels=type_labels(),
role_labels=role_labels(),
states=issuance.LICENSE_STATES,
active_filter=request.args.get("status", ""),
)
@bp.get("/<license_id>")
@permission_required("licenses.view")
def detail(license_id: str):
conn = db()
row = issuance.get_license(conn, license_id)
if row is None:
abort(404)
from tesm_core.db import Database
document = json.loads(str(row["license_document"]))
return render_template(
"license/license_detail.html",
license=row,
state=issuance.display_state(row),
document=document,
document_json=json.dumps(document, indent=2, ensure_ascii=False, sort_keys=True),
deliveries=Database.all(
conn,
"SELECT * FROM license_deliveries WHERE license_id=? ORDER BY delivered_at DESC",
(license_id,),
),
modules=json.loads(str(row["modules"] or "[]")),
module_labels=module_labels(),
type_labels=type_labels(),
role_labels=role_labels(),
mail_ready=mail.is_enabled(conn),
all_modules=lic.ALL_MODULES,
)
# --------------------------------------------------------------------------- #
# Ausstellung
# --------------------------------------------------------------------------- #
@bp.post("/aus-ticket/<ticket_id>")
@permission_required("licenses.create")
def issue(ticket_id: str):
conn = db()
as_trial = request.form.get("as_trial") == "1"
key = signing_key()
try:
with transaction():
result = issuance.issue_from_ticket(
conn,
ticket_id,
master_private_key=key.private_key,
master_public_key=key.public_key,
master_endpoint=master_endpoint(),
vendor=vendor_info(),
actor=current_user.username,
keystore=core().keystore,
as_trial=as_trial,
)
except issuance.IssuanceError as exc:
flash(str(exc), "danger")
return redirect(url_for("tickets.detail", ticket_id=ticket_id))
if result.already_existed:
flash(
"Zu diesem Ticket war bereits eine Lizenz offen -- sie wird angezeigt statt eine "
"zweite auszustellen.",
"info",
)
else:
log_event(
"license.issued",
target=result.license_id[:12],
detail=("Trial-Ausstellung" if as_trial else "regulaer"),
severity="notice",
)
reporting.report_soon(core())
flash("Lizenz ausgestellt.", "success")
return redirect(url_for("licenses.detail", license_id=result.license_id))
@bp.get("/<license_id>/datei")
@permission_required("licenses.export")
@reauth_required
def download(license_id: str):
"""Laedt das vollstaendige Auslieferungs-Bundle herunter.
Es enthaelt den privaten Client-Schluessel der Lizenz -- damit ist es ein
Geheimnis. Deshalb: eigenes Recht, frische Passwortbestaetigung, Eintrag im
Zustellprotokoll und im Aenderungsprotokoll.
"""
conn = db()
row = issuance.get_license(conn, license_id)
if row is None:
abort(404)
try:
bundle = issuance.delivery_bundle(conn, license_id, keystore=core().keystore)
except issuance.IssuanceError as exc:
flash(str(exc), "danger")
return redirect(url_for("licenses.detail", license_id=license_id))
with transaction():
issuance.record_delivery(
conn,
license_id=license_id,
channel="download",
recipient=current_user.username,
actor=current_user.username,
detail="Vollstaendiges Bundle" if "client_key" in bundle else "Nur Lizenzdokument",
)
log_event("license.downloaded", target=license_id[:12], severity="notice")
return Response(
bundle_bytes(bundle),
mimetype="application/json",
headers={
"Content-Disposition": (
f'attachment; filename="{license_filename(license_id, str(row["customer_name"]))}"'
),
"Cache-Control": "no-store",
},
)
@bp.post("/<license_id>/neu-signieren")
@permission_required("licenses.edit")
@reauth_required
def resign(license_id: str):
"""Signiert eine neue Revision -- z. B. nach einer Modulerweiterung.
Der Client bekommt sie beim naechsten Heartbeat automatisch, ohne erneute
Aktivierung.
"""
conn = db()
row = issuance.get_license(conn, license_id)
if row is None:
abort(404)
modules = sorted(set(request.form.getlist("modules")) & set(lic.ALL_MODULES))
changes: dict[str, object] = {}
if str(row["role"]) == "customer":
changes["modules"] = modules
if request.form.get("extend_days"):
try:
days = int(request.form["extend_days"])
except ValueError:
flash("Die Verlaengerung muss eine Zahl sein.", "danger")
return redirect(url_for("licenses.detail", license_id=license_id))
if days > 0:
document = json.loads(str(row["license_document"]))
base = lic.parse_iso(str(document["expires_at"]))
from datetime import timedelta
changes["expires_at"] = lic.iso(base + timedelta(days=days))
if not changes:
flash("Es wurde keine Aenderung angegeben.", "info")
return redirect(url_for("licenses.detail", license_id=license_id))
key = signing_key()
try:
with transaction():
updated = issuance.resign_license(
conn, license_id, master_private_key=key.private_key, changes=changes
)
except (issuance.IssuanceError, ValueError) as exc:
flash(str(exc), "danger")
return redirect(url_for("licenses.detail", license_id=license_id))
log_event(
"license.resigned",
target=license_id[:12],
detail=f"Revision {updated['seq']}: " + ", ".join(sorted(changes)),
severity="notice",
)
reporting.report_soon(core())
flash(
f"Neue Revision {updated['seq']} signiert. Der Client erhaelt sie beim naechsten "
"Heartbeat.",
"success",
)
return redirect(url_for("licenses.detail", license_id=license_id))
@bp.post("/<license_id>/widerrufen")
@permission_required("licenses.edit")
def revoke(license_id: str):
conn = db()
try:
with transaction():
issuance.revoke_license(conn, license_id, reason=request.form.get("reason", ""))
except issuance.IssuanceError as exc:
flash(str(exc), "danger")
return redirect(url_for("licenses.detail", license_id=license_id))
log_event(
"license.revoked",
target=license_id[:12],
detail=request.form.get("reason", ""),
severity="warning",
)
reporting.report_soon(core())
flash(
"Lizenz widerrufen. Das Ticket bleibt offen -- eine neue Lizenz kann sofort "
"ausgestellt werden.",
"success",
)
return redirect(url_for("licenses.detail", license_id=license_id))
@bp.post("/<license_id>/loeschen")
@permission_required("licenses.delete")
def delete(license_id: str):
conn = db()
try:
with transaction():
issuance.delete_license(conn, license_id)
except issuance.IssuanceError as exc:
flash(str(exc), "danger")
return redirect(url_for("licenses.detail", license_id=license_id))
log_event("license.deleted", target=license_id[:12], severity="warning")
reporting.report_soon(core())
flash("Lizenz geloescht.", "success")
return redirect(url_for("licenses.overview"))
# --------------------------------------------------------------------------- #
# Versand
# --------------------------------------------------------------------------- #
@bp.post("/<license_id>/versenden")
@permission_required("licenses.export")
def send_mail(license_id: str):
conn = db()
row = issuance.get_license(conn, license_id)
if row is None:
abort(404)
recipient = (request.form.get("recipient") or str(row["contact_email"] or "")).strip()
if not recipient:
flash("Es ist keine Empfaengeradresse hinterlegt.", "danger")
return redirect(url_for("licenses.detail", license_id=license_id))
try:
bundle = issuance.delivery_bundle(conn, license_id, keystore=core().keystore)
except issuance.IssuanceError as exc:
flash(str(exc), "danger")
return redirect(url_for("licenses.detail", license_id=license_id))
vendor = vendor_info()
is_customer = str(row["role"]) == "customer"
subject = (
"Ihre TESM-Lizenz" if is_customer else "Ihre TESM-Lizenzserver-Lizenz"
)
lines = [
f"Guten Tag{(' ' + str(row['contact_person'])) if row['contact_person'] else ''},",
"",
"im Anhang finden Sie Ihre Lizenzdatei.",
"",
f"Lizenz-ID: {license_id}",
f"Typ: {type_labels().get(str(row['type']), str(row['type']))}",
f"Gueltig bis: {row['expires_at']}"
+ (" (unbefristet)" if row["lifetime"] else ""),
]
if is_customer and row["ticket_id"]:
# Self-Service- und Portallinks nur fuer Kundenlizenzen: fuer andere
# Rollen sind diese Wege ohnehin gesperrt.
lines += [
"",
"Ihre Lizenz verwalten (herunterladen, bei einem Systemwechsel freigeben):",
customer_facing_url("selfservice.overview", ticket_id=str(row["ticket_id"])),
"",
"Ihre Stammdaten pflegen:",
customer_facing_url("portal.request_link"),
]
lines += ["", "Freundliche Gruesse", vendor.get("name") or "Ihr Lizenzgeber"]
if vendor.get("phone"):
lines.append(f"Telefon: {vendor['phone']}")
if vendor.get("email"):
lines.append(f"E-Mail: {vendor['email']}")
try:
mail.send(
conn,
to=recipient,
subject=subject,
body="\n".join(lines),
attachment=mail.Attachment(
name=license_filename(license_id, str(row["customer_name"])),
content=bundle_bytes(bundle),
),
)
except mail.MailError as exc:
flash(f"Versand fehlgeschlagen: {exc}", "danger")
return redirect(url_for("licenses.detail", license_id=license_id))
with transaction():
issuance.record_delivery(
conn,
license_id=license_id,
channel="email",
recipient=recipient,
actor=current_user.username,
)
log_event("license.sent", target=license_id[:12], detail=recipient, severity="notice")
flash(f"Lizenz an {recipient} versendet.", "success")
return redirect(url_for("licenses.detail", license_id=license_id))
# --------------------------------------------------------------------------- #
# Offline-Codes
# --------------------------------------------------------------------------- #
@bp.route("/offline-code", methods=["GET", "POST"])
@permission_required("licenses.execute")
def manual_code():
"""Verarbeitet einen Offline-Code des Kunden.
Bewusst dieselben Verarbeitungsfunktionen wie die JSON-API: identische
Signatur-, Replay- und Fingerprint-Pruefung, nur ein anderer Transportweg.
"""
response_code = ""
request_summary: dict[str, object] = {}
message = ""
ok = False
if request.method == "POST":
conn = db()
raw = request.form.get("code", "")
try:
payload = lic.decode_code(raw)
except ValueError as exc:
flash(f"Der Code ist unlesbar: {exc}", "danger")
return render_template("license/manual_code.html", response_code="", summary={})
request_summary = {
"license_id": payload.get("license_id"),
"action": payload.get("action"),
"fingerprint": str(payload.get("fingerprint") or "")[:24] + "...",
"hostname": payload.get("hostname"),
"timestamp": payload.get("timestamp"),
}
key = signing_key()
with transaction():
outcome = activation.process(conn, payload, master_private_key=key.private_key)
if outcome.ok:
response_code = lic.encode_code(outcome.response)
ok = True
message = "Antwortcode erzeugt. Bitte an den Kunden zuruecksenden."
log_event(
f"license.offline_{payload.get('action')}",
target=str(payload.get("license_id") or "")[:12],
detail="Offline-Code verarbeitet",
severity="notice",
)
else:
message = str(outcome.response.get("message") or "Die Anfrage wurde abgewiesen.")
flash(message, "danger")
return render_template(
"license/manual_code.html",
response_code=response_code,
summary=request_summary,
message=message,
ok=ok,
)
# --------------------------------------------------------------------------- #
# Sub-Lizenzserver
# --------------------------------------------------------------------------- #
@bp.get("/sub-lizenzserver")
@permission_required("reports.view")
def reports():
conn = db()
sources = reporting.reporting_sources(conn)
return render_template(
"license/reports.html",
sources=sources,
heartbeat_hours=lic.HEARTBEAT_INTERVAL_SECONDS // 3600,
)
@bp.get("/sub-lizenzserver/<license_id>")
@permission_required("reports.view")
def report_detail(license_id: str):
conn = db()
row = issuance.get_license(conn, license_id)
if row is None or str(row["role"]) != "license_server":
abort(404)
reported = reporting.reported_for_customer(conn, int(row["customer_id"]))
return render_template(
"license/report_detail.html", license=row, reported=reported
)
@@ -0,0 +1,183 @@
"""Die eigene Lizenz dieses Lizenzservers.
Auch ein Lizenzserver braucht eine Lizenz -- sie entscheidet ueber seine Rolle:
* ``master``: Wurzelinstanz. Nur sie darf Lizenzen fuer weitere Lizenzserver
ausstellen. Diese Rolle entsteht ausschliesslich lokal ueber
``tesm-license-admin bootstrap`` -- es gibt keine hoehere Instanz, die sie
bestaetigen koennte, und die Ticket-Oberflaeche bietet sie an keiner Stelle an.
* ``license_server``: von einem Master ausgestellter Sub-Lizenzserver. Er darf
alles, was ein Lizenzserver koennen soll -- **ausser** selbst weitere
Lizenzserver-Lizenzen auszustellen. Diese Grenze haengt an der signierten
Rolle der Lizenzdatei, nicht an einem Gruppenrecht.
Die Seite bleibt immer erreichbar, auch ohne gueltige Lizenz -- sonst liesse
sich keine neue einspielen.
"""
from __future__ import annotations
import socket
from flask import Blueprint, flash, redirect, render_template, request, url_for
from tesm_core.auth.models import (
admin_required,
current_user,
permission_required,
reauth_required,
)
from tesm_core.db import Database
from tesm_core.extension import core, db, transaction
from tesm_core.licensing_client import LICENSED_FEATURES, LicenseError
import tesm_licensing as lic
from ..services import master_key
from .common import can_issue_license_server_licenses, log_event, signing_key
bp = Blueprint("own_license", __name__, url_prefix="/eigene-lizenz")
MAX_UPLOAD_BYTES = 256 * 1024
@bp.get("/")
@permission_required("settings_license.view")
def overview():
extension = core()
conn = db()
manager = extension.license
state = manager.reload(conn) if manager else None
pending = manager._pending(conn) if manager else {} # noqa: SLF001 - eigenes Paket
return render_template(
"license/own_license.html",
state=state,
pending=pending,
features=LICENSED_FEATURES,
role_labels=dict(lic.ROLE_LABELS),
type_labels=dict(lic.LICENSE_TYPE_LABELS),
fingerprint=lic.system_fingerprint(),
hostname=socket.gethostname(),
trusted_keys=Database.all(
conn, "SELECT * FROM trusted_issuer_keys ORDER BY added_at DESC"
),
signing_key_health=master_key.health(extension.registry["master_key_path"]),
may_issue_license_server=can_issue_license_server_licenses(),
grace_days=lic.GRACE_PERIOD_DAYS,
)
@bp.post("/einspielen")
@permission_required("settings_license.edit")
def upload():
extension = core()
manager = extension.license
if manager is None:
flash("Diese Installation nutzt keine Lizenzierung.", "info")
return redirect(url_for("own_license.overview"))
upload_file = request.files.get("license_file")
if upload_file is None or not upload_file.filename:
flash("Keine Datei ausgewaehlt.", "danger")
return redirect(url_for("own_license.overview"))
raw = upload_file.stream.read(MAX_UPLOAD_BYTES + 1)
if len(raw) > MAX_UPLOAD_BYTES:
flash("Die Datei ist zu gross fuer eine Lizenzdatei.", "danger")
return redirect(url_for("own_license.overview"))
try:
with transaction() as conn:
state = manager.import_bundle(
conn,
raw,
actor=current_user.username,
allow_new_issuer=request.form.get("allow_new_issuer") == "1"
and current_user.is_admin,
)
except LicenseError as exc:
flash(str(exc), "danger")
return redirect(url_for("own_license.overview"))
log_event(
"own_license.imported",
target=(state.status.license_id[:12] if state.status else ""),
detail=f"Rolle {state.role}",
severity="warning",
scope="eigene",
)
flash("Lizenz eingespielt. Bitte jetzt aktivieren.", "success")
return redirect(url_for("own_license.overview"))
def _handshake(action: str):
manager = core().license
if manager is None:
return redirect(url_for("own_license.overview"))
try:
with transaction() as conn:
result = manager.handshake(
conn, action, actor=current_user.username, hostname=socket.gethostname()
)
except LicenseError as exc:
flash(str(exc), "danger")
return redirect(url_for("own_license.overview"))
flash(
result["message"],
"success" if result["ok"] else ("warning" if result["transport"] == "offline" else "danger"),
)
return redirect(url_for("own_license.overview"))
@bp.post("/aktivieren")
@permission_required("settings_license.edit")
def activate():
return _handshake("activate")
@bp.post("/deaktivieren")
@permission_required("settings_license.edit")
@reauth_required
def deactivate():
return _handshake("deactivate")
@bp.post("/heartbeat")
@permission_required("settings_license.edit")
def heartbeat():
return _handshake("heartbeat")
@bp.post("/offline-antwort")
@permission_required("settings_license.edit")
def offline_response():
manager = core().license
if manager is None:
return redirect(url_for("own_license.overview"))
try:
with transaction() as conn:
result = manager.submit_offline_response(
conn, request.form.get("code", ""), actor=current_user.username
)
except LicenseError as exc:
flash(str(exc), "danger")
return redirect(url_for("own_license.overview"))
flash(result["message"], "success" if result["ok"] else "danger")
return redirect(url_for("own_license.overview"))
@bp.get("/signaturschluessel")
@permission_required("settings_license.view")
def signing_key_info():
"""Zeigt den oeffentlichen Signaturschluessel -- zum Pinnen beim Kunden.
Der oeffentliche Teil ist kein Geheimnis; er reist ohnehin in jeder Lizenz
mit. Ihn hier anzuzeigen erlaubt es, ihn unabhaengig zu vergleichen, bevor
ein Kunde ihn als vertrauenswuerdig hinterlegt.
"""
key = signing_key()
return render_template(
"license/signing_key.html",
key_id=key.key_id,
public_key=key.public_key,
created_at=key.created_at,
health=master_key.health(core().registry["master_key_path"]),
)
@@ -0,0 +1,278 @@
"""Kundenportal -- zeitlich begrenzter Zugang ohne Konto.
Zweistufig, bewusst:
1. ``/portal`` nimmt eine E-Mail-Adresse an und verschickt einen **einmal**
verwendbaren Zugangslink mit kurzer Gueltigkeit.
2. Der Link erzeugt eine Sitzung mit eigener, unabhaengiger Gueltigkeit.
Der Vorgaenger hatte zuerst einen permanenten, nie ablaufenden Link pro Kunde
(``/portal/<token>``). Das wurde als Schwachstelle erkannt und ersetzt -- hier
existiert die permanente Variante gar nicht.
Anti-Enumeration: die Antwort ist immer dieselbe, unabhaengig davon, ob die
Adresse bekannt ist. Ein Fehler beim Mailversand wird dem Anfragenden gegenueber
verschwiegen, aber serverseitig protokolliert -- sonst waere eine kaputte
Mailkonfiguration unsichtbar.
"""
from __future__ import annotations
import logging
import secrets
from datetime import datetime, timedelta, timezone
from typing import Any
from flask import Blueprint, flash, redirect, render_template, request, session, url_for
from tesm_core import audit
from tesm_core.auth.service import client_ip
from tesm_core.db import Database
from tesm_core.extension import core, db, transaction
from tesm_core.security import ratelimit
from ..services import issuance, mail
from .common import customer_facing_url, vendor_info
logger = logging.getLogger(__name__)
bp = Blueprint("portal", __name__, url_prefix="/portal")
SESSION_CUSTOMER = "portal_customer_id"
SESSION_EXPIRES = "portal_expires_at"
RATE_BUCKET = "portal:request"
RATE_LIMIT = 5
RATE_WINDOW_MINUTES = 15
GENERIC_ANSWER = (
"Falls diese E-Mail-Adresse bei uns hinterlegt ist, wurde soeben eine Nachricht mit "
"einem {minutes} Minuten gueltigen Zugangslink verschickt."
)
EDITABLE_FIELDS = (
"contact_person",
"contact_phone",
"contact_email",
"street",
"house_number",
"postal_code",
"city",
)
def _now() -> datetime:
return datetime.now(timezone.utc)
def _iso(value: datetime) -> str:
return value.isoformat(timespec="seconds")
def _parse(value: Any) -> datetime | None:
"""Liest einen Zeitstempel aus der Datenbank.
Bewusst geparst statt als Zeichenkette verglichen: ein Wert, der ueber
``datetime('now')`` von SQLite selbst geschrieben wurde, hat ein Leerzeichen
statt ``T`` und keine Zeitzone -- ein lexikografischer Vergleich mit unserem
ISO-Format waere dann falsch, und zwar zugunsten von "abgelaufen".
"""
if not value:
return None
text = str(value).strip().replace(" ", "T")
if text.endswith("Z"):
text = text[:-1] + "+00:00"
try:
parsed = datetime.fromisoformat(text)
except ValueError:
return None
return parsed if parsed.tzinfo else parsed.replace(tzinfo=timezone.utc)
def _enabled() -> bool:
return bool(core().settings.get(db(), "portal_enabled"))
@bp.route("/", methods=["GET", "POST"])
def request_link():
extension = core()
conn = db()
if not _enabled():
return render_template("license/portal_disabled.html"), 404
minutes = int(extension.settings.get(conn, "portal_link_minutes") or 30)
if request.method == "POST":
email = (request.form.get("email") or "").strip()
verdict = ratelimit.check(
conn,
RATE_BUCKET,
client_ip() or "unbekannt",
limit=RATE_LIMIT,
window_minutes=RATE_WINDOW_MINUTES,
)
with transaction():
ratelimit.record(conn, RATE_BUCKET, client_ip() or "unbekannt")
if verdict.blocked:
# Auch hier keine abweichende Aussage -- nur eine Bremse.
flash(GENERIC_ANSWER.format(minutes=minutes), "info")
return redirect(url_for("portal.request_link"))
customer = issuance.find_customer_by_email(conn, email)
if customer is not None:
token = secrets.token_urlsafe(32)
with transaction():
conn.execute(
"INSERT INTO portal_access_tokens (token, customer_id, created_at, "
"expires_at, created_ip) VALUES (?,?,?,?,?)",
(
token,
int(customer["id"]),
_iso(_now()),
_iso(_now() + timedelta(minutes=minutes)),
client_ip() or "",
),
)
audit.log(
conn,
actor="portal",
action="portal.link_requested",
target=str(customer["name"]),
ip=client_ip(),
severity="notice",
)
access_url = customer_facing_url("portal.access", token=token)
vendor = vendor_info()
try:
mail.send(
conn,
to=str(customer["contact_email"]),
subject="Ihr Zugang zum Kundenportal",
body="\n".join(
[
f"Guten Tag{(' ' + str(customer['contact_person'])) if customer['contact_person'] else ''},",
"",
"mit dem folgenden Link erreichen Sie Ihre Daten im Kundenportal.",
f"Der Link ist {minutes} Minuten gueltig und laesst sich nur einmal verwenden:",
"",
access_url,
"",
"Wenn Sie diesen Zugang nicht angefragt haben, ignorieren Sie die Nachricht.",
"",
"Freundliche Gruesse",
vendor.get("name") or "Ihr Lizenzgeber",
]
),
)
except mail.MailError as exc:
# Bewusst nicht nach aussen tragen (Anti-Enumeration), aber
# serverseitig sichtbar machen.
logger.error("Portal-Mail an %s fehlgeschlagen: %s", email, exc)
flash(GENERIC_ANSWER.format(minutes=minutes), "info")
return redirect(url_for("portal.request_link"))
return render_template("license/portal_request.html", minutes=minutes)
@bp.get("/zugang/<token>")
def access(token: str):
extension = core()
conn = db()
if not _enabled():
return render_template("license/portal_disabled.html"), 404
row = Database.one(
conn, "SELECT * FROM portal_access_tokens WHERE token=?", (token,)
)
expires = _parse(row["expires_at"]) if row is not None else None
valid = row is not None and row["used_at"] is None and expires is not None and _now() < expires
if not valid:
flash("Dieser Link ist ungueltig oder abgelaufen.", "danger")
return redirect(url_for("portal.request_link"))
minutes = int(extension.settings.get(conn, "portal_session_minutes") or 60)
with transaction():
# Einmalverwendung erzwingen: auch innerhalb der Gueltigkeit scheitert
# ein zweiter Aufruf.
conn.execute(
"UPDATE portal_access_tokens SET used_at=? WHERE token=? AND used_at IS NULL",
(_iso(_now()), token),
)
audit.log(
conn,
actor="portal",
action="portal.session_started",
target=str(row["customer_id"]),
ip=client_ip(),
severity="notice",
)
session[SESSION_CUSTOMER] = int(row["customer_id"])
session[SESSION_EXPIRES] = _iso(_now() + timedelta(minutes=minutes))
return redirect(url_for("portal.me"))
def _current_customer() -> dict[str, Any] | None:
customer_id = session.get(SESSION_CUSTOMER)
expires = _parse(session.get(SESSION_EXPIRES))
if not customer_id or expires is None or _now() >= expires:
session.pop(SESSION_CUSTOMER, None)
session.pop(SESSION_EXPIRES, None)
return None
return issuance.get_customer(db(), int(customer_id))
@bp.route("/meine-daten", methods=["GET", "POST"])
def me():
conn = db()
if not _enabled():
return render_template("license/portal_disabled.html"), 404
customer = _current_customer()
if customer is None:
flash("Ihre Sitzung ist abgelaufen. Bitte fordern Sie einen neuen Zugangslink an.", "info")
return redirect(url_for("portal.request_link"))
if request.method == "POST":
fields = {key: request.form.get(key, "") for key in EDITABLE_FIELDS}
try:
with transaction():
changes = issuance.update_customer(
conn, int(customer["id"]), name=str(customer["name"]), **fields
)
if changes:
audit.log(
conn,
actor="portal",
action="portal.customer_updated",
target=str(customer["name"]),
detail=", ".join(changes),
ip=client_ip(),
severity="notice",
)
flash("Ihre Angaben wurden gespeichert." if changes else "Keine Aenderungen.", "success")
except issuance.IssuanceError as exc:
flash(str(exc), "danger")
return redirect(url_for("portal.me"))
tickets = issuance.list_tickets(conn, customer_id=int(customer["id"]))
licenses = {
str(row["ticket_id"]): {**row, **issuance.display_state(row)}
for row in issuance.list_licenses(conn, customer_id=int(customer["id"]))
if row["status"] in {"issued", "active"}
}
self_service_enabled = bool(core().settings.get(conn, "self_service_enabled"))
return render_template(
"license/portal_me.html",
customer=customer,
tickets=tickets,
licenses=licenses,
expires_at=session.get(SESSION_EXPIRES),
self_service_enabled=self_service_enabled,
vendor=vendor_info(),
)
@bp.post("/abmelden")
def logout():
session.pop(SESSION_CUSTOMER, None)
session.pop(SESSION_EXPIRES, None)
flash("Sie wurden abgemeldet.", "info")
return redirect(url_for("portal.request_link"))
@@ -0,0 +1,304 @@
"""Self-Service je Ticket -- ohne Anmeldung, allein ueber den Ticket-Link.
Die Berechtigung ist der Besitz der Ticket-ID: 192 Bit Zufall
(``secrets.token_urlsafe(24)``), zugleich Primaerschluessel. Damit kann ein Kunde
seine Lizenz herunterladen, bei einem Systemwechsel freigeben und neu ausstellen
lassen -- ohne Support-Anruf.
Fuer Tickets mit einer anderen Rolle als ``customer`` antworten **alle** Routen
mit 404, nicht mit "keine Berechtigung": aus der Fehlermeldung soll sich nicht
ablesen lassen, dass eine geratene ID zu einem Lizenzserver-Ticket gehoert.
"""
from __future__ import annotations
from flask import Blueprint, Response, abort, flash, redirect, render_template, request, url_for
from tesm_core import audit
from tesm_core.auth.service import client_ip
from tesm_core.extension import core, db, transaction
from tesm_core.security import ratelimit
import tesm_licensing as lic
from ..services import activation, issuance, reporting
from .common import (
bundle_bytes,
customer_facing_url,
license_filename,
master_endpoint,
signing_key,
type_labels,
vendor_info,
)
bp = Blueprint("selfservice", __name__, url_prefix="/lizenz")
RATE_BUCKET = "selfservice"
RATE_LIMIT = 20
RATE_WINDOW_MINUTES = 10
def _ticket_or_404(ticket_id: str) -> dict:
conn = db()
if not core().settings.get(conn, "self_service_enabled"):
abort(404)
ticket = issuance.get_ticket(conn, ticket_id)
if ticket is None or str(ticket["role"]) != "customer":
abort(404)
return ticket
def _rate_guard() -> None:
conn = db()
verdict = ratelimit.check(
conn,
RATE_BUCKET,
client_ip() or "unbekannt",
limit=RATE_LIMIT,
window_minutes=RATE_WINDOW_MINUTES,
)
with transaction():
ratelimit.record(conn, RATE_BUCKET, client_ip() or "unbekannt")
if verdict.blocked:
abort(429, description="Zu viele Anfragen. Bitte spaeter erneut versuchen.")
def _needs_offline(conn, ticket_id: str): # type: ignore[no-untyped-def]
"""Die Lizenz, fuer die der Offline-Weg gerade einen Zweck hat -- sonst ``None``.
Zwei Faelle, und nur diese:
* **Noch nicht aktiviert.** Der Kunde muss aktivieren.
* **Neue Revision ausstehend.** Der Anbieter hat nachsigniert (andere
Module, laengere Laufzeit); eine Installation ohne Verbindung holt sie
sonst nie ab.
Ist die Lizenz aktiv und auf dem aktuellen Stand, gibt es nichts zu tun --
fuer einen Systemwechsel gibt es die Freigabe.
"""
for row in issuance.list_licenses(conn, ticket_id=ticket_id):
status = str(row["status"])
if status == "issued":
return row
if status == "active" and issuance.revision_pending(row):
return row
return None
def _log(action: str, ticket: dict, detail: str = "", severity: str = "notice") -> None:
extension = core()
with transaction() as conn:
audit.log(
conn,
actor="self-service",
action=action,
target=str(ticket["customer_name"]),
detail=detail,
ip=client_ip(),
severity=severity,
)
live = extension.registry.get("live_log")
if live is not None:
live.info("selfservice", f"{action} {ticket['customer_name']} {detail}".strip())
@bp.get("/<ticket_id>")
def overview(ticket_id: str):
_rate_guard()
ticket = _ticket_or_404(ticket_id)
conn = db()
licenses = [
{**row, **issuance.display_state(row)}
for row in issuance.list_licenses(conn, ticket_id=ticket_id)
]
current = next((row for row in licenses if row["status"] in {"issued", "active"}), None)
return render_template(
"license/selfservice.html",
offline_needed=_needs_offline(db(), ticket_id),
ticket=ticket,
current=current,
history=[row for row in licenses if row is not current],
type_labels=type_labels(),
vendor=vendor_info(),
portal_url=url_for("portal.request_link"),
allow_revoke=bool(core().settings.get(conn, "allow_customer_revoke")),
blocked=str(ticket["status"]) == "blocked",
)
@bp.post("/<ticket_id>/ausstellen")
def issue(ticket_id: str):
_rate_guard()
ticket = _ticket_or_404(ticket_id)
conn = db()
if str(ticket["status"]) == "blocked":
flash(
"Dieses Ticket ist gesperrt. Bitte wenden Sie sich an Ihren Ansprechpartner.",
"danger",
)
return redirect(url_for("selfservice.overview", ticket_id=ticket_id))
if issuance.ticket_has_open_license(conn, ticket_id):
flash(
"Es existiert bereits eine gueltige Lizenz. Geben Sie diese zuerst frei, wenn Sie "
"auf ein anderes System wechseln moechten.",
"warning",
)
return redirect(url_for("selfservice.overview", ticket_id=ticket_id))
key = signing_key()
try:
with transaction():
result = issuance.issue_from_ticket(
conn,
ticket_id,
master_private_key=key.private_key,
master_public_key=key.public_key,
master_endpoint=master_endpoint(),
vendor=vendor_info(),
actor="self-service",
keystore=core().keystore,
)
except issuance.IssuanceError as exc:
flash(str(exc), "danger")
return redirect(url_for("selfservice.overview", ticket_id=ticket_id))
_log("license.issued_self_service", ticket, result.license_id[:12])
reporting.report_soon(core())
flash(
"Ihre Lizenz wurde erstellt. Laden Sie die Datei herunter und spielen Sie sie in Ihrer "
"TESM-Installation ein.",
"success",
)
return redirect(url_for("selfservice.overview", ticket_id=ticket_id))
@bp.get("/<ticket_id>/datei/<license_id>")
def download(ticket_id: str, license_id: str):
_rate_guard()
ticket = _ticket_or_404(ticket_id)
conn = db()
row = issuance.get_license(conn, license_id)
if row is None or str(row["ticket_id"]) != ticket_id:
abort(404)
if str(row["status"]) == "revoked":
abort(410, description="Diese Lizenz wurde widerrufen.")
try:
bundle = issuance.delivery_bundle(conn, license_id, keystore=core().keystore)
except issuance.IssuanceError as exc:
abort(409, description=str(exc))
with transaction():
issuance.record_delivery(
conn,
license_id=license_id,
channel="self-service",
recipient=str(ticket["contact_email"] or ""),
actor="self-service",
)
_log("license.downloaded_self_service", ticket, license_id[:12], severity="info")
return Response(
bundle_bytes(bundle),
mimetype="application/json",
headers={
"Content-Disposition": (
f'attachment; filename="{license_filename(license_id, str(ticket["customer_name"]))}"'
),
"Cache-Control": "no-store",
},
)
@bp.route("/<ticket_id>/offline", methods=["GET", "POST"])
def offline(ticket_id: str):
"""Loest einen Aktivierungscode ein, wenn TESM den Server nicht erreicht."""
ticket = _ticket_or_404(ticket_id)
conn = db()
pending = _needs_offline(conn, ticket_id)
if pending is None:
# Ohne noch nicht aktivierte Lizenz gibt es hier nichts zu tun.
return redirect(url_for("selfservice.overview", ticket_id=ticket_id))
response_code = ""
summary: dict[str, object] = {}
if request.method == "POST":
_rate_guard()
raw = request.form.get("code", "")
try:
payload = lic.decode_code(raw)
except ValueError as exc:
flash(f"Der Code ist unlesbar: {exc}", "danger")
return redirect(url_for("selfservice.offline", ticket_id=ticket_id))
# Nur Lizenzen dieses Vorgangs -- ein fremder Code gehoert nicht hierher.
row = issuance.get_license(conn, str(payload.get("license_id") or ""))
if row is None or str(row["ticket_id"]) != ticket_id:
_log(
"selfservice.offline_rejected",
ticket,
"Code gehoert nicht zu diesem Vorgang",
severity="warning",
)
flash(
"Dieser Code gehoert nicht zu Ihrem Vorgang. Bitte pruefen Sie, ob Sie ihn "
"vollstaendig kopiert haben.",
"danger",
)
return redirect(url_for("selfservice.offline", ticket_id=ticket_id))
key = signing_key()
with transaction():
outcome = activation.process(conn, payload, master_private_key=key.private_key)
summary = {"action": payload.get("action"), "hostname": payload.get("hostname")}
if outcome.ok:
response_code = lic.encode_code(outcome.response)
_log(
f"selfservice.offline_{payload.get('action')}",
ticket,
f"Lizenz {str(payload.get('license_id'))[:12]}",
)
else:
message = str(outcome.response.get("message") or "Die Anfrage wurde abgewiesen.")
_log("selfservice.offline_rejected", ticket, message, severity="warning")
flash(message, "danger")
return render_template(
"license/selfservice_offline.html",
ticket=ticket,
license=pending,
response_code=response_code,
summary=summary,
vendor=vendor_info(),
)
@bp.post("/<ticket_id>/freigeben/<license_id>")
def revoke(ticket_id: str, license_id: str):
"""Gibt die aktuelle Lizenz frei -- fuer einen Systemwechsel.
Fachlich ein Widerruf: die Datei gilt nicht mehr, das Ticket bleibt offen,
eine neue Lizenz kann sofort ausgestellt werden.
"""
_rate_guard()
ticket = _ticket_or_404(ticket_id)
conn = db()
if not core().settings.get(conn, "allow_customer_revoke"):
abort(404)
row = issuance.get_license(conn, license_id)
if row is None or str(row["ticket_id"]) != ticket_id:
abort(404)
try:
with transaction():
issuance.revoke_license(conn, license_id, reason="Freigabe durch den Kunden")
except issuance.IssuanceError as exc:
flash(str(exc), "danger")
return redirect(url_for("selfservice.overview", ticket_id=ticket_id))
_log("license.revoked_self_service", ticket, license_id[:12], severity="warning")
reporting.report_soon(core())
flash(
"Die Lizenz wurde freigegeben. Sie koennen jetzt eine neue Lizenz fuer Ihr neues System "
"ausstellen.",
"success",
)
return redirect(url_for("selfservice.overview", ticket_id=ticket_id))
@@ -0,0 +1,305 @@
"""Einstellungen des Lizenzservers: System, Anbieterdaten, E-Mail-Versand.
Netzwerk, Webserver/TLS und Verzeichnisdienst kommen aus dem Kern.
"""
from __future__ import annotations
import base64
from typing import Any
from flask import Blueprint, flash, redirect, render_template, request, url_for
from tesm_core import audit
from tesm_core.auth.models import current_user, permission_required
from tesm_core.auth.service import client_ip
from tesm_core.extension import core, db, transaction
from ..services import mail
from .common import log_event, master_endpoint, vendor_info
bp = Blueprint("settings", __name__, url_prefix="/einstellungen")
MAX_LOGO_BYTES = 200 * 1024
LOGO_MIME = {
".png": "image/png",
".jpg": "image/jpeg",
".jpeg": "image/jpeg",
".svg": "image/svg+xml",
}
LOGO_MAGIC = {
b"\x89PNG\r\n\x1a\n": "image/png",
b"\xff\xd8\xff": "image/jpeg",
b"<svg": "image/svg+xml",
b"<?xml": "image/svg+xml",
}
@bp.get("/system")
@permission_required("settings_system.view")
def system():
extension = core()
conn = db()
sysops = extension.sysops
return render_template(
"license/settings_system.html",
values=extension.settings.get_many(
conn,
[
"portal_enabled",
"portal_link_minutes",
"portal_session_minutes",
"self_service_enabled",
"allow_customer_revoke",
"default_valid_days",
"trash_retention_days",
"log_rotate_interval",
"log_rotate_keep",
],
),
specs={spec.key: spec for spec in extension.settings.specs()},
hostname=(sysops.read_command(["hostname"]).stdout.strip() if sysops else ""),
timezone=(
sysops.read_command(
["timedatectl", "show", "--property=Timezone", "--value"]
).stdout.strip()
if sysops
else ""
),
timezones=_timezone_choices(),
nav_items=extension.nav.items,
nav_order=extension.settings.get(conn, "nav_order") or extension.nav.default_order(),
sysops_available=bool(sysops and sysops.available),
)
def _timezone_choices() -> list[str]:
try:
from zoneinfo import available_timezones
return sorted(available_timezones())
except Exception: # noqa: BLE001
return ["Europe/Berlin", "UTC"]
@bp.post("/system")
@permission_required("settings_system.edit")
def system_save():
extension = core()
conn = db()
values: dict[str, Any] = {}
for key in (
"portal_enabled",
"portal_link_minutes",
"portal_session_minutes",
"self_service_enabled",
"allow_customer_revoke",
"default_valid_days",
"trash_retention_days",
"log_rotate_interval",
"log_rotate_keep",
):
spec = extension.settings.spec(key)
if spec.kind == "bool":
values[key] = request.form.get(key) == "1"
elif key in request.form:
values[key] = request.form.get(key)
with transaction():
changed = extension.settings.set_many(conn, values, actor=current_user.username)
if changed:
audit.log(
conn,
actor=current_user.username,
actor_id=current_user.id,
action="settings.system_saved",
detail=", ".join(changed),
ip=client_ip(),
severity="notice",
)
flash("Einstellungen gespeichert." if changed else "Keine Aenderungen.",
"success" if changed else "info")
return redirect(url_for("settings.system"))
@bp.post("/system/hostname")
@permission_required("settings_system.execute")
def set_hostname():
extension = core()
sysops = extension.sysops
hostname = (request.form.get("hostname") or "").strip()
if not hostname or len(hostname) > 63 or not hostname.replace("-", "").isalnum():
flash("Der Hostname ist ungueltig.", "danger")
return redirect(url_for("settings.system"))
if sysops is None or not sysops.available:
flash("Systemaktionen sind in dieser Umgebung nicht verfuegbar.", "warning")
return redirect(url_for("settings.system"))
result = sysops.set_hostname(hostname)
if result.ok:
log_event("settings.hostname_changed", target=hostname, severity="notice", scope="system")
flash(f"Hostname auf {hostname} gesetzt.", "success")
else:
flash(f"Hostname konnte nicht gesetzt werden: {result.message}", "danger")
return redirect(url_for("settings.system"))
@bp.post("/system/zeitzone")
@permission_required("settings_system.execute")
def set_timezone():
import time
sysops = core().sysops
timezone = (request.form.get("timezone") or "").strip()
if timezone not in _timezone_choices():
flash("Unbekannte Zeitzone.", "danger")
return redirect(url_for("settings.system"))
if sysops is None or not sysops.available:
flash("Systemaktionen sind in dieser Umgebung nicht verfuegbar.", "warning")
return redirect(url_for("settings.system"))
result = sysops.set_timezone(timezone)
if result.ok:
try:
time.tzset() # type: ignore[attr-defined]
except AttributeError: # pragma: no cover - Windows
pass
log_event("settings.timezone_changed", target=timezone, severity="notice", scope="system")
flash(f"Zeitzone auf {timezone} gesetzt.", "success")
else:
flash(f"Zeitzone konnte nicht gesetzt werden: {result.message}", "danger")
return redirect(url_for("settings.system"))
# --------------------------------------------------------------------------- #
# Anbieterdaten
# --------------------------------------------------------------------------- #
@bp.get("/anbieter")
@permission_required("settings_vendor.view")
def vendor():
extension = core()
conn = db()
return render_template(
"license/settings_vendor.html",
values=extension.settings.get_many(
conn,
[
"vendor_name",
"vendor_email",
"vendor_phone",
"vendor_address",
"vendor_logo_base64",
"master_endpoint",
],
),
max_logo_kb=MAX_LOGO_BYTES // 1024,
)
@bp.post("/anbieter")
@permission_required("settings_vendor.edit")
def vendor_save():
extension = core()
conn = db()
values = {
key: (request.form.get(key) or "").strip()
for key in ("vendor_name", "vendor_email", "vendor_phone", "vendor_address")
}
endpoint = (request.form.get("master_endpoint") or "").strip().rstrip("/")
if endpoint and not endpoint.startswith(("http://", "https://")):
flash("Der Endpunkt muss mit http:// oder https:// beginnen.", "danger")
return redirect(url_for("settings.vendor"))
values["master_endpoint"] = endpoint
logo_problem = ""
upload = request.files.get("vendor_logo")
if upload is not None and upload.filename:
from pathlib import Path
suffix = Path(upload.filename).suffix.lower()
head = upload.stream.read(16)
upload.stream.seek(0)
data = upload.stream.read(MAX_LOGO_BYTES + 1)
if suffix not in LOGO_MIME:
logo_problem = "Erlaubt sind PNG, JPG und SVG."
elif len(data) > MAX_LOGO_BYTES:
logo_problem = f"Das Logo ist zu gross (maximal {MAX_LOGO_BYTES // 1024} KB)."
elif not any(head.startswith(magic) for magic in LOGO_MAGIC):
logo_problem = "Die Datei ist kein Bild."
else:
mime = LOGO_MIME[suffix]
values["vendor_logo_base64"] = (
f"data:{mime};base64," + base64.b64encode(data).decode("ascii")
)
with transaction():
changed = extension.settings.set_many(conn, values, actor=current_user.username)
if logo_problem:
# Ein zu grosses Logo verwirft nur das Logo -- die uebrigen Angaben
# werden trotzdem gespeichert.
flash(logo_problem, "danger")
log_event(
"settings.vendor_saved", detail=", ".join(changed), severity="notice", scope="anbieter"
)
flash("Anbieterdaten gespeichert." if changed else "Keine Aenderungen.",
"success" if changed else "info")
return redirect(url_for("settings.vendor"))
@bp.post("/anbieter/logo-entfernen")
@permission_required("settings_vendor.edit")
def vendor_logo_remove():
with transaction() as conn:
core().settings.set(conn, "vendor_logo_base64", "", actor=current_user.username)
flash("Logo entfernt.", "success")
return redirect(url_for("settings.vendor"))
# --------------------------------------------------------------------------- #
# E-Mail
# --------------------------------------------------------------------------- #
@bp.get("/email")
@permission_required("settings_mail.view")
def mail_settings():
extension = core()
conn = db()
return render_template(
"license/settings_mail.html",
values=extension.settings.get_many(conn, [spec.key for spec in mail.SETTINGS]),
specs={spec.key: spec for spec in mail.SETTINGS},
has_secret=mail.has_secret(conn),
ready=mail.is_enabled(conn),
can_secrets=current_user.has("settings_mail.secrets"),
)
@bp.post("/email")
@permission_required("settings_mail.edit")
def mail_save():
extension = core()
conn = db()
values: dict[str, Any] = {}
for spec in mail.SETTINGS:
if spec.kind == "bool":
values[spec.key] = request.form.get(spec.key) == "1"
elif spec.key in request.form:
values[spec.key] = (request.form.get(spec.key) or "").strip()
with transaction():
changed = extension.settings.set_many(conn, values, actor=current_user.username)
if current_user.has("settings_mail.secrets") and request.form.get("client_secret"):
mail.store_secret(conn, request.form.get("client_secret"))
changed.append("client_secret")
log_event("settings.mail_saved", detail=", ".join(changed), severity="notice", scope="mail")
flash("E-Mail-Einstellungen gespeichert." if changed else "Keine Aenderungen.",
"success" if changed else "info")
return redirect(url_for("settings.mail_settings"))
@bp.post("/email/test")
@permission_required("settings_mail.execute")
def mail_test():
result = mail.test_connection(db())
flash(result["message"], "success" if result["ok"] else "danger")
return redirect(url_for("settings.mail_settings"))
@@ -0,0 +1,238 @@
"""Tickets: der dauerhafte Anspruch eines Kunden auf eine Lizenz."""
from __future__ import annotations
import json
from flask import Blueprint, abort, flash, redirect, render_template, request, url_for
from tesm_core.auth.models import current_user, permission_required
from tesm_core.extension import core, db, transaction
import tesm_licensing as lic
from ..services import issuance, reporting
from .common import (
can_issue_license_server_licenses,
customer_facing_url,
log_event,
master_endpoint,
module_labels,
role_labels,
type_labels,
)
bp = Blueprint("tickets", __name__, url_prefix="/tickets")
@bp.get("/")
@permission_required("tickets.view")
def overview():
conn = db()
tickets = issuance.list_tickets(conn)
licenses = {
str(row["ticket_id"]): {**row, **issuance.display_state(row)}
for row in issuance.list_licenses(conn)
if row["status"] in {"issued", "active"}
}
return render_template(
"license/tickets.html",
endpoint_missing=_endpoint_missing(),
tickets=tickets,
open_licenses=licenses,
type_labels=type_labels(),
role_labels=role_labels(),
module_labels=module_labels(),
)
@bp.get("/neu")
@permission_required("tickets.create")
def new():
conn = db()
extension = core()
return render_template(
"license/ticket_new.html",
endpoint_missing=_endpoint_missing(),
customers=issuance.list_customers(conn),
modules=lic.ALL_MODULES,
module_labels=module_labels(),
types=lic.LICENSE_TYPES,
type_labels=type_labels(),
default_valid_days=int(extension.settings.get(conn, "default_valid_days") or 365),
may_create_license_server=can_issue_license_server_licenses(),
preselected_customer=request.args.get("kunde", type=int),
)
@bp.post("/neu")
@permission_required("tickets.create")
def create():
conn = db()
# Rolle: jeder Versuch ausserhalb der strukturellen Erlaubnis wird still auf
# "customer" zurueckgestuft -- nicht nur in der Oberflaeche verborgen.
role = request.form.get("role", "customer")
if role != "customer" and not can_issue_license_server_licenses():
role = "customer"
try:
with transaction():
ticket_id = issuance.create_ticket(
conn,
customer_id=int(request.form.get("customer_id") or 0),
license_type=request.form.get("type", "standard"),
modules=request.form.getlist("modules"),
valid_days=int(request.form.get("valid_days") or 365),
lifetime=request.form.get("lifetime") == "1",
role=role,
notes=request.form.get("notes", ""),
actor=current_user.username,
)
log_event(
"ticket.created",
target=ticket_id[:12],
detail=f"Typ {request.form.get('type')}, Rolle {role}",
severity="notice",
scope="ticket",
)
flash("Ticket angelegt.", "success")
return redirect(url_for("tickets.detail", ticket_id=ticket_id))
except (issuance.IssuanceError, ValueError) as exc:
flash(str(exc), "danger")
return redirect(url_for("tickets.new"))
def _endpoint_missing() -> str:
"""Meldung, falls ohne Endpunkt gearbeitet wird -- sonst leer."""
if master_endpoint():
return ""
return (
"Es ist kein oeffentlicher Endpunkt hinterlegt (Einstellungen -> Anbieter). "
"Solange er fehlt, koennen keine Lizenzen ausgestellt werden: der Endpunkt wird "
"in die Lizenz signiert und laesst sich spaeter nicht nachtragen."
)
@bp.get("/<ticket_id>")
@permission_required("tickets.view")
def detail(ticket_id: str):
conn = db()
ticket = issuance.get_ticket(conn, ticket_id)
if ticket is None:
abort(404)
licenses = [
{**row, **issuance.display_state(row)}
for row in issuance.list_licenses(conn, ticket_id=ticket_id)
]
extension = core()
self_service_enabled = bool(extension.settings.get(conn, "self_service_enabled"))
return render_template(
"license/ticket_detail.html",
endpoint_missing=_endpoint_missing(),
ticket=ticket,
ticket_modules=json.loads(str(ticket["modules"] or "[]")),
licenses=licenses,
has_open_license=issuance.ticket_has_open_license(conn, ticket_id),
modules=lic.ALL_MODULES,
module_labels=module_labels(),
types=lic.LICENSE_TYPES,
type_labels=type_labels(),
role_labels=role_labels(),
trial_days=lic.TRIAL_DEFAULT_DAYS,
# Self-Service gibt es nur fuer Kundentickets -- fuer andere Rollen ist
# der Weg vollstaendig gesperrt.
self_service_url=(
customer_facing_url("selfservice.overview", ticket_id=ticket_id)
if self_service_enabled and str(ticket["role"]) == "customer"
else ""
),
portal_url=customer_facing_url("portal.request_link"),
)
@bp.post("/<ticket_id>")
@permission_required("tickets.edit")
def update(ticket_id: str):
conn = db()
if issuance.get_ticket(conn, ticket_id) is None:
abort(404)
try:
with transaction():
changes = issuance.update_ticket(
conn,
ticket_id,
license_type=request.form.get("type", "standard"),
modules=request.form.getlist("modules"),
valid_days=int(request.form.get("valid_days") or 365),
lifetime=request.form.get("lifetime") == "1",
notes=request.form.get("notes", ""),
)
if changes:
log_event(
"ticket.updated",
target=ticket_id[:12],
detail="; ".join(changes),
severity="notice",
scope="ticket",
)
reporting.report_soon(core())
flash(
"Gespeichert. Die Aenderung wirkt auf die naechste Ausstellung."
if changes
else "Keine Aenderungen.",
"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>/sperre")
@permission_required("tickets.edit")
def toggle_block(ticket_id: str):
conn = db()
ticket = issuance.get_ticket(conn, ticket_id)
if ticket is None:
abort(404)
new_status = "open" if str(ticket["status"]) == "blocked" else "blocked"
with transaction():
issuance.set_ticket_status(conn, ticket_id, new_status)
log_event(
"ticket.blocked" if new_status == "blocked" else "ticket.unblocked",
target=ticket_id[:12],
detail=str(ticket["customer_name"]),
severity="warning" if new_status == "blocked" else "notice",
scope="ticket",
)
reporting.report_soon(core())
flash(
"Ticket gesperrt -- es lassen sich keine neuen Lizenzen mehr ausstellen."
if new_status == "blocked"
else "Ticket entsperrt.",
"success",
)
return redirect(url_for("tickets.detail", ticket_id=ticket_id))
@bp.post("/<ticket_id>/loeschen")
@permission_required("tickets.delete")
def delete(ticket_id: str):
conn = db()
ticket = issuance.get_ticket(conn, ticket_id)
if ticket is None:
abort(404)
try:
with transaction():
issuance.delete_ticket(conn, ticket_id)
log_event(
"ticket.deleted",
target=ticket_id[:12],
detail=str(ticket["customer_name"]),
severity="warning",
scope="ticket",
)
reporting.report_soon(core())
flash("Ticket und seine Lizenzhistorie geloescht.", "success")
return redirect(url_for("tickets.overview"))
except issuance.IssuanceError as exc:
flash(str(exc), "danger")
return redirect(url_for("tickets.detail", ticket_id=ticket_id))
+469
View File
@@ -0,0 +1,469 @@
"""Kommandozeilenwerkzeuge des Lizenzservers.
Enthaelt insbesondere ``bootstrap`` -- die eigene Lizenz der Wurzelinstanz.
Warum das ein lokales Skript ist und keine Funktion in der Oberflaeche: die
Master-Lizenz ist die einzige Lizenz, fuer die es **keine hoehere Instanz gibt**,
die sie bestaetigen koennte. Ein Aktivierungs-Handshake gegen sich selbst waere
Theater. Das Skript signiert die Datei mit dem eigenen Schluessel und setzt den
Aktivierungszustand direkt -- und nur so kann eine Lizenzdatei je die Rolle
``master`` tragen. Die Ticket-Oberflaeche bietet diese Rolle an keiner Stelle an.
"""
from __future__ import annotations
import argparse
import getpass
import json
import sys
from pathlib import Path
from typing import Any
import tesm_licensing as lic
from tesm_core import audit
from tesm_core.cli_web import add_web_setup, run_web_setup
from tesm_core.db import Database
from tesm_core.extension import core
from tesm_core.rbac import service as rbac
from tesm_core.security import passwords
from . import create_app, master_key_path
from .services import legacy_import, master_key
def _app_and_core() -> tuple[Any, Any]:
app = create_app()
return app, core(app)
def cmd_init_key(args: argparse.Namespace) -> int:
app, extension = _app_and_core()
path = master_key_path(extension.config)
if path.exists() and not args.force:
key = master_key.load(path)
print(f"Signaturschluessel existiert bereits: {key.key_id} ({path})")
print("Ein Austausch macht ALLE bereits ausgestellten Lizenzen unpruefbar.")
return 0
if path.exists() and args.force:
print(
"ABBRUCH: --force auf einen bestehenden Signaturschluessel wird nicht unterstuetzt.\n"
"Ein neuer Schluessel entwertet jede ausgestellte Lizenz. Bitte die Datei bewusst\n"
"von Hand beiseite legen, wenn das wirklich gewollt ist.",
file=sys.stderr,
)
return 2
key = master_key.ensure(path)
print(f"Signaturschluessel erzeugt: {key.key_id}")
print(f"Datei: {path}")
print("Bitte SOFORT sichern -- ohne sie ist keine ausgestellte Lizenz mehr pruefbar.")
print(f"Oeffentlicher Schluessel (zum Pinnen beim Kunden):\n{key.public_key}")
return 0
def cmd_bootstrap(args: argparse.Namespace) -> int:
"""Stellt die eigene Lizenz dieser Wurzelinstanz aus."""
app, extension = _app_and_core()
key = master_key.ensure(master_key_path(extension.config))
with app.app_context(), extension.database.session() as conn:
vendor = {
"name": str(extension.settings.get(conn, "vendor_name") or "Eigenbetrieb"),
"email": str(extension.settings.get(conn, "vendor_email") or ""),
"phone": str(extension.settings.get(conn, "vendor_phone") or ""),
"address": str(extension.settings.get(conn, "vendor_address") or ""),
"logo_base64": "",
}
bundle, _ = lic.issue_license(
customer={"name": args.name, "contact_email": vendor["email"]},
license_type="enterprise",
modules=[],
valid_days=int(args.days),
master_private_key_b64=key.private_key,
master_public_key_b64=key.public_key,
# Bewusst leer: es gibt keinen uebergeordneten Endpunkt. Damit
# laeuft der Handshake fuer diese Lizenz nie ins Netz.
master_endpoint="",
vendor=vendor,
role="master",
)
document, client_key = lic.split_bundle(bundle)
config = extension.config
config.license_path.parent.mkdir(parents=True, exist_ok=True)
config.license_path.write_text(
json.dumps(document, indent=2, sort_keys=True, ensure_ascii=False) + "\n",
encoding="utf-8",
)
try:
config.license_path.chmod(0o640)
except OSError:
pass
from tesm_core.keystore import write_private_file
write_private_file(
config.license_key_path,
json.dumps({"license_id": document["license_id"], "client_key": client_key}) + "\n",
)
with extension.database.transaction(conn):
conn.execute(
"INSERT INTO trusted_issuer_keys (key_id, public_key, label, source, added_at, "
"added_by) VALUES (?,?,?,'bootstrap',datetime('now'),'cli') "
"ON CONFLICT(key_id) DO NOTHING",
(key.key_id, key.public_key, "Eigener Signaturschluessel"),
)
# Es gibt keine hoehere Instanz, die diese Lizenz aktivieren koennte.
extension.settings.set(conn, "license_activation_status", "active", actor="cli")
extension.settings.set(conn, "license_pending_request", "", actor="cli")
audit.log(
conn,
actor="cli",
action="own_license.bootstrapped",
target=document["license_id"],
detail=f"Rolle master, {args.days} Tage, Schluessel {key.key_id}",
severity="warning",
)
state = extension.license.reload(conn) if extension.license else None
print(f"Master-Lizenz ausgestellt: {document['license_id']}")
print(f"Gueltig bis: {document['expires_at']}")
print(f"Datei: {config.license_path}")
if state is not None:
print(f"Zustand: gueltig={state.valid}, aktiviert={state.activated}, Rolle={state.role}")
return 0
def cmd_create_admin(args: argparse.Namespace) -> int:
app, extension = _app_and_core()
username = args.username or input("Benutzername: ").strip()
if not username:
print("Kein Benutzername angegeben.", file=sys.stderr)
return 2
password = args.password or getpass.getpass("Passwort: ")
if not args.password and password != getpass.getpass("Passwort wiederholen: "):
print("Die Passwoerter stimmen nicht ueberein.", file=sys.stderr)
return 2
problems = extension.password_policy.check(password, username=username)
if problems and not args.force:
for problem in problems:
print(f"- {problem}", file=sys.stderr)
return 2
with app.app_context(), extension.database.session() as conn:
existing = rbac.find_user_by_username(conn, username, include_deleted=True)
with extension.database.transaction(conn):
if existing is not None:
conn.execute(
"UPDATE users SET password_hash=?, is_admin=1, is_locked=0, deleted_at=NULL, "
"must_change_password=0, updated_at=datetime('now') WHERE id=?",
(passwords.hash_password(password), existing["id"]),
)
action = "aktualisiert"
else:
conn.execute(
"INSERT INTO users (username, password_hash, is_admin, auth_source, "
"created_at, updated_at, password_changed_at) "
"VALUES (?,?,1,'local',datetime('now'),datetime('now'),datetime('now'))",
(username, passwords.hash_password(password)),
)
action = "angelegt"
audit.log(
conn,
actor="cli",
action="user.admin_created",
target=username,
severity="warning",
)
print(f"Administrator {username!r} {action}.")
return 0
def cmd_status(args: argparse.Namespace) -> int:
app, extension = _app_and_core()
with app.app_context(), extension.database.session() as conn:
chain = audit.verify_chain(conn, limit=5000)
counts = {
"kunden": Database.value(conn, "SELECT COUNT(*) FROM license_customers", default=0),
"tickets": Database.value(conn, "SELECT COUNT(*) FROM license_tickets", default=0),
"lizenzen": Database.value(conn, "SELECT COUNT(*) FROM licenses", default=0),
"aktiv": Database.value(
conn, "SELECT COUNT(*) FROM licenses WHERE status='active'", default=0
),
"administratoren": rbac.count_active_admins(conn),
}
state = extension.license.reload(conn) if extension.license else None
key_health = master_key.health(master_key_path(extension.config))
payload = {
"datenbank": str(extension.config.db_path),
"bestand": counts,
"audit_kette": chain,
"signaturschluessel": key_health,
"eigene_lizenz": (
{
"vorhanden": state.present,
"gueltig": state.valid,
"aktiviert": state.activated,
"rolle": state.role,
"fehler": state.error,
}
if state
else None
),
}
if args.json:
print(json.dumps(payload, indent=2, ensure_ascii=False, default=str))
return 0
print(f"Datenbank : {payload['datenbank']}")
for key, value in counts.items():
print(f"{key.capitalize():<17}: {value}")
print(
"Audit-Kette : "
+ ("unversehrt" if chain["ok"] else f"GEBROCHEN bei {chain['broken_at']}")
)
print(
"Signaturschluessel: "
+ (str(key_health.get("key_id")) if key_health.get("present") else "FEHLT")
)
for problem in key_health.get("problems", []):
print(f" ! {problem}", file=sys.stderr)
if state:
print(
"Eigene Lizenz : "
+ ("aktiv" if state.valid and state.activated else "nicht aktiv")
+ f", Rolle {state.role}"
)
if not counts["administratoren"]:
print("\nWARNUNG: kein entsperrtes Administratorkonto vorhanden.", file=sys.stderr)
return 1
return 0
def cmd_housekeeping(_args: argparse.Namespace) -> int:
app, extension = _app_and_core()
from tesm_core.security import ratelimit
from tesm_core.security import sessions as session_store
from .services import activation
with app.app_context(), extension.database.session() as conn:
with extension.database.transaction(conn):
sessions = session_store.prune(conn)
events = ratelimit.prune(conn)
nonces = activation.prune_nonces(conn)
archived = audit.archive_old_rows(conn, extension.audit_archive_dir)
expired_tokens = conn.execute(
"DELETE FROM portal_access_tokens WHERE expires_at < datetime('now', '-7 days')"
).rowcount
print(
f"{sessions} Sitzungen, {events} Ratenereignisse, {nonces} Nonces, "
f"{expired_tokens or 0} Portal-Token entfernt, {len(archived)} Protokolltage archiviert."
)
return 0
def cmd_web_setup(args: argparse.Namespace) -> int:
app, extension = _app_and_core()
return run_web_setup(app, extension, args)
def cmd_migrate_legacy(args: argparse.Namespace) -> int:
"""Uebernimmt Signaturschluessel, Kunden, Tickets und Lizenzen der Vorgaengerfassung."""
root = Path(args.source)
app, extension = _app_and_core()
try:
legacy = legacy_import.open_legacy(root)
except legacy_import.LegacyImportError as exc:
print(str(exc), file=sys.stderr)
return 2
key_path = master_key_path(extension.config)
report = legacy_import.Report()
try:
# -- Schluessel ---------------------------------------------------- #
# Der alte Schluessel liegt als 0600 root. Gelesen wird er nur, wenn er
# wirklich gebraucht wird -- naemlich beim Uebernehmen. Fuer den blossen
# Vergleich genuegt die oeffentliche Haelfte, und fehlt sie, ist das
# kein Grund abzubrechen: der Vergleich ist eine Auskunft, keine
# Voraussetzung.
issuer_changes = False
legacy_public = ""
try:
_, legacy_public = legacy_import.read_legacy_key(root)
except legacy_import.LegacyImportError as exc:
if args.adopt_key:
raise
print(f"Hinweis: alter Signaturschluessel nicht lesbar ({exc}).")
if key_path.is_file():
existing = master_key.load(key_path)
report.key_id = existing.key_id
if legacy_public and existing.public_key == legacy_public:
print(f"Signaturschluessel stimmt bereits ueberein ({existing.key_id}).")
elif not legacy_public:
issuer_changes = True
print(
f"Es wird mit dem vorhandenen Signaturschluessel {existing.key_id} "
"gearbeitet."
)
else:
# Nicht ueberschreiben: jede mit dem vorhandenen Schluessel
# ausgestellte Lizenz waere danach unpruefbar.
issuer_changes = True
print(
f"Diese Installation hat einen eigenen Signaturschluessel "
f"({existing.key_id}); der alte war {lic.key_id(legacy_public)}. "
"Es wird mit dem vorhandenen weitergearbeitet."
)
elif args.adopt_key:
legacy_private, legacy_public = legacy_import.read_legacy_key(root)
report.key_id = legacy_import.adopt_key(legacy_private, legacy_public, key_path)
report.key_adopted = True
print(f"Signaturschluessel uebernommen: {report.key_id}")
else:
print(
"Diese Installation hat noch keinen Signaturschluessel. Mit --adopt-key "
"wird der alte uebernommen -- dann bleibt die Anbieteridentitaet erhalten "
"und Kunden muessen keinen Ausstellerwechsel bestaetigen.",
file=sys.stderr,
)
return 2
key = master_key.load(key_path)
with app.app_context(), extension.database.session() as conn:
vendor = {
"name": str(extension.settings.get(conn, "vendor_name") or "Eigenbetrieb"),
"email": str(extension.settings.get(conn, "vendor_email") or ""),
"phone": str(extension.settings.get(conn, "vendor_phone") or ""),
"address": str(extension.settings.get(conn, "vendor_address") or ""),
"logo_base64": "",
}
endpoint = str(extension.settings.get(conn, "master_endpoint") or "")
if not endpoint and not args.dry_run:
print(
"Es ist kein Endpunkt hinterlegt (Einstellungen -> Anbieter). Ohne ihn "
"koennen uebernommene Lizenzen sich spaeter nicht aktivieren.",
file=sys.stderr,
)
return 2
with extension.database.transaction(conn):
result = legacy_import.import_all(
legacy,
conn,
keystore=extension.keystore,
master_private_key=key.private_key,
master_public_key=key.public_key,
master_endpoint=endpoint,
vendor=vendor,
actor="migration",
reissue=not args.dry_run,
)
result.key_adopted = report.key_adopted
result.key_id = report.key_id
if issuer_changes:
result.warnings.append(
"Die Lizenzen sind mit einem anderen Schluessel signiert als "
"frueher. Kunden, die den alten Aussteller gepinnt haben, muessen "
"beim Einspielen einmal 'Wechsel des Ausstellers ausdruecklich "
"zulassen' bestaetigen."
)
if args.dry_run:
# Nichts festschreiben -- der Lauf war nur zur Ansicht.
raise _DryRun(result)
except _DryRun as preview:
print()
print("== Probelauf, nichts gespeichert ==")
print(preview.report.as_text())
return 0
except legacy_import.LegacyImportError as exc:
print(str(exc), file=sys.stderr)
return 2
finally:
legacy.close()
print()
print(result.as_text())
return 0
class _DryRun(Exception):
"""Bricht die Transaktion eines Probelaufs ab -- ohne Fehler zu sein."""
def __init__(self, report: Any) -> None:
super().__init__("Probelauf")
self.report = report
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(
prog="tesm-license-admin", description="Verwaltungswerkzeuge des TESM-Lizenzservers"
)
sub = parser.add_subparsers(dest="command", required=True)
init_key = sub.add_parser("init-key", help="Signaturschluessel erzeugen (einmalig)")
init_key.add_argument("--force", action="store_true")
init_key.set_defaults(func=cmd_init_key)
bootstrap = sub.add_parser(
"bootstrap", help="Eigene Master-Lizenz dieser Wurzelinstanz ausstellen"
)
bootstrap.add_argument("--days", type=int, default=3650)
bootstrap.add_argument("--name", default="Lizenzserver (Eigenbetrieb)")
bootstrap.set_defaults(func=cmd_bootstrap)
admin = sub.add_parser("create-admin", help="Administratorkonto anlegen oder zuruecksetzen")
admin.add_argument("--username")
admin.add_argument("--password")
admin.add_argument("--force", action="store_true")
admin.set_defaults(func=cmd_create_admin)
status = sub.add_parser("status", help="Zustand ausgeben")
status.add_argument("--json", action="store_true")
status.set_defaults(func=cmd_status)
house = sub.add_parser("housekeeping", help="Laufzeitdaten aufraeumen")
house.set_defaults(func=cmd_housekeeping)
migrate = sub.add_parser(
"migrate-legacy",
help="Lizenzserver der Vorgaengerfassung uebernehmen",
description=(
"Uebernimmt Signaturschluessel, Kunden, Tickets und Lizenzen aus einer alten "
"Installation. Die alte Installation wird nur gelesen. Jede Lizenz wird im "
"neuen Format neu signiert -- die Kunden brauchen danach eine neue "
"Lizenzdatei, weil die alte Signatur nicht mehr gilt."
),
)
migrate.add_argument(
"--source", default="/srv/tesm-license", help="Verzeichnis der alten Installation"
)
migrate.add_argument(
"--adopt-key",
action="store_true",
help="Den alten Signaturschluessel uebernehmen. Nur moeglich, solange diese "
"Installation noch keinen eigenen hat -- ein vorhandener wird nie ueberschrieben.",
)
migrate.add_argument(
"--dry-run",
action="store_true",
help="Nur anzeigen, was passieren wuerde -- nichts schreiben",
)
migrate.set_defaults(func=cmd_migrate_legacy)
add_web_setup(sub, cmd_web_setup)
return parser
def main(argv: list[str] | None = None) -> int:
args = build_parser().parse_args(argv)
return int(args.func(args))
if __name__ == "__main__": # pragma: no cover
raise SystemExit(main())
@@ -0,0 +1,188 @@
"""Navigationsbaum des Lizenzservers.
Kunde, Ticket und Lizenz sind drei **flache** Top-Level-Punkte, jeder einzeln
ueber die Navigation erreichbar. Ein Zwischenstand des Vorgaengerprojekts hatte
Tickets und Lizenzen tief in die Kundenseite verschachtelt -- das war
nachweislich unbrauchbar. Die Kundendetailseite existiert trotzdem, aber als
reiner Lese-Ueberblick.
Settings und Protokolle bleiben zweistufige Gruppen -- das ist keine Ausnahme
von der Flachheit, sondern die Standardkonvention fuer mehrseitige Bereiche.
"""
from __future__ import annotations
from typing import Any
from tesm_core.extension import core
from tesm_core.nav import NavItem, NavTree
def _is_master(_user: Any) -> bool:
"""Nur eine Master-Instanz sieht die Berichte der Sub-Lizenzserver."""
manager = core().license
if manager is None:
return True
return manager.state.role == "master"
NAVIGATION = NavTree(
[
NavItem(key="index", label="Uebersicht", icon="grid", endpoint="dashboard.index"),
NavItem(
key="customers",
label="Kunden",
icon="building",
endpoint="customers.overview",
permissions=("customers.view",),
),
NavItem(
key="tickets",
label="Tickets",
icon="ticket",
endpoint="tickets.overview",
permissions=("tickets.view",),
),
NavItem(
key="licenses",
label="Lizenzen",
icon="shield",
endpoint="licenses.overview",
permissions=("licenses.view",),
),
NavItem(
key="offline_code",
label="Offline-Aktivierung",
icon="transfer",
endpoint="licenses.manual_code",
permissions=("licenses.execute",),
),
NavItem(
key="reports",
label="Sub-Lizenzserver",
icon="share",
endpoint="licenses.reports",
permissions=("reports.view",),
condition=_is_master,
),
NavItem(
key="observability",
label="Protokolle",
icon="terminal",
children=(
NavItem(
key="logs_live",
label="Laufend",
icon="terminal",
endpoint="logs.live",
permissions=("logs_live.view",),
),
NavItem(
key="logs_history",
label="Verlauf",
icon="clock",
endpoint="logs.history",
permissions=("logs_history.view", "logs_history.export"),
),
NavItem(
key="logs_audit",
label="Aenderungen",
icon="history",
endpoint="logs.audit_view",
permissions=("logs_audit.view", "logs_audit.export"),
),
),
),
NavItem(
key="administration",
label="Verwaltung",
icon="sliders",
children=(
NavItem(
key="users",
label="Benutzer",
icon="users",
endpoint="admin.users",
permissions=("users.view",),
),
NavItem(
key="groups",
label="Gruppen und Rechte",
icon="shield-users",
endpoint="admin.groups",
permissions=("groups.view",),
),
NavItem(
key="settings_vendor",
label="Anbieterdaten",
icon="building",
endpoint="settings.vendor",
permissions=("settings_vendor.view",),
),
NavItem(
key="settings_mail",
label="E-Mail-Versand",
icon="mail",
endpoint="settings.mail_settings",
permissions=("settings_mail.view",),
),
NavItem(
key="settings_system",
label="System",
icon="sliders",
endpoint="settings.system",
permissions=("settings_system.view",),
),
NavItem(
key="settings_network",
label="Netzwerk",
icon="network",
endpoint="hostsettings.network",
permissions=("settings_network.view",),
),
NavItem(
key="settings_web",
label="Webserver und TLS",
icon="server",
endpoint="hostsettings.web",
permissions=("settings_web.view",),
),
NavItem(
key="settings_directory",
label="Verzeichnisdienst",
icon="users",
endpoint="hostsettings.directory_settings",
permissions=("settings_directory.view",),
),
NavItem(
key="settings_license",
label="Eigene Lizenz",
icon="key",
endpoint="own_license.overview",
permissions=("settings_license.view",),
),
NavItem(
key="backup",
label="Sicherung",
icon="transfer",
endpoint="backup.overview",
permissions=("backup.view", "backup.export", "backup.execute"),
),
NavItem(
key="trash",
label="Papierkorb",
icon="trash",
endpoint="trash.overview",
permissions=("trash.view",),
),
NavItem(
key="diagnostics",
label="Diagnose",
icon="info",
endpoint="diagnostics.overview",
permissions=("diagnostics.view",),
),
),
),
]
)
@@ -0,0 +1,183 @@
"""Rechtematrix des Lizenzservers.
Dieselbe Systematik wie in TESM: festes Aktionsvokabular, Loeschen als eigenes
Recht, jede Seite mit eigenem Rechtepaar.
Zwei Nuancen, die der Vorgaenger nachtraeglich korrigieren musste und die hier
von Anfang an so gebaut sind:
* **Tickets und Lizenzen sind getrennte Rechte.** "Ticket sperren" und "Lizenz
ausstellen" sind verschiedene Vorgaenge und lassen sich unabhaengig vergeben.
* **Die eigene Lizenzseite hat ihr eigenes Recht** (``settings_license``) und
laeuft nicht ueber ``settings_system`` mit.
Und eine Grenze, die ausdruecklich **kein** Recht ist: das Ausstellen von
Lizenzserver-Lizenzen. Es haengt an der Rolle der eigenen Lizenzdatei
(``master``) und am Admin-Kennzeichen -- nicht an einer vergebbaren Permission.
Ein lokaler Administrator eines Sub-Lizenzservers koennte sich ein Recht selbst
einraeumen; eine von woanders signierte Lizenzdatei nicht.
"""
from __future__ import annotations
from tesm_core.rbac.common import administration_area, observability_area
from tesm_core.rbac.model import Action, Area, PermissionTree, Resource, RolePreset
A = Action
CUSTOMERS = Resource(
key="customers",
label="Kunden",
actions=(A.VIEW, A.CREATE, A.EDIT, A.DELETE),
hint="Stammdaten der Kundeninstanzen",
action_hints={A.DELETE: "Nur moeglich, solange kein Ticket am Kunden haengt."},
)
TICKETS = Resource(
key="tickets",
label="Tickets",
actions=(A.VIEW, A.CREATE, A.EDIT, A.DELETE),
hint="Dauerhafte Anspruechte auf eine Lizenz",
action_hints={
A.EDIT: "Parameter aendern und Ticket sperren/entsperren. Wirkt erst auf die "
"naechste Ausstellung -- bereits ausgestellte Lizenzen bleiben unveraendert.",
A.DELETE: "Nur moeglich, wenn keine offene Lizenz mehr existiert.",
},
)
LICENSES = Resource(
key="licenses",
label="Lizenzen",
actions=(A.VIEW, A.CREATE, A.EDIT, A.DELETE, A.EXECUTE, A.EXPORT),
hint="Konkrete, signierte Lizenzdateien",
action_hints={
A.CREATE: "Aus einem Ticket eine Lizenz ausstellen.",
A.EDIT: "Lizenz widerrufen oder eine neue Revision signieren.",
A.EXECUTE: "Offline-Aktivierungscodes verarbeiten.",
A.EXPORT: "Lizenzdatei herunterladen oder per E-Mail versenden.",
},
)
PORTAL = Resource(
key="portal",
label="Kundenportal",
actions=(A.VIEW, A.EXECUTE),
hint="Zugangslinks fuer Kunden",
action_hints={A.EXECUTE: "Zugangslink erzeugen und versenden."},
)
REPORTS = Resource(
key="reports",
label="Berichte der Sub-Lizenzserver",
actions=(A.VIEW,),
hint="Von untergeordneten Lizenzservern gemeldete Kunden, Tickets und Lizenzen",
)
MAIL = Resource(
key="settings_mail",
label="E-Mail-Versand",
actions=(A.VIEW, A.EDIT, A.EXECUTE, A.SECRETS),
hint="Microsoft Graph (App-only)",
action_hints={
A.EXECUTE: "Verbindungstest ausfuehren.",
A.SECRETS: "Client-Secret setzen.",
},
)
VENDOR = Resource(
key="settings_vendor",
label="Anbieterdaten",
actions=(A.VIEW, A.EDIT),
hint="Name, Kontakt, Logo und oeffentlicher Endpunkt -- reisen in jeder Lizenz mit",
)
LICENSING_AREA = Area(
key="licensing",
label="Lizenzierung",
icon="key",
hint="Kunden, Tickets, Lizenzen. Ohne diesen Zugang bleibt der gesamte Bereich verborgen.",
resources=(CUSTOMERS, TICKETS, LICENSES, PORTAL, REPORTS),
)
PERMISSIONS = PermissionTree(
[
LICENSING_AREA,
observability_area(),
administration_area(MAIL, VENDOR),
]
)
ROLE_PRESETS: tuple[RolePreset, ...] = (
RolePreset(
key="viewer",
name="Benutzer",
description="Standardgruppe: nur das laufende Protokoll lesen.",
grants=(("logs_live", (A.VIEW,)),),
is_default=True,
),
RolePreset(
key="sales",
name="Vertrieb",
description="Kunden und Tickets pflegen, Lizenzen ausstellen und versenden.",
grants=(
("customers", "*"),
("tickets", "*"),
("licenses", (A.VIEW, A.CREATE, A.EDIT, A.EXPORT)),
("portal", "*"),
("logs_live", (A.VIEW,)),
("logs_audit", (A.VIEW,)),
),
),
RolePreset(
key="support",
name="Support",
description="Lesen und Offline-Aktivierungen verarbeiten.",
grants=(
("customers", (A.VIEW,)),
("tickets", (A.VIEW,)),
("licenses", (A.VIEW, A.EXECUTE)),
("reports", (A.VIEW,)),
("logs_live", (A.VIEW,)),
),
),
RolePreset(
key="auditor",
name="Revision",
description="Nur lesen -- inklusive Aenderungsprotokoll und Diagnose.",
grants=(
("customers", (A.VIEW,)),
("tickets", (A.VIEW,)),
("licenses", (A.VIEW,)),
("reports", (A.VIEW,)),
("users", (A.VIEW,)),
("groups", (A.VIEW,)),
("logs_live", (A.VIEW,)),
("logs_history", (A.VIEW, A.EXPORT)),
("logs_audit", (A.VIEW, A.EXPORT)),
("diagnostics", (A.VIEW,)),
),
),
RolePreset(
key="administration",
name="Administration",
description="Benutzer, Gruppen und Einstellungen -- ohne Admin-Kennzeichen.",
grants=(
("users", "*"),
("groups", "*"),
("settings_system", "*"),
("settings_license", "*"),
("settings_directory", (A.VIEW, A.EDIT, A.EXECUTE)),
("settings_web", "*"),
("settings_network", "*"),
("settings_mail", (A.VIEW, A.EDIT, A.EXECUTE)),
("settings_vendor", "*"),
("backup", (A.VIEW, A.EXECUTE, A.EXPORT)),
("trash", "*"),
("diagnostics", (A.VIEW,)),
("logs_audit", (A.VIEW,)),
),
),
)
__all__ = ["PERMISSIONS", "ROLE_PRESETS"]
@@ -0,0 +1,216 @@
"""Migrationen der Komponente ``tesm_license``.
Das Datenmodell folgt der fachlichen Hierarchie **Kunde -> Ticket -> Lizenz**:
* Ein **Kunde** ist eine Firma mit Stammdaten.
* Ein **Ticket** ist der dauerhafte Anspruch dieses Kunden auf eine Lizenz mit
bestimmten Parametern (Typ, Module, Laufzeit).
* Eine **Lizenz** ist die konkrete, signierte Datei, die aus einem Ticket
ausgestellt wurde.
Der Vorgaenger hatte in einem frueheren Stand nur "Lizenz" in "Ticket"
umbenannt, ohne die Trennung tatsaechlich zu bauen -- das wird hier nicht
wiederholt.
Aenderungen gegenueber dem Vorgaenger:
* **Kein ``portal_token``.** Der permanente, unbegrenzt gueltige Magic-Link war
eine erkannte Schwachstelle und wurde durch einen zweistufigen Flow ersetzt
(Anfragelink 30 Minuten, einmalig -> Sitzung 60 Minuten). Spalte und Index des
alten Verfahrens entstehen hier gar nicht erst.
* **Nonce-Speicher** (``license_nonces``): jede Aktivierungs-/Heartbeat-Anfrage
darf genau einmal verarbeitet werden. Der Vorgaenger pruefte Nonces nicht --
eine abgefangene Anfrage war unbegrenzt wiederverwendbar.
* **Zustellprotokoll** (``license_deliveries``): wer wann welche Lizenzdatei
bekommen hat.
"""
from __future__ import annotations
from tesm_core.migrations import Migration
V1 = """
CREATE TABLE IF NOT EXISTS license_customers (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL COLLATE NOCASE,
contact_email TEXT NOT NULL DEFAULT '',
contact_phone TEXT NOT NULL DEFAULT '',
contact_person TEXT NOT NULL DEFAULT '',
-- Adresse ausdruecklich in Einzelfeldern, damit sie z. B. fuer Rechnungen
-- weiterverwendbar bleibt.
street TEXT NOT NULL DEFAULT '',
house_number TEXT NOT NULL DEFAULT '',
postal_code TEXT NOT NULL DEFAULT '',
city TEXT NOT NULL DEFAULT '',
notes TEXT NOT NULL DEFAULT '',
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
created_by TEXT NOT NULL DEFAULT ''
);
CREATE INDEX IF NOT EXISTS idx_customers_email ON license_customers(contact_email);
CREATE TABLE IF NOT EXISTS license_tickets (
-- Die ID ist zugleich der Schluessel des Self-Service-Links: 192 Bit
-- Zufall, nicht ratbar.
id TEXT PRIMARY KEY,
customer_id INTEGER NOT NULL REFERENCES license_customers(id) ON DELETE RESTRICT,
type TEXT NOT NULL,
modules TEXT NOT NULL DEFAULT '[]',
valid_days INTEGER NOT NULL DEFAULT 365,
lifetime INTEGER NOT NULL DEFAULT 0,
status TEXT NOT NULL DEFAULT 'open',
role TEXT NOT NULL DEFAULT 'customer',
notes TEXT NOT NULL DEFAULT '',
created_by TEXT NOT NULL DEFAULT '',
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_tickets_customer ON license_tickets(customer_id);
CREATE INDEX IF NOT EXISTS idx_tickets_status ON license_tickets(status);
CREATE TABLE IF NOT EXISTS licenses (
id INTEGER PRIMARY KEY AUTOINCREMENT,
license_id TEXT NOT NULL UNIQUE,
ticket_id TEXT REFERENCES license_tickets(id) ON DELETE CASCADE,
customer_id INTEGER NOT NULL REFERENCES license_customers(id) ON DELETE RESTRICT,
type TEXT NOT NULL,
role TEXT NOT NULL DEFAULT 'customer',
modules TEXT NOT NULL DEFAULT '[]',
lifetime INTEGER NOT NULL DEFAULT 0,
seq INTEGER NOT NULL DEFAULT 1,
issued_at TEXT NOT NULL,
expires_at TEXT NOT NULL,
license_pubkey TEXT NOT NULL,
license_document TEXT NOT NULL,
-- Der private Client-Schluessel dieser Lizenz, AES-256-GCM-verschluesselt und
-- an die Lizenz-ID gebunden. Er muss erneut ausliefer-bar sein (verlorene
-- Mail, neuer Ansprechpartner), darf aber nie im Klartext in der Datenbank
-- liegen. Die Herausgabe verlangt Recht, frische Passwortbestaetigung und
-- landet im Aenderungsprotokoll.
client_key_encrypted TEXT NOT NULL DEFAULT '',
status TEXT NOT NULL DEFAULT 'issued',
fingerprint TEXT,
hostname TEXT,
activated_at TEXT,
deactivated_at TEXT,
last_heartbeat_at TEXT,
revoked_at TEXT,
revoke_reason TEXT NOT NULL DEFAULT '',
created_by TEXT NOT NULL DEFAULT '',
created_at TEXT NOT NULL
);
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');
-- Verbrauchte Nonces des Aktivierungsprotokolls. Verhindert Replay.
CREATE TABLE IF NOT EXISTS license_nonces (
nonce TEXT PRIMARY KEY,
license_id TEXT NOT NULL,
action TEXT NOT NULL,
seen_at TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_nonces_seen ON license_nonces(seen_at);
-- Wer hat wann welche Lizenzdatei erhalten.
CREATE TABLE IF NOT EXISTS license_deliveries (
id INTEGER PRIMARY KEY AUTOINCREMENT,
license_id TEXT NOT NULL,
channel TEXT NOT NULL,
recipient TEXT NOT NULL DEFAULT '',
delivered_at TEXT NOT NULL,
delivered_by TEXT NOT NULL DEFAULT '',
detail TEXT NOT NULL DEFAULT ''
);
CREATE INDEX IF NOT EXISTS idx_deliveries_license ON license_deliveries(license_id);
-- Zeitlich begrenzte Zugangslinks des Kundenportals: einmalig verwendbar.
CREATE TABLE IF NOT EXISTS portal_access_tokens (
token TEXT PRIMARY KEY,
customer_id INTEGER NOT NULL REFERENCES license_customers(id) ON DELETE CASCADE,
created_at TEXT NOT NULL,
expires_at TEXT NOT NULL,
used_at TEXT,
created_ip TEXT NOT NULL DEFAULT ''
);
CREATE INDEX IF NOT EXISTS idx_portal_tokens_customer ON portal_access_tokens(customer_id);
-- Von Sub-Lizenzservern gemeldeter Bestand. Rein informativ; dieser Master
-- verwaltet ihn nicht selbst. Jeder Bericht ersetzt alle Zeilen seiner Quelle
-- vollstaendig (Schnappschuss statt Delta) -- so verschwindet ein beim
-- Sub-Lizenzserver geloeschtes Ticket automatisch auch hier.
CREATE TABLE IF NOT EXISTS reported_customers (
id INTEGER PRIMARY KEY AUTOINCREMENT,
source_license_id TEXT NOT NULL,
remote_ref TEXT NOT NULL,
name TEXT NOT NULL,
contact_email TEXT NOT NULL DEFAULT '',
contact_person TEXT NOT NULL DEFAULT '',
city TEXT NOT NULL DEFAULT '',
last_reported_at TEXT NOT NULL,
UNIQUE(source_license_id, remote_ref)
);
CREATE INDEX IF NOT EXISTS idx_reported_customers_src ON reported_customers(source_license_id);
CREATE TABLE IF NOT EXISTS reported_tickets (
id INTEGER PRIMARY KEY AUTOINCREMENT,
source_license_id TEXT NOT NULL,
remote_ref TEXT NOT NULL,
customer_ref TEXT NOT NULL,
type TEXT NOT NULL,
modules TEXT NOT NULL DEFAULT '[]',
valid_days INTEGER,
lifetime INTEGER NOT NULL DEFAULT 0,
status TEXT NOT NULL DEFAULT 'open',
role TEXT NOT NULL DEFAULT 'customer',
created_at TEXT,
last_reported_at TEXT NOT NULL,
UNIQUE(source_license_id, remote_ref)
);
CREATE INDEX IF NOT EXISTS idx_reported_tickets_src ON reported_tickets(source_license_id);
CREATE TABLE IF NOT EXISTS reported_licenses (
id INTEGER PRIMARY KEY AUTOINCREMENT,
source_license_id TEXT NOT NULL,
remote_ref TEXT NOT NULL,
ticket_ref TEXT,
type TEXT NOT NULL,
modules TEXT NOT NULL DEFAULT '[]',
lifetime INTEGER NOT NULL DEFAULT 0,
issued_at TEXT,
expires_at TEXT,
status TEXT NOT NULL,
fingerprint TEXT,
hostname TEXT,
activated_at TEXT,
last_heartbeat_at TEXT,
last_reported_at TEXT NOT NULL,
UNIQUE(source_license_id, remote_ref)
);
CREATE INDEX IF NOT EXISTS idx_reported_licenses_src ON reported_licenses(source_license_id);
"""
# Version 2 -----------------------------------------------------------------
#
# Wann wurde zuletzt eine neue Revision signiert? Aus diesem Zeitstempel und
# ``last_heartbeat_at`` ergibt sich, ob der Client sie schon hat. Ein eigener
# Status waere hier falsch: eine Lizenz kann aktiv sein *und* eine ausstehende
# Revision haben.
V2 = """
ALTER TABLE licenses ADD COLUMN revision_at TEXT;
CREATE INDEX IF NOT EXISTS idx_licenses_revision ON licenses(revision_at);
"""
LICENSE_MIGRATIONS: tuple[Migration, ...] = (
Migration(version=1, name="baseline", sql=V1, allow_integrity_error=True),
Migration(version=2, name="revision-timestamp", sql=V2),
)
@@ -0,0 +1 @@
"""Fachdienste des Lizenzservers -- Ausstellung, Aktivierung, Mail, Berichte."""
@@ -0,0 +1,344 @@
"""Aktivierung, Deaktivierung, Heartbeat -- die Serverseite des Protokolls.
Ein Satz Verarbeitungsfunktionen, zwei Transportwege: die JSON-API
(``/api/v1/licenses/*``) und der Offline-Code (Copy-Paste). Beide landen hier --
es gibt keinen zweiten, abweichenden Pfad, an dem eine Pruefung fehlen koennte.
Durchgesetzte Regeln:
* Signatur der Anfrage gegen den in der Datenbank gespeicherten
Lizenz-Public-Key -- nicht gegen etwas aus der Anfrage selbst.
* **Replay-Schutz**: jede Nonce genau einmal, plus Frischefenster auf dem
Zeitstempel. Der Vorgaenger pruefte beides nicht.
* **Bindung an die Maschine ueber den Fingerprint**, nie ueber den Hostnamen.
Der Hostname ist ein rein informatives Feld; er wird per ``COALESCE`` nur
ueberschrieben, wenn tatsaechlich ein Wert kommt.
* **Eine aktive Lizenz pro Maschine**: aktiviert jemand eine neue Lizenz auf
einem Fingerprint, der schon eine andere aktive traegt, wird die alte
automatisch deaktiviert. Pro Fingerprint, nicht pro Kunde -- ein Kunde darf
mehrere Maschinen gleichzeitig lizenziert haben.
"""
from __future__ import annotations
import json
import logging
import sqlite3
from dataclasses import dataclass
from datetime import datetime, timedelta, timezone
from typing import Any
import tesm_licensing as lic
from tesm_core import audit
from tesm_core.db import Database
logger = logging.getLogger(__name__)
NONCE_RETENTION_DAYS = 30
@dataclass(frozen=True, slots=True)
class Outcome:
"""Ergebnis einer Verarbeitung -- HTTP-Status inklusive."""
status_code: int
response: dict[str, Any]
@property
def ok(self) -> bool:
return self.status_code == 200
def _now() -> str:
return datetime.now(timezone.utc).isoformat(timespec="seconds")
def _error(status_code: int, code: str, message: str) -> Outcome:
"""Fehlerantworten sind bewusst **unsigniert** und knapp.
Sie enthalten nichts, was einem Angreifer beim Raten hilft -- insbesondere
keine Auskunft darueber, ob eine Lizenz-ID existiert, sobald die Signatur
nicht passt.
"""
return Outcome(status_code, {"error": code, "message": message})
def _signed_response(
conn: sqlite3.Connection,
request: dict[str, Any],
*,
master_private_key: str,
status: str,
detail: str = "",
license_update: dict[str, Any] | None = None,
) -> Outcome:
response = lic.build_master_response(
str(request["action"]),
license_id=str(request["license_id"]),
fingerprint=str(request["fingerprint"]),
request_nonce=str(request["nonce"]),
master_private_key=master_private_key,
status=status,
detail=detail,
license_update=license_update,
)
return Outcome(200, response)
def _remember_nonce(conn: sqlite3.Connection, request: dict[str, Any]) -> None:
conn.execute(
"INSERT OR IGNORE INTO license_nonces (nonce, license_id, action, seen_at) "
"VALUES (?,?,?,?)",
(
str(request["nonce"]),
str(request["license_id"]),
str(request["action"]),
_now(),
),
)
def prune_nonces(conn: sqlite3.Connection) -> int:
horizon = (datetime.now(timezone.utc) - timedelta(days=NONCE_RETENTION_DAYS)).isoformat(
timespec="seconds"
)
cursor = conn.execute("DELETE FROM license_nonces WHERE seen_at < ?", (horizon,))
return cursor.rowcount or 0
def _verify(
conn: sqlite3.Connection, request: dict[str, Any], *, expected_action: str | None
) -> tuple[dict[str, Any] | None, Outcome | None]:
"""Sucht die Lizenz und prueft die Anfrage. Rueckgabe: ``(zeile, fehler)``."""
if not isinstance(request, dict):
return None, _error(400, "bad_request", "Anfrage ist kein Objekt.")
license_id = str(request.get("license_id") or "")
row = Database.one(conn, "SELECT * FROM licenses WHERE license_id=?", (license_id,))
if row is None:
return None, _error(404, "unknown_license", "Unbekannte Lizenz.")
def seen(nonce: str) -> bool:
return (
Database.value(conn, "SELECT 1 FROM license_nonces WHERE nonce=?", (nonce,))
is not None
)
try:
lic.verify_client_request(
request,
str(row["license_pubkey"]),
expected_action=expected_action,
seen_nonce=seen,
)
except lic.SignatureInvalid:
return None, _error(400, "invalid_signature", "Signatur der Anfrage ist ungueltig.")
except lic.ReplayDetected as exc:
return None, _error(409, "replay", str(exc))
except lic.ProtocolViolation as exc:
return None, _error(400, "bad_request", str(exc))
return row, None
# --------------------------------------------------------------------------- #
# Aktionen
# --------------------------------------------------------------------------- #
def process_activate(
conn: sqlite3.Connection, request: dict[str, Any], *, master_private_key: str
) -> Outcome:
row, error = _verify(conn, request, expected_action="activate")
if error is not None or row is None:
return error or _error(400, "bad_request", "Ungueltige Anfrage.")
fingerprint = str(request["fingerprint"])
hostname = str(request.get("hostname") or "")
status = str(row["status"])
if status == "revoked":
_remember_nonce(conn, request)
return _signed_response(
conn,
request,
master_private_key=master_private_key,
status="revoked",
detail="Diese Lizenz wurde widerrufen.",
)
if status == "active" and str(row["fingerprint"] or "") not in {"", fingerprint}:
_remember_nonce(conn, request)
return _error(
409,
"already_bound_elsewhere",
"Diese Lizenz ist bereits auf einer anderen Maschine aktiv. "
"Bitte dort zuerst deaktivieren.",
)
# Eine Maschine traegt hoechstens eine aktive Lizenz: eine andere aktive
# Lizenz auf demselben Fingerprint wird verdraengt.
superseded = Database.all(
conn,
"SELECT license_id FROM licenses WHERE fingerprint=? AND status='active' "
"AND license_id<>?",
(fingerprint, str(row["license_id"])),
)
for other in superseded:
conn.execute(
"UPDATE licenses SET status='deactivated', deactivated_at=? WHERE license_id=?",
(_now(), other["license_id"]),
)
conn.execute(
"UPDATE licenses SET status='active', fingerprint=?, "
"hostname=COALESCE(NULLIF(?, ''), hostname), activated_at=?, deactivated_at=NULL "
"WHERE license_id=?",
(fingerprint, hostname, _now(), str(row["license_id"])),
)
_remember_nonce(conn, request)
audit.log(
conn,
actor="api",
action="license.activated",
target=str(row["license_id"]),
detail=(
f"Fingerprint {fingerprint[:16]}..., Host {hostname or 'unbekannt'}"
+ (f", verdraengt {len(superseded)} Lizenz(en)" if superseded else "")
),
severity="notice",
)
return _signed_response(
conn,
request,
master_private_key=master_private_key,
status="ok",
detail="Aktivierung bestaetigt.",
)
def process_deactivate(
conn: sqlite3.Connection, request: dict[str, Any], *, master_private_key: str
) -> Outcome:
row, error = _verify(conn, request, expected_action="deactivate")
if error is not None or row is None:
return error or _error(400, "bad_request", "Ungueltige Anfrage.")
fingerprint = str(request["fingerprint"])
if str(row["status"]) != "active" or str(row["fingerprint"] or "") != fingerprint:
_remember_nonce(conn, request)
return _error(
409,
"not_active_on_this_fingerprint",
"Diese Lizenz ist auf dieser Maschine nicht aktiv.",
)
conn.execute(
"UPDATE licenses SET status='deactivated', deactivated_at=? WHERE license_id=?",
(_now(), str(row["license_id"])),
)
_remember_nonce(conn, request)
audit.log(
conn,
actor="api",
action="license.deactivated",
target=str(row["license_id"]),
detail=f"Fingerprint {fingerprint[:16]}...",
severity="notice",
)
return _signed_response(
conn,
request,
master_private_key=master_private_key,
status="ok",
detail="Deaktivierung bestaetigt. Die Lizenz kann auf einer anderen Maschine aktiviert werden.",
)
def process_heartbeat(
conn: sqlite3.Connection, request: dict[str, Any], *, master_private_key: str
) -> Outcome:
row, error = _verify(conn, request, expected_action="heartbeat")
if error is not None or row is None:
return error or _error(400, "bad_request", "Ungueltige Anfrage.")
fingerprint = str(request["fingerprint"])
hostname = str(request.get("hostname") or "")
if str(row["status"]) == "revoked":
# Auch hier eine signierte Antwort: der Client loescht seine Lizenzdatei
# erst, wenn er die Signatur selbst geprueft hat.
_remember_nonce(conn, request)
return _signed_response(
conn,
request,
master_private_key=master_private_key,
status="revoked",
detail="Diese Lizenz wurde widerrufen.",
)
if str(row["status"]) != "active" or str(row["fingerprint"] or "") != fingerprint:
_remember_nonce(conn, request)
return _error(409, "not_active", "Diese Lizenz ist auf dieser Maschine nicht aktiv.")
conn.execute(
"UPDATE licenses SET last_heartbeat_at=?, hostname=COALESCE(NULLIF(?, ''), hostname) "
"WHERE license_id=?",
(_now(), hostname, str(row["license_id"])),
)
_remember_nonce(conn, request)
report = request.get("report")
if report is not None:
from .reporting import ingest_report
# Nur Lizenzen, die in **dieser** Datenbank als Lizenzserver eingetragen
# sind, duerfen berichten. Was der Client behauptet, ist unerheblich.
if str(row["role"]) == "license_server":
ingested = ingest_report(conn, str(row["license_id"]), report)
audit.log(
conn,
actor="api",
action="license.report_ingested",
target=str(row["license_id"]),
detail=(
f"{ingested['customers']} Kunden, {ingested['tickets']} Tickets, "
f"{ingested['licenses']} Lizenzen"
),
)
else:
logger.warning(
"Bericht von Lizenz %s verworfen -- Rolle ist %s, nicht license_server",
row["license_id"],
row["role"],
)
# Nachtraegliche Aenderungen (z. B. Modulerweiterung) erreichen den Client
# ueber den Heartbeat -- ohne erneute Aktivierung.
document = json.loads(str(row["license_document"]))
client_seq = int(request.get("seq") or 0)
update = document if int(document.get("seq") or 1) > client_seq else None
return _signed_response(
conn,
request,
master_private_key=master_private_key,
status="ok",
detail="Verbindung bestaetigt.",
license_update=update,
)
PROCESSORS = {
"activate": process_activate,
"deactivate": process_deactivate,
"heartbeat": process_heartbeat,
}
def process(
conn: sqlite3.Connection, request: dict[str, Any], *, master_private_key: str
) -> Outcome:
"""Verteilt anhand des ``action``-Feldes -- fuer den Offline-Code-Weg."""
action = str((request or {}).get("action") or "")
processor = PROCESSORS.get(action)
if processor is None:
return _error(400, "bad_action", "Unbekannte Aktion.")
return processor(conn, request, master_private_key=master_private_key)
@@ -0,0 +1,765 @@
"""Kunden, Tickets und die Ausstellung von Lizenzen."""
from __future__ import annotations
import json
import re
import secrets
import sqlite3
from dataclasses import dataclass
from datetime import datetime, timezone
from typing import Any, Iterable, Sequence
import tesm_licensing as lic
from tesm_core.db import Database
TICKET_TYPES = lic.LICENSE_TYPES
TICKET_STATES = ("open", "blocked")
LICENSE_STATES = ("issued", "active", "deactivated", "revoked")
#: Ampel-Schwellen der Anzeige. Bewusst getrennt von der Kulanzlogik in
#: ``tesm_licensing`` -- gleiche Zahl (30), anderes Konzept.
GREEN_ABOVE_DAYS = 90
RED_BELOW_DAYS = 30
EMAIL_RE = re.compile(r"^[^@\s]+@[^@\s]+\.[^@\s]+$")
class IssuanceError(Exception):
"""Eingabe- oder Regelverstoss mit Klartextmeldung."""
def _now() -> str:
return datetime.now(timezone.utc).isoformat(timespec="seconds")
# --------------------------------------------------------------------------- #
# Kunden
# --------------------------------------------------------------------------- #
def validate_email(value: str, *, required: bool) -> str:
text = (value or "").strip()
if not text:
if required:
raise IssuanceError("Eine E-Mail-Adresse ist erforderlich.")
# Leer bleibt erlaubt: Altkunden von vor der Pflichtfeld-Einfuehrung
# sollen weiter bearbeitbar bleiben, statt ausgesperrt zu werden.
return ""
if not EMAIL_RE.match(text):
raise IssuanceError(f"{text!r} ist keine gueltige E-Mail-Adresse.")
return text
def list_customers(conn: sqlite3.Connection) -> list[dict[str, Any]]:
return Database.all(
conn,
"""
SELECT c.*,
(SELECT COUNT(*) FROM license_tickets t WHERE t.customer_id = c.id) AS ticket_count,
(SELECT COUNT(*) FROM licenses l
WHERE l.customer_id = c.id AND l.status IN ('issued','active')) AS open_licenses,
(SELECT l.license_id FROM licenses l
WHERE l.customer_id = c.id AND l.status='active' AND l.role='license_server'
LIMIT 1) AS license_server_id
FROM license_customers c
ORDER BY c.name COLLATE NOCASE
""",
)
def get_customer(conn: sqlite3.Connection, customer_id: int) -> dict[str, Any] | None:
return Database.one(conn, "SELECT * FROM license_customers WHERE id=?", (customer_id,))
def create_customer(conn: sqlite3.Connection, *, actor: str, **fields: Any) -> int:
name = str(fields.get("name") or "").strip()
if not name:
raise IssuanceError("Der Firmenname darf nicht leer sein.")
email = validate_email(str(fields.get("contact_email") or ""), required=True)
cursor = conn.execute(
"INSERT INTO license_customers (name, contact_email, contact_phone, contact_person, "
"street, house_number, postal_code, city, notes, created_at, updated_at, created_by) "
"VALUES (?,?,?,?,?,?,?,?,?,?,?,?)",
(
name,
email,
str(fields.get("contact_phone") or "").strip(),
str(fields.get("contact_person") or "").strip(),
str(fields.get("street") or "").strip(),
str(fields.get("house_number") or "").strip(),
str(fields.get("postal_code") or "").strip(),
str(fields.get("city") or "").strip(),
str(fields.get("notes") or "").strip(),
_now(),
_now(),
actor,
),
)
return int(cursor.lastrowid or 0)
def update_customer(conn: sqlite3.Connection, customer_id: int, **fields: Any) -> list[str]:
row = get_customer(conn, customer_id)
if row is None:
raise IssuanceError("Dieser Kunde existiert nicht.")
name = str(fields.get("name") or "").strip()
if not name:
raise IssuanceError("Der Firmenname darf nicht leer sein.")
email = validate_email(str(fields.get("contact_email") or ""), required=False)
columns = {
"name": name,
"contact_email": email,
"contact_phone": str(fields.get("contact_phone") or "").strip(),
"contact_person": str(fields.get("contact_person") or "").strip(),
"street": str(fields.get("street") or "").strip(),
"house_number": str(fields.get("house_number") or "").strip(),
"postal_code": str(fields.get("postal_code") or "").strip(),
"city": str(fields.get("city") or "").strip(),
"notes": str(fields.get("notes") or "").strip(),
}
changes = [key for key, value in columns.items() if str(row.get(key) or "") != value]
if not changes:
return []
assignments = ", ".join(f"{key}=?" for key in columns)
conn.execute(
f"UPDATE license_customers SET {assignments}, updated_at=? WHERE id=?",
(*columns.values(), _now(), customer_id),
)
return changes
def delete_customer(conn: sqlite3.Connection, customer_id: int) -> None:
tickets = Database.value(
conn, "SELECT COUNT(*) FROM license_tickets WHERE customer_id=?", (customer_id,), default=0
)
if int(tickets or 0):
raise IssuanceError(
f"An diesem Kunden haengen noch {tickets} Ticket(s). Bitte zuerst entfernen."
)
conn.execute("DELETE FROM license_customers WHERE id=?", (customer_id,))
def find_customer_by_email(conn: sqlite3.Connection, email: str) -> dict[str, Any] | None:
return Database.one(
conn,
"SELECT * FROM license_customers WHERE contact_email <> '' "
"AND lower(contact_email)=lower(?)",
((email or "").strip(),),
)
# --------------------------------------------------------------------------- #
# Tickets
# --------------------------------------------------------------------------- #
def list_tickets(
conn: sqlite3.Connection, *, customer_id: int | None = None
) -> list[dict[str, Any]]:
clause = "WHERE t.customer_id = ?" if customer_id else ""
params = (customer_id,) if customer_id else ()
return Database.all(
conn,
f"""
SELECT t.*, c.name AS customer_name, c.contact_email,
(SELECT COUNT(*) FROM licenses l WHERE l.ticket_id = t.id) AS license_count,
(SELECT l.license_id FROM licenses l
WHERE l.ticket_id = t.id AND l.status IN ('issued','active') LIMIT 1) AS open_license_id
FROM license_tickets t
JOIN license_customers c ON c.id = t.customer_id
{clause}
ORDER BY t.created_at DESC
""",
params,
)
def get_ticket(conn: sqlite3.Connection, ticket_id: str) -> dict[str, Any] | None:
return Database.one(
conn,
"SELECT t.*, c.name AS customer_name, c.contact_email, c.contact_person "
"FROM license_tickets t JOIN license_customers c ON c.id = t.customer_id WHERE t.id=?",
(ticket_id,),
)
def normalize_modules(license_type: str, role: str, modules: Iterable[str]) -> list[str]:
"""Bestimmt den Modulsatz eines Tickets.
Regeln (bewusst hier zentral, nicht in der Oberflaeche):
* ``role != "customer"``: immer leer -- das TESM-Modulkonzept ist fuer einen
Lizenzserver bedeutungslos.
* ``trial``/``standard``: leer (Trial bekommt den vollen Satz erst bei der
Ausstellung als Trial-Override).
* ``enterprise``: alle Module.
* ``custom``: die uebergebene Auswahl.
"""
if role != "customer":
return []
if license_type == "enterprise":
return sorted(lic.ALL_MODULES)
if license_type == "custom":
return sorted(set(modules) & set(lic.ALL_MODULES))
return []
def create_ticket(
conn: sqlite3.Connection,
*,
customer_id: int,
license_type: str,
modules: Sequence[str],
valid_days: int,
lifetime: bool,
role: str,
notes: str = "",
actor: str = "",
) -> str:
if get_customer(conn, customer_id) is None:
raise IssuanceError("Dieser Kunde existiert nicht.")
if license_type not in TICKET_TYPES:
raise IssuanceError("Unbekannter Lizenztyp.")
if role not in lic.LICENSE_ROLES or role == "master":
# "master" entsteht ausschliesslich lokal per Bootstrap-Skript.
raise IssuanceError("Unzulaessige Rolle.")
# Trial + unbefristet ist ein sinnloser Zustand: eine permanente Lizenz ohne
# Module. Diese Regel wird an drei Stellen geprueft (hier, beim Bearbeiten
# und unmittelbar vor dem Signieren).
if lifetime and license_type == "trial":
raise IssuanceError("Eine Testlizenz kann nicht unbefristet sein.")
if not lifetime and int(valid_days) <= 0:
raise IssuanceError("Die Laufzeit muss positiv sein.")
ticket_id = secrets.token_urlsafe(24)
conn.execute(
"INSERT INTO license_tickets (id, customer_id, type, modules, valid_days, lifetime, "
"status, role, notes, created_by, created_at, updated_at) "
"VALUES (?,?,?,?,?,?,'open',?,?,?,?,?)",
(
ticket_id,
customer_id,
license_type,
json.dumps(normalize_modules(license_type, role, modules)),
int(valid_days),
int(bool(lifetime)),
role,
(notes or "").strip(),
actor,
_now(),
_now(),
),
)
return ticket_id
def update_ticket(
conn: sqlite3.Connection,
ticket_id: str,
*,
license_type: str,
modules: Sequence[str],
valid_days: int,
lifetime: bool,
notes: str = "",
) -> list[str]:
"""Aendert die Parameter eines Tickets.
Wirkt ausschliesslich auf die **naechste** Ausstellung. Bereits ausgestellte
Lizenzen behalten die Werte, mit denen sie signiert wurden -- eine
nachtraegliche Aenderung waere sonst eine Signaturaenderung ohne neue Datei.
Kunde und Rolle sind nach der Anlage unveraenderlich: das Self-Service-Ticket
ist an genau diesen Kunden gebunden, und ein Rollenwechsel wuerde eine
Vertrauensgrenze verschieben.
"""
ticket = get_ticket(conn, ticket_id)
if ticket is None:
raise IssuanceError("Dieses Ticket existiert nicht.")
if license_type not in TICKET_TYPES:
raise IssuanceError("Unbekannter Lizenztyp.")
if lifetime and license_type == "trial":
raise IssuanceError("Eine Testlizenz kann nicht unbefristet sein.")
role = str(ticket["role"])
new_modules = json.dumps(normalize_modules(license_type, role, modules))
changes: list[str] = []
if license_type != str(ticket["type"]):
changes.append(f"Typ: {ticket['type']} -> {license_type}")
if new_modules != str(ticket["modules"]):
changes.append("Module geaendert")
if int(valid_days) != int(ticket["valid_days"]):
changes.append(f"Laufzeit: {ticket['valid_days']} -> {valid_days} Tage")
if bool(lifetime) != bool(ticket["lifetime"]):
changes.append(f"Unbefristet: {bool(ticket['lifetime'])} -> {bool(lifetime)}")
conn.execute(
"UPDATE license_tickets SET type=?, modules=?, valid_days=?, lifetime=?, notes=?, "
"updated_at=? WHERE id=?",
(
license_type,
new_modules,
int(valid_days),
int(bool(lifetime)),
(notes or "").strip(),
_now(),
ticket_id,
),
)
return changes
def set_ticket_status(conn: sqlite3.Connection, ticket_id: str, status: str) -> None:
if status not in TICKET_STATES:
raise IssuanceError("Unbekannter Ticketzustand.")
conn.execute(
"UPDATE license_tickets SET status=?, updated_at=? WHERE id=?",
(status, _now(), ticket_id),
)
def ticket_has_open_license(conn: sqlite3.Connection, ticket_id: str) -> bool:
return (
Database.value(
conn,
"SELECT COUNT(*) FROM licenses WHERE ticket_id=? AND status IN ('issued','active')",
(ticket_id,),
default=0,
)
or 0
) > 0
def delete_ticket(conn: sqlite3.Connection, ticket_id: str) -> None:
if ticket_has_open_license(conn, ticket_id):
raise IssuanceError(
"Zu diesem Ticket existiert noch eine offene Lizenz. Bitte zuerst widerrufen."
)
# Historische Lizenzzeilen haengen per ON DELETE CASCADE mit -- sonst blieben
# verwaiste Zeilen mit toter ticket_id zurueck.
conn.execute("DELETE FROM license_tickets WHERE id=?", (ticket_id,))
# --------------------------------------------------------------------------- #
# Lizenzen
# --------------------------------------------------------------------------- #
def list_licenses(
conn: sqlite3.Connection,
*,
customer_id: int | None = None,
ticket_id: str | None = None,
status: str | None = None,
) -> list[dict[str, Any]]:
clauses: list[str] = []
params: list[Any] = []
if customer_id:
clauses.append("l.customer_id = ?")
params.append(customer_id)
if ticket_id:
clauses.append("l.ticket_id = ?")
params.append(ticket_id)
if status:
clauses.append("l.status = ?")
params.append(status)
where = f"WHERE {' AND '.join(clauses)}" if clauses else ""
return Database.all(
conn,
f"""
SELECT l.*, c.name AS customer_name, c.contact_email
FROM licenses l JOIN license_customers c ON c.id = l.customer_id
{where}
ORDER BY l.created_at DESC
""",
tuple(params),
)
def get_license(conn: sqlite3.Connection, license_id: str) -> dict[str, Any] | None:
return Database.one(
conn,
"SELECT l.*, c.name AS customer_name, c.contact_email, c.contact_person "
"FROM licenses l JOIN license_customers c ON c.id = l.customer_id WHERE l.license_id=?",
(license_id,),
)
@dataclass(frozen=True, slots=True)
class IssuedLicense:
license_id: str
bundle: dict[str, Any]
document: dict[str, Any]
already_existed: bool = False
def client_key_aad(license_id: str) -> str:
return f"licenses.client_key:{license_id}"
def issue_from_ticket(
conn: sqlite3.Connection,
ticket_id: str,
*,
master_private_key: str,
master_public_key: str,
master_endpoint: str,
vendor: dict[str, Any],
actor: str,
keystore: Any,
as_trial: bool = False,
) -> IssuedLicense:
"""Stellt aus einem Ticket eine Lizenz aus.
``as_trial`` ist ein reiner **Ausstellungs-Override**: 30 Tage, alle Module,
Typ ``trial``. Das Ticket selbst bleibt dabei unveraendert -- nur diese eine
Datei traegt Trial-Konditionen. Fuer Tickets mit einer anderen Rolle als
``customer`` ist der Override sinnlos und wird abgewiesen.
"""
ticket = get_ticket(conn, ticket_id)
if ticket is None:
raise IssuanceError("Dieses Ticket existiert nicht.")
if str(ticket["status"]) != "open":
raise IssuanceError("Dieses Ticket ist gesperrt.")
role = str(ticket["role"])
if as_trial and role != "customer":
raise IssuanceError(
"Eine Testlizenz gibt es nur fuer Kundeninstanzen, nicht fuer Lizenzserver."
)
if as_trial:
license_type = "trial"
modules = sorted(lic.ALL_MODULES)
valid_days = lic.TRIAL_DEFAULT_DAYS
lifetime = False
else:
license_type = str(ticket["type"])
modules = json.loads(str(ticket["modules"] or "[]"))
valid_days = int(ticket["valid_days"])
lifetime = bool(ticket["lifetime"])
# Dritte Absicherung derselben Regel, unmittelbar vor dem Signieren.
if lifetime and license_type == "trial":
raise IssuanceError("Eine Testlizenz kann nicht unbefristet sein.")
# Der Endpunkt wird mitsigniert. Ohne ihn entsteht eine Lizenz, die sich
# nie online aktivieren laesst -- und das laesst sich nachtraeglich nur
# durch eine neue Signatur beheben, nicht durch eine Einstellung. Deshalb
# hier, an der einzigen Stelle, durch die jede Ausstellung laeuft.
if not str(master_endpoint or "").strip():
raise IssuanceError(
"Es ist kein oeffentlicher Endpunkt hinterlegt (Einstellungen -> Anbieter). "
"Ohne ihn koennte sich diese Lizenz nie online aktivieren -- der Endpunkt "
"wird in die Lizenz signiert und laesst sich spaeter nicht nachtragen."
)
customer = get_customer(conn, int(ticket["customer_id"]))
assert customer is not None
bundle, license_pub = lic.issue_license(
customer={
"name": str(customer["name"]),
"contact_email": str(customer["contact_email"] or ""),
"customer_ref": str(customer["id"]),
},
license_type=license_type,
modules=modules,
valid_days=valid_days,
master_private_key_b64=master_private_key,
master_public_key_b64=master_public_key,
master_endpoint=master_endpoint,
vendor=vendor,
role=role,
lifetime=lifetime,
)
document = bundle["license"]
try:
conn.execute(
"INSERT INTO licenses (license_id, ticket_id, customer_id, type, role, modules, "
"lifetime, seq, issued_at, expires_at, license_pubkey, license_document, "
"client_key_encrypted, status, created_by, created_at) "
"VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,'issued',?,?)",
(
document["license_id"],
ticket_id,
int(ticket["customer_id"]),
license_type,
role,
json.dumps(modules),
int(bool(lifetime)),
int(document["seq"]),
document["issued_at"],
document["expires_at"],
license_pub,
json.dumps(document),
keystore.encrypt(
str(bundle["client_key"]), aad=client_key_aad(str(document["license_id"]))
),
actor,
_now(),
),
)
except sqlite3.IntegrityError:
# 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.
existing = Database.one(
conn,
"SELECT license_id, license_document FROM licenses "
"WHERE ticket_id=? AND status IN ('issued','active')",
(ticket_id,),
)
if existing is None:
raise
return IssuedLicense(
license_id=str(existing["license_id"]),
bundle={},
document=json.loads(str(existing["license_document"])),
already_existed=True,
)
return IssuedLicense(
license_id=str(document["license_id"]), bundle=bundle, document=document
)
def delivery_bundle(conn: sqlite3.Connection, license_id: str, *, keystore: Any) -> dict[str, Any]:
"""Baut das Auslieferungs-Bundle inklusive Client-Schluessel neu auf.
Der Schluessel liegt verschluesselt in der Datenbank; hier wird er genau
fuer die Auslieferung entschluesselt. Fehlt er (Lizenz aus einer aelteren
Version), enthaelt das Bundle nur das Dokument -- der Kunde braucht dann eine
neu ausgestellte Lizenz.
"""
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",
"fmt": lic.FORMAT_VERSION,
"license": document,
}
encrypted = str(row["client_key_encrypted"] or "")
if encrypted:
try:
bundle["client_key"] = keystore.decrypt(encrypted, aad=client_key_aad(license_id))
except Exception as exc: # noqa: BLE001
raise IssuanceError(
"Der Client-Schluessel dieser Lizenz konnte nicht entschluesselt werden. "
"Bitte eine neue Lizenz ausstellen."
) from exc
return bundle
def revoke_license(conn: sqlite3.Connection, license_id: str, *, reason: str = "") -> None:
"""Widerruft eine Lizenz.
Das Ticket bleibt bewusst offen: ein Widerruf ist Routine (Systemwechsel,
Neuinstallation), keine Sperre. Wer den Kunden aussperren will, sperrt das
Ticket.
"""
row = get_license(conn, license_id)
if row is None:
raise IssuanceError("Diese Lizenz existiert nicht.")
if str(row["status"]) == "revoked":
raise IssuanceError("Diese Lizenz ist bereits widerrufen.")
conn.execute(
"UPDATE licenses SET status='revoked', revoked_at=?, revoke_reason=? WHERE license_id=?",
(_now(), (reason or "").strip()[:400], license_id),
)
def delete_license(conn: sqlite3.Connection, license_id: str) -> None:
row = get_license(conn, license_id)
if row is None:
raise IssuanceError("Diese Lizenz existiert nicht.")
if str(row["status"]) not in {"revoked", "issued"}:
raise IssuanceError(
"Eine aktive Lizenz kann nicht geloescht werden. Bitte zuerst widerrufen."
)
conn.execute("DELETE FROM licenses WHERE license_id=?", (license_id,))
def resign_license(
conn: sqlite3.Connection,
license_id: str,
*,
master_private_key: str,
changes: dict[str, Any],
) -> dict[str, Any]:
"""Signiert eine neue Revision -- z. B. nach einer Modulerweiterung.
Der Client erhaelt sie beim naechsten Heartbeat. ``seq`` steigt dabei; eine
aeltere Revision kann ihm damit nicht mehr eingespielt werden.
"""
row = get_license(conn, license_id)
if row is None:
raise IssuanceError("Diese Lizenz existiert nicht.")
document = json.loads(str(row["license_document"]))
updated = lic.resign_license(
document, master_private_key_b64=master_private_key, changes=changes
)
conn.execute(
"UPDATE licenses SET license_document=?, seq=?, type=?, modules=?, lifetime=?, "
"expires_at=?, revision_at=? WHERE license_id=?",
(
json.dumps(updated),
int(updated["seq"]),
str(updated["type"]),
json.dumps(updated["modules"]),
int(bool(updated["lifetime"])),
str(updated["expires_at"]),
_now(),
license_id,
),
)
return updated
def record_delivery(
conn: sqlite3.Connection,
*,
license_id: str,
channel: str,
recipient: str,
actor: str,
detail: str = "",
) -> None:
conn.execute(
"INSERT INTO license_deliveries (license_id, channel, recipient, delivered_at, "
"delivered_by, detail) VALUES (?,?,?,?,?,?)",
(license_id, channel, recipient, _now(), actor, detail[:400]),
)
# --------------------------------------------------------------------------- #
# Anzeige
# --------------------------------------------------------------------------- #
def revision_pending(row: Any) -> bool:
"""Wurde seit der letzten Revision noch kein Kontakt vom Client gesehen?
Widerrufene Lizenzen zaehlen nicht -- dort ist nichts mehr abzuholen.
"""
def field(name: str) -> Any:
# Die Zeile kommt aus verschiedenen Abfragen; nicht jede waehlt alles.
# Eine fehlende Spalte darf keine Ausnahme werfen -- die Funktion sitzt
# in Listen, Detailseiten und im Kundenportal.
try:
return row[name]
except (KeyError, IndexError, TypeError):
return None
revision_at = field("revision_at")
if not revision_at or str(field("status") or "") == "revoked":
return False
last = field("last_heartbeat_at") or field("activated_at")
if not last:
return True
# Beide Zeitstempel stammen aus ``_now()`` und sind damit gleich formatiert.
return str(last) < str(revision_at)
def display_state(row: dict[str, Any], *, now: datetime | None = None) -> dict[str, Any]:
"""Ampelfarbe und Resttage einer Lizenzzeile.
Genau eine Stelle im Code, von jeder Oberflaeche benutzt (Dashboard,
Listen, Detailseiten, Self-Service, Kundenportal, gemeldete Bestaende der
Sub-Lizenzserver).
Grenzen: mehr als 90 Tage = gruen, 30 bis 90 Tage = orange, unter 30 Tage
(auch abgelaufen) = rot. Unbefristete Lizenzen sind immer gruen.
"""
moment = now or datetime.now(timezone.utc)
result = {
"color": "green",
"days_left": None,
"is_expired": False,
"lifetime": bool(row.get("lifetime")),
# Kein eigener Status: eine Lizenz kann aktiv sein *und* eine noch
# nicht abgeholte Revision haben.
"revision_pending": revision_pending(row),
}
if row.get("lifetime"):
return result
raw = row.get("expires_at")
if not raw:
return {**result, "color": "red"}
try:
expires = lic.parse_iso(str(raw))
except ValueError:
# Fail-safe: unlesbares Datum gilt als kritisch, nicht als in Ordnung.
return {**result, "color": "red"}
delta = expires - moment
days_left = int(delta.total_seconds() // 86400)
expired = delta.total_seconds() <= 0
if expired:
color = "red"
elif days_left < RED_BELOW_DAYS:
color = "red"
elif days_left <= GREEN_ABOVE_DAYS:
color = "orange"
else:
color = "green"
return {
**result,
"color": color,
"days_left": max(0, days_left),
"is_expired": expired,
"lifetime": False,
}
def dashboard_summary(conn: sqlite3.Connection) -> dict[str, Any]:
rows = list_licenses(conn)
buckets: dict[str, list[dict[str, Any]]] = {"red": [], "orange": [], "green": []}
for row in rows:
if str(row["status"]) in {"revoked", "deactivated"}:
continue
state = display_state(row)
entry = {**row, **state}
buckets[state["color"]].append(entry)
return {
"buckets": buckets,
"counts": {
"customers": int(
Database.value(conn, "SELECT COUNT(*) FROM license_customers", default=0) or 0
),
"tickets_open": int(
Database.value(
conn, "SELECT COUNT(*) FROM license_tickets WHERE status='open'", default=0
)
or 0
),
"tickets_blocked": int(
Database.value(
conn, "SELECT COUNT(*) FROM license_tickets WHERE status='blocked'", default=0
)
or 0
),
"licenses_active": int(
Database.value(
conn, "SELECT COUNT(*) FROM licenses WHERE status='active'", default=0
)
or 0
),
"licenses_issued": int(
Database.value(
conn, "SELECT COUNT(*) FROM licenses WHERE status='issued'", default=0
)
or 0
),
"licenses_revoked": int(
Database.value(
conn, "SELECT COUNT(*) FROM licenses WHERE status='revoked'", default=0
)
or 0
),
},
}
@@ -0,0 +1,354 @@
"""Uebernahme eines Lizenzservers der Vorgaengerfassung.
Die alten Lizenzdateien sind mit der neuen Fassung **nicht** verwendbar, und
das ist Absicht: dort wurde ohne Domaenentrennung signiert (die Signatur ueber
eine Lizenz war damit auch als Signatur ueber eine Protokollantwort gueltig),
es fehlten Format-Version, Sequenznummer und Gueltigkeitsbeginn. Beides
gleichzeitig zu akzeptieren hiesse, die Haertung wieder aufzugeben -- eine
Uebergangsfrist waere hier eine dauerhaft offene Tuer.
Was **sehr wohl** uebernommen wird, ist alles andere:
* **Der Signaturschluessel.** Damit bleibt die Anbieteridentitaet dieselbe. Bei
Kunden, die den Aussteller bereits gepinnt haben, passt der Schluessel
weiterhin -- sie muessen keinen Ausstellerwechsel bestaetigen.
* **Kunden, Tickets und Lizenzen** aus der alten Datenbank.
Jede uebernommene Lizenz wird im neuen Format **neu signiert**. Der Kunde
bekommt dadurch eine neue Lizenzdatei; das laesst sich nicht vermeiden, denn
die alte traegt eine Signatur, die es in dieser Form nicht mehr gibt. Der
Bericht am Ende sagt genau, wer eine neue Datei braucht.
Der Vorgang ist **lesend** gegenueber der alten Installation. Sie wird nicht
angefasst, nicht angehalten und nicht veraendert.
"""
from __future__ import annotations
import json
import sqlite3
from dataclasses import dataclass, field
from datetime import datetime, timezone
from pathlib import Path
from typing import Any
import tesm_licensing as lic
from tesm_core.db import Database
from tesm_core.keystore import Keystore
from . import issuance
class LegacyImportError(Exception):
"""Die alte Installation ist nicht lesbar oder passt nicht."""
@dataclass
class Report:
"""Was die Uebernahme getan hat -- vollstaendig und nachpruefbar."""
key_adopted: bool = False
key_id: str = ""
customers: int = 0
tickets: int = 0
licenses: int = 0
reissued: list[str] = field(default_factory=list)
skipped: list[str] = field(default_factory=list)
warnings: list[str] = field(default_factory=list)
def as_text(self) -> str:
lines = [
f"Signaturschluessel uebernommen: {'ja' if self.key_adopted else 'nein'}"
+ (f" ({self.key_id})" if self.key_id else ""),
f"Kunden : {self.customers}",
f"Tickets : {self.tickets}",
f"Lizenzen : {self.licenses}",
]
if self.reissued:
lines.append("")
lines.append("Neu signiert -- diese Kunden brauchen eine neue Lizenzdatei:")
lines.extend(f" * {entry}" for entry in self.reissued)
if self.skipped:
lines.append("")
lines.append("Uebersprungen:")
lines.extend(f" * {entry}" for entry in self.skipped)
if self.warnings:
lines.append("")
lines.append("Hinweise:")
lines.extend(f" * {entry}" for entry in self.warnings)
return "\n".join(lines)
def _now() -> str:
return datetime.now(timezone.utc).isoformat(timespec="seconds")
# --------------------------------------------------------------------------- #
# Quelle
# --------------------------------------------------------------------------- #
def open_legacy(root: Path) -> sqlite3.Connection:
"""Oeffnet die alte Datenbank **nur lesend**."""
path = Path(root) / "sqlite.db"
if not path.is_file():
raise LegacyImportError(f"Unter {path} liegt keine Datenbank der Vorgaengerfassung.")
try:
conn = sqlite3.connect(f"file:{path}?mode=ro", uri=True)
except sqlite3.Error as exc:
raise LegacyImportError(f"Die alte Datenbank ist nicht lesbar: {exc}") from exc
conn.row_factory = sqlite3.Row
tables = {row[0] for row in conn.execute("SELECT name FROM sqlite_master WHERE type='table'")}
missing = {"license_customers", "license_tickets", "licenses"} - tables
if missing:
conn.close()
raise LegacyImportError(
f"Das sieht nicht nach einem Lizenzserver aus -- es fehlen: {', '.join(sorted(missing))}"
)
return conn
def read_legacy_key(root: Path) -> tuple[str, str]:
"""Liest den alten Signaturschluessel. Rueckgabe: ``(privat, oeffentlich)``."""
path = Path(root) / "master_signing_key.json"
if not path.is_file():
raise LegacyImportError(f"Der alte Signaturschluessel {path} fehlt.")
try:
data = json.loads(path.read_text(encoding="utf-8"))
except (OSError, ValueError) as exc:
raise LegacyImportError(f"Der alte Signaturschluessel ist unlesbar: {exc}") from exc
# Die Vorgaengerfassung nannte die Felder "private"/"public".
private = str(data.get("private") or data.get("private_key") or "")
public = str(data.get("public") or data.get("public_key") or "")
if not private or not public:
raise LegacyImportError("Der alte Signaturschluessel ist unvollstaendig.")
if lic.public_from_private(private) != public:
raise LegacyImportError(
"Im alten Signaturschluessel passen privater und oeffentlicher Teil nicht "
"zueinander. Die Datei ist beschaedigt."
)
return private, public
def adopt_key(private: str, public: str, target: Path) -> str:
"""Uebernimmt den alten Schluessel in das Format der neuen Fassung.
Damit bleibt die Anbieteridentitaet erhalten: Kunden, die den Aussteller
schon gepinnt haben, sehen weiterhin denselben Schluessel und muessen
keinen Wechsel bestaetigen.
"""
from tesm_core.keystore import write_private_file
if Path(target).is_file():
raise LegacyImportError(
f"Unter {target} liegt bereits ein Signaturschluessel. Er wird nicht "
"ueberschrieben -- das wuerde jede damit ausgestellte Lizenz unbrauchbar "
"machen. Fuer eine Uebernahme bitte eine frische Installation verwenden."
)
payload = {
"private_key": private,
"public_key": public,
"key_id": lic.key_id(public),
"created_at": _now(),
"origin": "Uebernommen aus der Vorgaengerfassung.",
}
write_private_file(Path(target), json.dumps(payload, indent=2, sort_keys=True) + "\n")
return lic.key_id(public)
# --------------------------------------------------------------------------- #
# Uebernahme
# --------------------------------------------------------------------------- #
def _remaining_days(expires_at: str) -> int:
"""Restlaufzeit in Tagen, mindestens 1 bei noch gueltiger Lizenz.
Die neue Ausstellung rechnet in Tagen ab jetzt. Wer noch drei Monate
hatte, soll auch nach der Uebernahme drei Monate haben -- nicht ploetzlich
ein volles Jahr und nicht weniger als vorher.
"""
try:
moment = lic.parse_iso(expires_at)
except (ValueError, TypeError):
return 0
remaining = (moment - datetime.now(timezone.utc)).days
return remaining if remaining > 0 else 0
def _modules(raw: Any) -> list[str]:
try:
values = json.loads(str(raw or "[]"))
except ValueError:
return []
return sorted(set(str(value) for value in values) & set(lic.ALL_MODULES))
def import_all(
legacy: sqlite3.Connection,
conn: sqlite3.Connection,
*,
keystore: Keystore,
master_private_key: str,
master_public_key: str,
master_endpoint: str,
vendor: dict[str, Any],
actor: str = "migration",
reissue: bool = True,
) -> Report:
"""Uebernimmt Kunden, Tickets und Lizenzen. Erwartet eine offene Transaktion."""
report = Report()
# -- Kunden ------------------------------------------------------------ #
customer_map: dict[int, int] = {}
for row in legacy.execute("SELECT * FROM license_customers ORDER BY id"):
name = str(row["name"] or "").strip()
if not name:
report.skipped.append(f"Kunde ohne Namen (alte Nummer {row['id']})")
continue
existing = Database.one(
conn, "SELECT id FROM license_customers WHERE name=? COLLATE NOCASE", (name,)
)
if existing is not None:
customer_map[int(row["id"])] = int(existing["id"])
report.warnings.append(f"Kunde {name!r} war schon vorhanden -- nicht doppelt angelegt.")
continue
new_id = issuance.create_customer(
conn,
actor=actor,
name=name,
contact_email=str(row["contact_email"] or ""),
contact_phone=str(row["contact_phone"] or ""),
contact_person=str(row["contact_person"] or ""),
street=str(row["street"] or ""),
house_number=str(row["house_number"] or ""),
postal_code=str(row["postal_code"] or ""),
city=str(row["city"] or ""),
notes=str(row["notes"] or ""),
)
customer_map[int(row["id"])] = new_id
report.customers += 1
# -- Tickets ----------------------------------------------------------- #
for row in legacy.execute("SELECT * FROM license_tickets ORDER BY created_at"):
customer_id = customer_map.get(int(row["customer_id"] or 0))
if customer_id is None:
report.skipped.append(f"Ticket {str(row['id'])[:12]} -- Kunde nicht uebernommen")
continue
if Database.one(conn, "SELECT id FROM license_tickets WHERE id=?", (row["id"],)):
continue
conn.execute(
"INSERT INTO license_tickets (id, customer_id, type, modules, valid_days, lifetime, "
"status, role, notes, created_by, created_at, updated_at) "
"VALUES (?,?,?,?,?,?,?,?,?,?,?,?)",
(
str(row["id"]),
customer_id,
str(row["type"] or "standard"),
json.dumps(_modules(row["modules"])),
int(row["valid_days"] or 365),
int(row["lifetime"] or 0),
str(row["status"] or "open"),
str(row["role"] or "customer"),
"Aus der Vorgaengerfassung uebernommen.",
str(row["created_by"] or actor),
str(row["created_at"] or _now()),
_now(),
),
)
report.tickets += 1
# -- Lizenzen ---------------------------------------------------------- #
for row in legacy.execute("SELECT * FROM licenses ORDER BY created_at"):
license_id = str(row["license_id"] or "")
customer_id = customer_map.get(int(row["customer_id"] or 0))
if not license_id or customer_id is None:
report.skipped.append(f"Lizenz {license_id[:12] or '?'} -- Kunde nicht uebernommen")
continue
if Database.one(conn, "SELECT id FROM licenses WHERE license_id=?", (license_id,)):
continue
customer = issuance.get_customer(conn, customer_id)
status = str(row["status"] or "issued")
modules = _modules(row["modules"])
if not reissue:
report.skipped.append(f"{customer['name']} / {license_id[:12]} -- nur gezaehlt")
report.licenses += 1
continue
# Neu ausstellen: gleiche Kennung, gleiche Eckdaten, neues Format und
# ein frisches Clientschluesselpaar. Das alte Paar gehoert zu einer
# Signatur, die es nicht mehr gibt.
lifetime = bool(row["lifetime"])
valid_days = _remaining_days(str(row["expires_at"] or ""))
if not lifetime and valid_days <= 0:
report.skipped.append(
f"{customer['name']} / {license_id[:12]} -- abgelaufen am "
f"{str(row['expires_at'])[:10]}, nicht uebernommen"
)
continue
bundle, license_public = lic.issue_license(
customer={
"name": str(customer["name"]),
"contact_email": str(customer["contact_email"] or ""),
"customer_ref": str(customer["id"]),
},
license_type=str(row["type"] or "standard"),
modules=modules,
valid_days=valid_days,
master_private_key_b64=master_private_key,
master_public_key_b64=master_public_key,
master_endpoint=master_endpoint,
vendor=vendor,
license_id=license_id,
role=str(row["role"] or "customer"),
lifetime=lifetime,
)
document = bundle["license"]
conn.execute(
"INSERT INTO licenses (license_id, ticket_id, customer_id, type, role, modules, "
"lifetime, seq, issued_at, expires_at, license_pubkey, license_document, "
"client_key_encrypted, status, created_by, created_at) "
"VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)",
(
license_id,
str(row["ticket_id"] or ""),
customer_id,
str(row["type"] or "standard"),
str(row["role"] or "customer"),
json.dumps(modules),
int(lifetime),
int(document["seq"]),
document["issued_at"],
document["expires_at"],
license_public,
json.dumps(document),
keystore.encrypt(
str(bundle["client_key"]), aad=issuance.client_key_aad(license_id)
),
# Kein Zustand, der eine Maschine nennt: die alte Bindung gilt
# fuer die alte Datei, nicht fuer diese.
"revoked" if status == "revoked" else "issued",
str(row["created_by"] or actor),
str(row["created_at"] or _now()),
),
)
report.licenses += 1
if status != "revoked":
report.reissued.append(
f"{customer['name']} -- {license_id} "
f"({'aktiv gewesen' if status == 'active' else status})"
)
if report.reissued:
report.warnings.append(
"Die neu signierten Lizenzen sind noch nicht aktiviert: die Bindung an eine "
"Maschine gilt nur fuer die alte Datei. Nach dem Einspielen aktiviert der "
"Kunde erneut."
)
return report
@@ -0,0 +1,213 @@
"""E-Mail-Versand ueber die Microsoft-Graph-API (App-only).
Kein SMTP und keine zusaetzliche Abhaengigkeit: der Client-Credentials-Flow ist
ein Token-Abruf und ein POST -- das geht mit ``urllib`` aus der
Standardbibliothek. Eine Bibliothek wie ``msal`` waere hier reiner Ballast.
Das Client-Secret liegt AES-GCM-verschluesselt in ``service_accounts``, an den
Zweck gebunden. Der Vorgaenger hielt es unverschluesselt in der
``settings``-Tabelle.
"""
from __future__ import annotations
import base64
import json
import logging
import sqlite3
import time
import urllib.error
import urllib.parse
import urllib.request
from dataclasses import dataclass
from datetime import datetime, timezone
from typing import Any
from tesm_core.db import Database
from tesm_core.extension import core
from tesm_core.settings_store import SettingSpec
logger = logging.getLogger(__name__)
SERVICE_PURPOSE = "graph_client_secret"
AAD_GRAPH_SECRET = "service_accounts.graph_client_secret"
TOKEN_URL = "https://login.microsoftonline.com/{tenant}/oauth2/v2.0/token"
SEND_URL = "https://graph.microsoft.com/v1.0/users/{sender}/sendMail"
SCOPE = "https://graph.microsoft.com/.default"
SETTINGS: tuple[SettingSpec, ...] = (
SettingSpec("graph_enabled", False, "bool", "E-Mail-Versand aktiv", section="mail"),
SettingSpec("graph_tenant_id", "", "str", "Verzeichnis-ID (Tenant)", section="mail"),
SettingSpec("graph_client_id", "", "str", "Anwendungs-ID (Client)", section="mail"),
SettingSpec("graph_sender", "", "str", "Absender-Postfach", section="mail"),
SettingSpec(
"graph_reply_to", "", "str", "Antwortadresse (optional)", section="mail"
),
)
_token_cache: dict[str, tuple[str, float]] = {}
class MailError(Exception):
"""Versandfehler mit Klartextmeldung."""
@dataclass(frozen=True, slots=True)
class Attachment:
name: str
content: bytes
content_type: str = "application/json"
def store_secret(conn: sqlite3.Connection, secret: str | None) -> None:
existing = Database.one(
conn, "SELECT password_encrypted FROM service_accounts WHERE purpose=?", (SERVICE_PURPOSE,)
)
encrypted = str(existing.get("password_encrypted") or "") if existing else ""
if secret:
encrypted = core().keystore.encrypt(secret, aad=AAD_GRAPH_SECRET)
conn.execute(
"INSERT INTO service_accounts (purpose, username, password_encrypted, updated_at) "
"VALUES (?,'',?,?) ON CONFLICT(purpose) DO UPDATE SET "
"password_encrypted=excluded.password_encrypted, updated_at=excluded.updated_at",
(SERVICE_PURPOSE, encrypted, datetime.now(timezone.utc).isoformat(timespec="seconds")),
)
def has_secret(conn: sqlite3.Connection) -> bool:
value = Database.value(
conn, "SELECT password_encrypted FROM service_accounts WHERE purpose=?", (SERVICE_PURPOSE,)
)
return bool(value)
def _secret(conn: sqlite3.Connection) -> str:
value = Database.value(
conn, "SELECT password_encrypted FROM service_accounts WHERE purpose=?", (SERVICE_PURPOSE,)
)
if not value:
raise MailError("Es ist kein Client-Secret hinterlegt.")
try:
return core().keystore.decrypt(str(value), aad=AAD_GRAPH_SECRET)
except Exception as exc: # noqa: BLE001
raise MailError(
"Das Client-Secret konnte nicht entschluesselt werden -- bitte neu eintragen."
) from exc
def _config(conn: sqlite3.Connection) -> dict[str, Any]:
return core().settings.get_many(conn, [spec.key for spec in SETTINGS])
def is_enabled(conn: sqlite3.Connection) -> bool:
config = _config(conn)
return bool(
config.get("graph_enabled")
and config.get("graph_tenant_id")
and config.get("graph_client_id")
and config.get("graph_sender")
and has_secret(conn)
)
def _post(url: str, data: bytes, headers: dict[str, str], *, timeout: int = 20) -> bytes:
request = urllib.request.Request(url, data=data, headers=headers, method="POST") # noqa: S310
try:
with urllib.request.urlopen(request, timeout=timeout) as response: # noqa: S310
return response.read()
except urllib.error.HTTPError as exc:
body = exc.read().decode("utf-8", errors="replace")
detail = body
try:
parsed = json.loads(body)
detail = str(parsed.get("error", {}).get("message") or parsed.get("error_description") or body)
except ValueError:
pass
raise MailError(f"Graph antwortete mit HTTP {exc.code}: {detail[:400]}") from exc
except urllib.error.URLError as exc:
raise MailError(f"Graph ist nicht erreichbar: {exc.reason}") from exc
def fetch_token(conn: sqlite3.Connection) -> str:
config = _config(conn)
tenant = str(config["graph_tenant_id"])
client_id = str(config["graph_client_id"])
cache_key = f"{tenant}:{client_id}"
cached = _token_cache.get(cache_key)
if cached and cached[1] > time.time() + 60:
return cached[0]
payload = urllib.parse.urlencode(
{
"client_id": client_id,
"client_secret": _secret(conn),
"scope": SCOPE,
"grant_type": "client_credentials",
}
).encode("utf-8")
raw = _post(
TOKEN_URL.format(tenant=urllib.parse.quote(tenant)),
payload,
{"Content-Type": "application/x-www-form-urlencoded"},
)
data = json.loads(raw.decode("utf-8"))
token = str(data.get("access_token") or "")
if not token:
raise MailError("Graph hat kein Zugriffstoken geliefert.")
_token_cache[cache_key] = (token, time.time() + int(data.get("expires_in") or 3000))
return token
def test_connection(conn: sqlite3.Connection) -> dict[str, Any]:
"""Holt nur ein Token -- verschickt bewusst keine echte Nachricht."""
if not is_enabled(conn):
return {"ok": False, "message": "Der E-Mail-Versand ist nicht vollstaendig konfiguriert."}
try:
fetch_token(conn)
except MailError as exc:
return {"ok": False, "message": str(exc)}
return {"ok": True, "message": "Anmeldung an Microsoft Graph erfolgreich."}
def send(
conn: sqlite3.Connection,
*,
to: str,
subject: str,
body: str,
attachment: Attachment | None = None,
) -> None:
if not is_enabled(conn):
raise MailError("Der E-Mail-Versand ist nicht konfiguriert.")
config = _config(conn)
message: dict[str, Any] = {
"message": {
"subject": subject,
# Reiner Text: kein HTML, damit nichts aus Kundendaten interpretiert wird.
"body": {"contentType": "Text", "content": body},
"toRecipients": [{"emailAddress": {"address": to}}],
},
"saveToSentItems": True,
}
reply_to = str(config.get("graph_reply_to") or "")
if reply_to:
message["message"]["replyTo"] = [{"emailAddress": {"address": reply_to}}]
if attachment is not None:
message["message"]["attachments"] = [
{
"@odata.type": "#microsoft.graph.fileAttachment",
"name": attachment.name,
"contentType": attachment.content_type,
"contentBytes": base64.b64encode(attachment.content).decode("ascii"),
}
]
token = fetch_token(conn)
_post(
SEND_URL.format(sender=urllib.parse.quote(str(config["graph_sender"]))),
json.dumps(message).encode("utf-8"),
{"Authorization": f"Bearer {token}", "Content-Type": "application/json"},
timeout=30,
)
logger.info("E-Mail an %s versendet: %s", to, subject)
@@ -0,0 +1,112 @@
"""Signaturschluessel des Lizenzservers.
Ein Ed25519-Schluesselpaar pro Installation. Der private Teil verlaesst diese
Maschine nie und wird fuer genau zwei Dinge benutzt:
1. Lizenzdateien signieren,
2. Antworten auf Aktivierungs-/Deaktivierungs-/Heartbeat-Anfragen signieren.
Der oeffentliche Teil reist in jeder ausgestellten Lizenz mit -- damit kann der
Client Antworten vollstaendig offline pruefen, ohne je einen Schluessel abholen
zu muessen.
Die Datei liegt mit Modus 0600 und ist aus dem Deployment-Abgleich
ausgeschlossen: ein Update darf sie nie ueberschreiben. Ginge sie verloren,
waere **jede** ausgestellte Lizenz nicht mehr verifizierbar.
"""
from __future__ import annotations
import json
import logging
from dataclasses import dataclass
from datetime import datetime, timezone
from pathlib import Path
import tesm_licensing as lic
from tesm_core.keystore import check_private_file, write_private_file
logger = logging.getLogger(__name__)
class MasterKeyError(Exception):
"""Schluessel fehlt oder ist unlesbar."""
@dataclass(frozen=True, slots=True)
class MasterKey:
private_key: str
public_key: str
created_at: str
@property
def key_id(self) -> str:
return lic.key_id(self.public_key)
def load(path: Path) -> MasterKey:
if not path.is_file():
raise MasterKeyError(
f"Der Signaturschluessel {path} fehlt. Ohne ihn kann keine Lizenz ausgestellt "
"werden. Erzeugen Sie ihn mit 'tesm-license-admin init-key'."
)
try:
data = json.loads(path.read_text(encoding="utf-8"))
except (OSError, ValueError) as exc:
raise MasterKeyError(f"Der Signaturschluessel ist unlesbar: {exc}") from exc
private = str(data.get("private_key") or "")
public = str(data.get("public_key") or "")
if not private or not public:
raise MasterKeyError("Der Signaturschluessel ist unvollstaendig.")
if lic.public_from_private(private) != public:
raise MasterKeyError(
"Der oeffentliche Teil des Signaturschluessels passt nicht zum privaten. "
"Die Datei ist beschaedigt oder wurde manipuliert."
)
return MasterKey(
private_key=private, public_key=public, created_at=str(data.get("created_at") or "")
)
def ensure(path: Path) -> MasterKey:
"""Erzeugt den Schluessel beim ersten Aufruf, laedt ihn danach nur noch.
**Niemals** neu erzeugen, wenn die Datei existiert -- das wuerde alle
bestehenden Lizenzen unbrauchbar machen.
"""
if path.is_file():
return load(path)
private, public = lic.generate_keypair()
payload = {
"private_key": private,
"public_key": public,
"key_id": lic.key_id(public),
"created_at": datetime.now(timezone.utc).isoformat(timespec="seconds"),
}
write_private_file(path, json.dumps(payload, indent=2, sort_keys=True) + "\n")
logger.warning(
"Neuer Signaturschluessel erzeugt (%s). Diese Datei ist unwiederbringlich -- "
"bitte sofort gesichert ablegen: %s",
lic.key_id(public),
path,
)
return load(path)
def health(path: Path) -> dict[str, object]:
"""Zustand fuer die Diagnoseseite."""
if not path.is_file():
return {"present": False, "problems": ["Signaturschluessel fehlt"]}
problems = check_private_file(path)
try:
key = load(path)
except MasterKeyError as exc:
return {"present": True, "problems": [*problems, str(exc)]}
return {
"present": True,
"key_id": key.key_id,
"public_key": key.public_key,
"created_at": key.created_at,
"problems": problems,
}
@@ -0,0 +1,64 @@
"""Laufendes Protokoll des Lizenzservers.
Anders als TESM hat der Lizenzserver keinen Hintergrundprozess, der Telemetrie
erzeugt -- es gibt keine Geraete zu pingen. Damit die Ansicht "Laufend"
trotzdem Inhalt hat, spiegelt sie hierher denselben Vorgangsstrom, der auch im
Aenderungsprotokoll landet: Ausstellung, Aktivierung, Widerruf, Heartbeat,
Mailversand.
Das ist ausdruecklich kein zweites Protokollkonzept, sondern eine
menschenlesbare Fortschreibung -- die belastbare Quelle bleibt ``audit_log``
mit seiner Hash-Kette.
"""
from __future__ import annotations
import logging
import threading
from datetime import datetime
from pathlib import Path
logger = logging.getLogger(__name__)
class LiveLog:
def __init__(self, path: Path) -> None:
self.path = Path(path)
self._lock = threading.Lock()
def write(self, level: str, scope: str, message: str) -> None:
line = (
f"{datetime.now().strftime('%Y-%m-%d %H:%M:%S')} {level:<7} {scope:<12} {message}\n"
)
try:
self.path.parent.mkdir(parents=True, exist_ok=True)
with self._lock, self.path.open("a", encoding="utf-8", newline="\n") as stream:
stream.write(line)
except OSError as exc: # pragma: no cover
logger.warning("Konnte nicht ins laufende Protokoll schreiben: %s", exc)
def info(self, scope: str, message: str) -> None:
self.write("INFO", scope, message)
def warning(self, scope: str, message: str) -> None:
self.write("WARNUNG", scope, message)
def error(self, scope: str, message: str) -> None:
self.write("FEHLER", scope, message)
def tail(self, lines: int = 400, *, needle: str = "") -> list[str]:
if not self.path.is_file():
return []
try:
size = self.path.stat().st_size
block = min(size, max(8192, lines * 200))
with self.path.open("rb") as stream:
stream.seek(max(0, size - block))
content = stream.read().decode("utf-8", errors="replace")
except OSError:
return []
result = content.splitlines()
if needle:
lowered = needle.lower()
result = [line for line in result if lowered in line.lower()]
return result[-lines:]
@@ -0,0 +1,274 @@
"""Sub-Lizenzserver-Berichte: eigenen Bestand melden, fremden aufnehmen.
Ein Lizenzserver, der selbst mit einer ``role="license_server"``-Lizenz laeuft,
meldet seinen kompletten Kunden-/Ticket-/Lizenzbestand an seinen Master --
huckepack auf dem bestehenden Heartbeat, zusaetzlich sofort nach jeder
Aenderung.
**Vollstaendiger Schnappschuss, kein Delta.** Jeder Bericht ersetzt alle Zeilen
seiner Quelle. Das ist bei diesen Datenmengen deutlich einfacher korrekt zu
halten: ein beim Sub-Lizenzserver geloeschtes Ticket verschwindet automatisch
auch beim Master, ohne dass je ein "geloescht"-Ereignis uebertragen werden muss.
"""
from __future__ import annotations
import json
import logging
import sqlite3
import threading
from datetime import datetime, timezone
from typing import Any
import tesm_licensing as lic
from tesm_core.db import Database
logger = logging.getLogger(__name__)
MAX_ROWS = lic.MAX_REPORT_ROWS
def _now() -> str:
return datetime.now(timezone.utc).isoformat(timespec="seconds")
# --------------------------------------------------------------------------- #
# Eigenen Bestand melden (nur als Sub-Lizenzserver relevant)
# --------------------------------------------------------------------------- #
def build_own_report(conn: sqlite3.Connection) -> dict[str, Any]:
"""Baut den Bericht ueber den eigenen Bestand.
Die eigene Bootstrap-Lizenz ist nicht Teil davon -- sie steht in
``license.json``, nicht in diesen Tabellen.
"""
customers = Database.all(
conn,
"SELECT id, name, contact_email, contact_person, city FROM license_customers "
"ORDER BY id LIMIT ?",
(MAX_ROWS,),
)
tickets = Database.all(
conn,
"SELECT id, customer_id, type, modules, valid_days, lifetime, status, role, created_at "
"FROM license_tickets ORDER BY created_at LIMIT ?",
(MAX_ROWS,),
)
licenses = Database.all(
conn,
"SELECT license_id, ticket_id, type, modules, lifetime, issued_at, expires_at, status, "
"fingerprint, hostname, activated_at, last_heartbeat_at FROM licenses "
"ORDER BY created_at LIMIT ?",
(MAX_ROWS,),
)
return {
"customers": [
{
"ref": str(row["id"]),
"name": str(row["name"]),
"contact_email": str(row["contact_email"] or ""),
"contact_person": str(row["contact_person"] or ""),
"city": str(row["city"] or ""),
}
for row in customers
],
"tickets": [
{
"ref": str(row["id"]),
"customer_ref": str(row["customer_id"]),
"type": str(row["type"]),
"modules": json.loads(str(row["modules"] or "[]")),
"valid_days": int(row["valid_days"] or 0),
"lifetime": bool(row["lifetime"]),
"status": str(row["status"]),
"role": str(row["role"]),
"created_at": str(row["created_at"] or ""),
}
for row in tickets
],
"licenses": [
{
"ref": str(row["license_id"]),
"ticket_ref": str(row["ticket_id"] or ""),
"type": str(row["type"]),
"modules": json.loads(str(row["modules"] or "[]")),
"lifetime": bool(row["lifetime"]),
"issued_at": str(row["issued_at"] or ""),
"expires_at": str(row["expires_at"] or ""),
"status": str(row["status"]),
"fingerprint": str(row["fingerprint"] or ""),
"hostname": str(row["hostname"] or ""),
"activated_at": str(row["activated_at"] or ""),
"last_heartbeat_at": str(row["last_heartbeat_at"] or ""),
}
for row in licenses
],
}
def report_soon(extension: Any) -> None:
"""Meldet den eigenen Bestand im Hintergrund an den Master.
Best effort: schlaegt der Versand fehl, bleibt es beim regulaeren Heartbeat.
Der Master ist damit hoechstens sechs Stunden hinterher, nie inkonsistent.
"""
manager = extension.license
if manager is None or manager.state.role != "license_server":
return
def worker() -> None:
try:
manager.run_heartbeat()
except Exception as exc: # noqa: BLE001
logger.info("Sofortmeldung an den Master fehlgeschlagen: %s", exc)
threading.Thread(target=worker, name="report-to-master", daemon=True).start()
# --------------------------------------------------------------------------- #
# Fremden Bestand aufnehmen (Master-Seite)
# --------------------------------------------------------------------------- #
def ingest_report(
conn: sqlite3.Connection, source_license_id: str, report: dict[str, Any]
) -> dict[str, int]:
"""Uebernimmt einen Bericht. Ersetzt alle bisherigen Zeilen dieser Quelle."""
counts = {"customers": 0, "tickets": 0, "licenses": 0}
stamp = _now()
for table in ("reported_customers", "reported_tickets", "reported_licenses"):
conn.execute(f"DELETE FROM {table} WHERE source_license_id=?", (source_license_id,))
for entry in (report.get("customers") or [])[:MAX_ROWS]:
conn.execute(
"INSERT INTO reported_customers (source_license_id, remote_ref, name, contact_email, "
"contact_person, city, last_reported_at) VALUES (?,?,?,?,?,?,?)",
(
source_license_id,
str(entry.get("ref") or ""),
str(entry.get("name") or ""),
str(entry.get("contact_email") or ""),
str(entry.get("contact_person") or ""),
str(entry.get("city") or ""),
stamp,
),
)
counts["customers"] += 1
for entry in (report.get("tickets") or [])[:MAX_ROWS]:
conn.execute(
"INSERT INTO reported_tickets (source_license_id, remote_ref, customer_ref, type, "
"modules, valid_days, lifetime, status, role, created_at, last_reported_at) "
"VALUES (?,?,?,?,?,?,?,?,?,?,?)",
(
source_license_id,
str(entry.get("ref") or ""),
str(entry.get("customer_ref") or ""),
str(entry.get("type") or ""),
json.dumps(entry.get("modules") or []),
int(entry.get("valid_days") or 0),
int(bool(entry.get("lifetime"))),
str(entry.get("status") or "open"),
str(entry.get("role") or "customer"),
str(entry.get("created_at") or ""),
stamp,
),
)
counts["tickets"] += 1
for entry in (report.get("licenses") or [])[:MAX_ROWS]:
conn.execute(
"INSERT INTO reported_licenses (source_license_id, remote_ref, ticket_ref, type, "
"modules, lifetime, issued_at, expires_at, status, fingerprint, hostname, "
"activated_at, last_heartbeat_at, last_reported_at) "
"VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?)",
(
source_license_id,
str(entry.get("ref") or ""),
str(entry.get("ticket_ref") or ""),
str(entry.get("type") or ""),
json.dumps(entry.get("modules") or []),
int(bool(entry.get("lifetime"))),
str(entry.get("issued_at") or ""),
str(entry.get("expires_at") or ""),
str(entry.get("status") or ""),
str(entry.get("fingerprint") or ""),
str(entry.get("hostname") or ""),
str(entry.get("activated_at") or ""),
str(entry.get("last_heartbeat_at") or ""),
stamp,
),
)
counts["licenses"] += 1
return counts
def reported_for_customer(conn: sqlite3.Connection, customer_id: int) -> dict[str, Any]:
"""Gemeldeter Bestand eines Kunden, der selbst Lizenzserver ist."""
source = Database.value(
conn,
"SELECT license_id FROM licenses WHERE customer_id=? AND role='license_server' "
"AND status='active' LIMIT 1",
(customer_id,),
)
if source is None:
return {"source_license_id": "", "customers": [], "last_reported_at": ""}
customers = Database.all(
conn,
"SELECT * FROM reported_customers WHERE source_license_id=? ORDER BY name COLLATE NOCASE",
(str(source),),
)
tickets = Database.all(
conn, "SELECT * FROM reported_tickets WHERE source_license_id=?", (str(source),)
)
licenses = Database.all(
conn, "SELECT * FROM reported_licenses WHERE source_license_id=?", (str(source),)
)
from .issuance import display_state
tickets_by_customer: dict[str, list[dict[str, Any]]] = {}
for ticket in tickets:
tickets_by_customer.setdefault(str(ticket["customer_ref"]), []).append(dict(ticket))
licenses_by_ticket: dict[str, list[dict[str, Any]]] = {}
for entry in licenses:
licenses_by_ticket.setdefault(str(entry["ticket_ref"]), []).append(
{**dict(entry), **display_state(dict(entry))}
)
result = []
for customer in customers:
own_tickets = tickets_by_customer.get(str(customer["remote_ref"]), [])
for ticket in own_tickets:
ticket["licenses"] = licenses_by_ticket.get(str(ticket["remote_ref"]), [])
result.append({**dict(customer), "tickets": own_tickets})
return {
"source_license_id": str(source),
"customers": result,
"last_reported_at": max(
[str(row["last_reported_at"]) for row in customers] or [""],
),
}
def reporting_sources(conn: sqlite3.Connection) -> list[dict[str, Any]]:
"""Alle Sub-Lizenzserver, die schon einmal berichtet haben."""
return Database.all(
conn,
"""
SELECT l.license_id, l.hostname, l.last_heartbeat_at, c.name AS customer_name, c.id AS customer_id,
(SELECT COUNT(*) FROM reported_customers r WHERE r.source_license_id = l.license_id) AS customers,
(SELECT COUNT(*) FROM reported_tickets r WHERE r.source_license_id = l.license_id) AS tickets,
(SELECT COUNT(*) FROM reported_licenses r WHERE r.source_license_id = l.license_id) AS licenses,
(SELECT MAX(r.last_reported_at) FROM reported_customers r
WHERE r.source_license_id = l.license_id) AS last_reported_at
FROM licenses l JOIN license_customers c ON c.id = l.customer_id
WHERE l.role='license_server'
ORDER BY c.name COLLATE NOCASE
""",
)
@@ -0,0 +1,420 @@
"""Sicherung des Lizenzservers: Kunden, Tickets, Lizenzen, Konten, Einstellungen.
Der verschluesselte Umschlag liegt im Kern (``tesm_core.transfer``). Hier steht,
was gesichert wird -- und was bewusst nicht.
Bewusst **nicht** in der Sicherung:
* Der Signaturschluessel (``master_signing_key.json``). Er ist der Kern des
Vertrauens und gehoert in eine getrennte, bewusst verwahrte Sicherung -- nicht
in dieselbe Datei wie die Kundendaten.
* Verbrauchte Protokoll-Nonces und Portal-Token: reiner Laufzeitzustand.
* Der eigene Lizenz- und Aktivierungszustand: er gehoert zur Maschine.
Die **verschluesselten Client-Schluessel** der ausgestellten Lizenzen sind
enthalten -- allerdings weiterhin verschluesselt mit dem Datenschluessel dieser
Installation. Eine Sicherung ohne ``data.keys`` gibt sie also nicht her; das ist
gewollt und in der Oberflaeche vermerkt.
"""
from __future__ import annotations
import json
import sqlite3
from typing import Any, Callable, Iterable
from tesm_core.db import Database
from tesm_core.transfer import (
MIN_PASSPHRASE_LENGTH,
PREVIEW_TTL_MINUTES,
Category,
TransferError,
drop_preview,
load_preview,
seal as _seal,
store_preview,
summarize as _summarize,
unseal as _unseal,
)
APP_TAG = "tesm-license"
CATEGORIES: tuple[Category, ...] = (
Category("customers", "Kunden", "Stammdaten der Kundeninstanzen"),
Category("tickets", "Tickets", "Anspruechte inklusive Self-Service-Schluessel", sensitive=True),
Category(
"licenses",
"Lizenzen",
"Ausgestellte Lizenzen mit signiertem Dokument und verschluesseltem Client-Schluessel",
sensitive=True,
),
Category("users", "Benutzer", "Lokale Konten mit Passwort-Hash", admin_only=True),
Category("groups", "Gruppen und Rechte", "Rechtematrix", admin_only=True),
Category("settings", "Einstellungen", "Anbieterdaten, Portal, Mailversand", admin_only=True),
)
CATEGORY_BY_KEY = {category.key: category for category in CATEGORIES}
def seal(payload: dict[str, Any], passphrase: str) -> str:
return _seal(payload, passphrase, app_tag=APP_TAG)
def unseal(raw: bytes | str, passphrase: str) -> tuple[dict[str, Any], dict[str, Any]]:
return _unseal(raw, passphrase, app_tag=APP_TAG)
def summarize(payload: dict[str, Any]) -> list[dict[str, Any]]:
return _summarize(payload, CATEGORIES)
# --------------------------------------------------------------------------- #
# Export
# --------------------------------------------------------------------------- #
def build_export(conn: sqlite3.Connection, *, categories: Iterable[str]) -> dict[str, Any]:
wanted = {key for key in categories if key in CATEGORY_BY_KEY}
payload: dict[str, Any] = {}
if "customers" in wanted:
payload["customers"] = Database.all(
conn,
"SELECT id, name, contact_email, contact_phone, contact_person, street, "
"house_number, postal_code, city, notes, created_at FROM license_customers",
)
if "tickets" in wanted:
payload["tickets"] = Database.all(
conn,
"SELECT id, customer_id, type, modules, valid_days, lifetime, status, role, notes, "
"created_by, created_at FROM license_tickets",
)
if "licenses" in wanted:
payload["licenses"] = Database.all(
conn,
"SELECT license_id, ticket_id, customer_id, type, role, modules, lifetime, seq, "
"issued_at, expires_at, license_pubkey, license_document, client_key_encrypted, "
"status, fingerprint, hostname, activated_at, deactivated_at, last_heartbeat_at, "
"revoked_at, revoke_reason, created_by, created_at FROM licenses",
)
if "users" in wanted:
payload["users"] = Database.all(
conn,
"SELECT u.username, u.password_hash, u.is_admin, u.is_locked, u.first_name, "
"u.last_name, u.email, u.must_change_password, "
"(SELECT group_concat(g.name, char(10)) FROM user_groups ug "
" JOIN groups g ON g.id = ug.group_id WHERE ug.user_id = u.id) AS group_names "
"FROM users u WHERE u.deleted_at IS NULL AND u.auth_source='local'",
)
if "groups" in wanted:
payload["groups"] = [
{
"name": row["name"],
"description": row["description"],
"is_default": row["is_default"],
"is_system": row["is_system"],
"preset_key": row["preset_key"],
"permissions": [
str(entry["permission"])
for entry in Database.all(
conn,
"SELECT permission FROM group_permissions WHERE group_id=? "
"ORDER BY permission",
(row["id"],),
)
],
}
for row in Database.all(conn, "SELECT * FROM groups WHERE deleted_at IS NULL")
]
if "settings" in wanted:
payload["settings"] = {
str(row["key"]): str(row["value"])
for row in Database.all(conn, "SELECT key, value FROM settings")
if not str(row["key"]).startswith("license_")
}
return payload
# --------------------------------------------------------------------------- #
# Uebernahme
# --------------------------------------------------------------------------- #
def apply_import(
conn: sqlite3.Connection,
payload: dict[str, Any],
*,
categories: Iterable[str],
set_setting: Callable[[str, Any], None],
) -> dict[str, dict[str, int]]:
"""Uebernimmt ausgewaehlte Kategorien.
Abgleich nach natuerlichem Schluessel. Kunden werden ueber ihren Namen
zusammengefuehrt; Tickets und Lizenzen ueber ihre ID, weil die zugleich
kryptographisch relevant ist (Self-Service-Link bzw. signierte Lizenz-ID).
"""
wanted = {key for key in categories if key in CATEGORY_BY_KEY}
report: dict[str, dict[str, int]] = {}
stamp = _stamp()
def bump(category: str, field: str) -> None:
report.setdefault(category, {"created": 0, "updated": 0, "skipped": 0})[field] += 1
customer_map: dict[str, int] = {}
if "customers" in wanted:
for entry in payload.get("customers", []):
name = str(entry.get("name") or "").strip()
if not name:
continue
existing = Database.one(
conn,
"SELECT id FROM license_customers WHERE name=? COLLATE NOCASE",
(name,),
)
columns = (
str(entry.get("contact_email") or ""),
str(entry.get("contact_phone") or ""),
str(entry.get("contact_person") or ""),
str(entry.get("street") or ""),
str(entry.get("house_number") or ""),
str(entry.get("postal_code") or ""),
str(entry.get("city") or ""),
str(entry.get("notes") or ""),
)
if existing is None:
cursor = conn.execute(
"INSERT INTO license_customers (name, contact_email, contact_phone, "
"contact_person, street, house_number, postal_code, city, notes, "
"created_at, updated_at, created_by) VALUES (?,?,?,?,?,?,?,?,?,?,?,'import')",
(name, *columns, stamp, stamp),
)
customer_id = int(cursor.lastrowid or 0)
bump("customers", "created")
else:
customer_id = int(existing["id"])
conn.execute(
"UPDATE license_customers SET contact_email=?, contact_phone=?, "
"contact_person=?, street=?, house_number=?, postal_code=?, city=?, notes=?, "
"updated_at=? WHERE id=?",
(*columns, stamp, customer_id),
)
bump("customers", "updated")
customer_map[str(entry.get("id") or "")] = customer_id
def resolve_customer(reference: Any) -> int | None:
mapped = customer_map.get(str(reference or ""))
if mapped:
return mapped
# Ohne mitgesicherte Kundenliste kann ein Ticket nicht zugeordnet werden.
value = Database.value(
conn, "SELECT id FROM license_customers WHERE id=?", (reference,)
)
return int(value) if value is not None else None
if "tickets" in wanted:
for entry in payload.get("tickets", []):
ticket_id = str(entry.get("id") or "")
customer_id = resolve_customer(entry.get("customer_id"))
if not ticket_id or customer_id is None:
bump("tickets", "skipped")
continue
existing = Database.one(
conn, "SELECT id FROM license_tickets WHERE id=?", (ticket_id,)
)
values = (
customer_id,
str(entry.get("type") or "standard"),
json.dumps(entry.get("modules") if isinstance(entry.get("modules"), list)
else json.loads(str(entry.get("modules") or "[]"))),
int(entry.get("valid_days") or 365),
int(bool(entry.get("lifetime"))),
str(entry.get("status") or "open"),
str(entry.get("role") or "customer"),
str(entry.get("notes") or ""),
)
if existing is None:
conn.execute(
"INSERT INTO license_tickets (id, customer_id, type, modules, valid_days, "
"lifetime, status, role, notes, created_by, created_at, updated_at) "
"VALUES (?,?,?,?,?,?,?,?,?,'import',?,?)",
(ticket_id, *values, stamp, stamp),
)
bump("tickets", "created")
else:
conn.execute(
"UPDATE license_tickets SET customer_id=?, type=?, modules=?, valid_days=?, "
"lifetime=?, status=?, role=?, notes=?, updated_at=? WHERE id=?",
(*values, stamp, ticket_id),
)
bump("tickets", "updated")
if "licenses" in wanted:
for entry in payload.get("licenses", []):
license_id = str(entry.get("license_id") or "")
customer_id = resolve_customer(entry.get("customer_id"))
if not license_id or customer_id is None:
bump("licenses", "skipped")
continue
if Database.one(
conn, "SELECT 1 FROM licenses WHERE license_id=?", (license_id,)
):
# Eine signierte Lizenz wird nie ueberschrieben: ihr Inhalt ist
# kryptographisch festgelegt.
bump("licenses", "skipped")
continue
ticket_id = str(entry.get("ticket_id") or "") or None
if ticket_id and not Database.one(
conn, "SELECT 1 FROM license_tickets WHERE id=?", (ticket_id,)
):
ticket_id = None
conn.execute(
"INSERT INTO licenses (license_id, ticket_id, customer_id, type, role, modules, "
"lifetime, seq, issued_at, expires_at, license_pubkey, license_document, "
"client_key_encrypted, status, fingerprint, hostname, activated_at, "
"deactivated_at, last_heartbeat_at, revoked_at, revoke_reason, created_by, "
"created_at) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,'import',?)",
(
license_id,
ticket_id,
customer_id,
str(entry.get("type") or "standard"),
str(entry.get("role") or "customer"),
json.dumps(entry.get("modules") if isinstance(entry.get("modules"), list)
else json.loads(str(entry.get("modules") or "[]"))),
int(bool(entry.get("lifetime"))),
int(entry.get("seq") or 1),
str(entry.get("issued_at") or stamp),
str(entry.get("expires_at") or stamp),
str(entry.get("license_pubkey") or ""),
str(entry.get("license_document") or "{}"),
str(entry.get("client_key_encrypted") or ""),
str(entry.get("status") or "issued"),
entry.get("fingerprint"),
entry.get("hostname"),
entry.get("activated_at"),
entry.get("deactivated_at"),
entry.get("last_heartbeat_at"),
entry.get("revoked_at"),
str(entry.get("revoke_reason") or ""),
stamp,
),
)
bump("licenses", "created")
if "groups" in wanted:
for entry in payload.get("groups", []):
name = str(entry.get("name") or "").strip()
if not name:
continue
existing = Database.one(
conn,
"SELECT id FROM groups WHERE name=? COLLATE NOCASE AND deleted_at IS NULL",
(name,),
)
if existing is None:
cursor = conn.execute(
"INSERT INTO groups (name, description, is_default, is_system, preset_key, "
"created_at, updated_at) VALUES (?,?,?,?,?,?,?)",
(
name,
str(entry.get("description") or ""),
int(bool(entry.get("is_default"))),
int(bool(entry.get("is_system"))),
str(entry.get("preset_key") or ""),
stamp,
stamp,
),
)
group_id = int(cursor.lastrowid or 0)
bump("groups", "created")
else:
group_id = int(existing["id"])
bump("groups", "updated")
conn.execute("DELETE FROM group_permissions WHERE group_id=?", (group_id,))
conn.executemany(
"INSERT OR IGNORE INTO group_permissions (group_id, permission) VALUES (?,?)",
[(group_id, str(permission)) for permission in entry.get("permissions", [])],
)
if "users" in wanted:
for entry in payload.get("users", []):
username = str(entry.get("username") or "").strip()
if not username:
continue
existing = Database.one(
conn, "SELECT id, deleted_at FROM users WHERE username=? COLLATE NOCASE", (username,)
)
if existing is not None and existing["deleted_at"]:
bump("users", "skipped")
continue
if existing is None:
cursor = conn.execute(
"INSERT INTO users (username, password_hash, is_admin, is_locked, "
"auth_source, first_name, last_name, email, must_change_password, "
"created_at, updated_at) VALUES (?,?,?,?,'local',?,?,?,?,?,?)",
(
username,
str(entry.get("password_hash") or ""),
int(bool(entry.get("is_admin"))),
int(bool(entry.get("is_locked"))),
str(entry.get("first_name") or ""),
str(entry.get("last_name") or ""),
str(entry.get("email") or ""),
int(bool(entry.get("must_change_password"))),
stamp,
stamp,
),
)
user_id = int(cursor.lastrowid or 0)
bump("users", "created")
else:
user_id = int(existing["id"])
bump("users", "updated")
names = [n for n in str(entry.get("group_names") or "").split("\n") if n.strip()]
if names:
conn.execute("DELETE FROM user_groups WHERE user_id=?", (user_id,))
for name in names:
group_id = Database.value(
conn,
"SELECT id FROM groups WHERE name=? COLLATE NOCASE AND deleted_at IS NULL",
(name.strip(),),
)
if group_id is not None:
conn.execute(
"INSERT OR IGNORE INTO user_groups (user_id, group_id) VALUES (?,?)",
(user_id, int(group_id)),
)
if "settings" in wanted:
for key, value in (payload.get("settings") or {}).items():
if str(key).startswith("license_"):
continue
set_setting(str(key), value)
bump("settings", "updated")
return report
def _stamp() -> str:
from datetime import datetime, timezone
return datetime.now(timezone.utc).isoformat(timespec="seconds")
__all__ = [
"APP_TAG",
"CATEGORIES",
"CATEGORY_BY_KEY",
"MIN_PASSPHRASE_LENGTH",
"PREVIEW_TTL_MINUTES",
"TransferError",
"apply_import",
"build_export",
"drop_preview",
"load_preview",
"seal",
"store_preview",
"summarize",
"unseal",
]
@@ -0,0 +1,97 @@
"""Laufzeiteinstellungen des Lizenzservers."""
from __future__ import annotations
from tesm_core.settings_store import SettingSpec
from .services.mail import SETTINGS as MAIL_SETTINGS
VENDOR_SETTINGS: tuple[SettingSpec, ...] = (
SettingSpec("vendor_name", "", "str", "Anbietername", section="vendor"),
SettingSpec("vendor_email", "", "str", "Kontakt-E-Mail", section="vendor"),
SettingSpec("vendor_phone", "", "str", "Telefon", section="vendor"),
SettingSpec("vendor_address", "", "str", "Adresse", section="vendor"),
SettingSpec(
"vendor_logo_base64",
"",
"str",
"Logo (Data-URI)",
hint="Reist in jeder ausgestellten Lizenz mit und wird beim Kunden angezeigt. "
"Maximal 200 KB.",
section="vendor",
export=False,
),
SettingSpec(
"master_endpoint",
"",
"str",
"Oeffentlicher Endpunkt dieses Lizenzservers",
hint="Basis-URL, unter der Kundeninstanzen diesen Server erreichen -- z. B. "
"https://lizenz.firma.de. Wird in jede Lizenz eingebettet und fuer alle "
"kundengerichteten Links verwendet.",
section="vendor",
),
)
PORTAL_SETTINGS: tuple[SettingSpec, ...] = (
SettingSpec(
"portal_link_minutes",
30,
"int",
"Gueltigkeit des Zugangslinks (Minuten)",
hint="Der Link ist zusaetzlich nur einmal verwendbar.",
section="portal",
minimum=5,
maximum=1440,
),
SettingSpec(
"portal_session_minutes",
60,
"int",
"Gueltigkeit der Portalsitzung (Minuten)",
hint="Zweites, unabhaengiges Zeitfenster: eines fuer das Anklicken des Links, "
"eines fuer die daraus entstehende Sitzung.",
section="portal",
minimum=5,
maximum=1440,
),
SettingSpec(
"portal_enabled", True, "bool", "Kundenportal aktiv", section="portal"
),
SettingSpec(
"self_service_enabled",
True,
"bool",
"Self-Service-Link je Ticket aktiv",
hint="Erlaubt dem Kunden, seine Lizenz selbst herunterzuladen, zu widerrufen und "
"neu auszustellen -- ohne Anmeldung, allein ueber den unratbaren Ticket-Link.",
section="portal",
),
)
DEFAULT_TICKET_SETTINGS: tuple[SettingSpec, ...] = (
SettingSpec(
"default_valid_days",
365,
"int",
"Vorgeschlagene Laufzeit neuer Tickets (Tage)",
section="licensing",
minimum=1,
maximum=36500,
),
SettingSpec(
"allow_customer_revoke",
True,
"bool",
"Kunden duerfen ihre Lizenz selbst widerrufen",
hint="Notwendig fuer einen Systemwechsel ohne Support-Anruf.",
section="licensing",
),
)
LICENSE_SERVER_SETTINGS: tuple[SettingSpec, ...] = (
*VENDOR_SETTINGS,
*PORTAL_SETTINGS,
*DEFAULT_TICKET_SETTINGS,
*MAIL_SETTINGS,
)
@@ -0,0 +1,132 @@
{% extends "tesm_core/base.html" %}
{% from "tesm_core/macros.html" import field, alert, pill %}
{% block page_title %}Sicherung und Uebertragung{% endblock %}
{% block page_sub %}Verschluesselter Export &middot; zweistufiger Import{% endblock %}
{% block content %}
{{ alert('Der Export wird mit Argon2id aus Ihrer Passphrase verschluesselt (AES-256-GCM). Ohne
diese Passphrase ist die Datei nicht wiederherstellbar -- es gibt keine Hintertuer.', 'warning',
'Passphrase') }}
{{ alert('Der Signaturschluessel (' ~ signing_key_path ~ ') ist bewusst
nicht Teil dieser Sicherung. Er ist der Kern des Vertrauens und gehoert in eine
getrennte, bewusst verwahrte Sicherung -- nicht in dieselbe Datei wie die Kundendaten. Ohne ihn
ist nach einer Wiederherstellung keine ausgestellte Lizenz mehr pruefbar.', 'danger',
'Wichtig: Schluessel getrennt sichern') }}
<div class="grid grid--split">
<section class="card">
<div class="card__header">
<div class="flex-1">
<div class="card__title">{{ icon('download', size=17) }} Sicherung erstellen</div>
<div class="card__sub">Verlangt eine frische Passwortbestaetigung</div>
</div>
</div>
<form method="post" action="{{ url_for('backup.export') }}" data-no-guard>
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
<div class="card__body stack">
<fieldset class="fieldset">
<legend>Kategorien</legend>
<div class="stack stack--tight">
{% for category in all_categories %}
{% set allowed = category in categories %}
<label class="check {{ 'check--disabled' if not allowed }}">
<input type="checkbox" name="categories" value="{{ category.key }}"
{% if allowed %}checked{% endif %} {% if not allowed %}disabled{% endif %}>
<span>
{{ category.label }}
{% if category.sensitive %}{{ pill('sensibel', 'danger') }}{% endif %}
{% if category.admin_only %}{{ pill('nur Admin', 'accent') }}{% endif %}
<div class="fs-xs faint">{{ category.hint }}</div>
{% if not allowed %}
<div class="fs-xs danger-text">Dafuer fehlt Ihnen das Recht.</div>
{% endif %}
</span>
</label>
{% endfor %}
</div>
</fieldset>
<div class="form-grid">
{{ field('passphrase', 'Passphrase', type='password', required=True,
autocomplete='new-password',
hint_text='Mindestens ' ~ min_passphrase ~ ' Zeichen.') }}
{{ field('passphrase_repeat', 'Passphrase wiederholen', type='password', required=True,
autocomplete='new-password') }}
</div>
</div>
<div class="card__footer">
<button class="btn btn--primary" type="submit">Sicherung herunterladen</button>
</div>
</form>
</section>
<div class="stack">
{% if preview %}
<section class="card card--accent">
<div class="card__header">
<div class="flex-1">
<div class="card__title">{{ icon('upload', size=17) }} Geoeffnete Sicherung</div>
<div class="card__sub">Gueltig fuer {{ preview_ttl }} Minuten</div>
</div>
</div>
<form method="post" action="{{ url_for('backup.import_apply') }}">
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
<div class="card__body">
<div class="table-wrap">
<table class="table table--compact">
<thead><tr><th></th><th>Kategorie</th><th class="numeric">Eintraege</th></tr></thead>
<tbody>
{% for entry in preview.summary %}
<tr>
<td><label class="check">
<input type="checkbox" name="categories" value="{{ entry.key }}" checked>
<span class="sr-only">{{ entry.label }}</span></label></td>
<td>{{ entry.label }}</td>
<td class="numeric">{{ entry.count }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{{ alert('Bereits vorhandene Lizenzen werden nie ueberschrieben -- ihr Inhalt ist
kryptographisch festgelegt. Kunden werden ueber ihren Namen zusammengefuehrt.',
'info') }}
</div>
<div class="card__footer modal__footer--split">
<button class="btn btn--sm btn--ghost" type="submit"
formaction="{{ url_for('backup.import_discard') }}">Verwerfen</button>
<button class="btn btn--primary" type="submit"
data-confirm="Ausgewaehlte Kategorien jetzt uebernehmen?"
data-confirm-title="Sicherung uebernehmen" data-confirm-ok="Uebernehmen"
data-confirm-style="primary">Uebernehmen</button>
</div>
</form>
</section>
{% else %}
<section class="card">
<div class="card__header">
<div class="flex-1">
<div class="card__title">{{ icon('upload', size=17) }} Sicherung einspielen</div>
<div class="card__sub">Erst oeffnen und pruefen, dann uebernehmen</div>
</div>
</div>
<form method="post" enctype="multipart/form-data" data-no-guard
action="{{ url_for('backup.import_preview') }}">
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
<div class="card__body stack">
<input class="input" type="file" name="backup_file" accept=".json" required>
{{ field('passphrase', 'Passphrase der Datei', type='password', required=True,
autocomplete='off') }}
</div>
<div class="card__footer">
<button class="btn btn--primary" type="submit" data-busy-label="Oeffne ...">
Datei oeffnen
</button>
</div>
</form>
</section>
{% endif %}
</div>
</div>
{% endblock %}
@@ -0,0 +1,219 @@
{% extends "tesm_core/base.html" %}
{% from "tesm_core/macros.html" import field, textarea_field, pill, empty_state, alert %}
{% block page_title %}{{ customer.name }}{% endblock %}
{% block page_sub %}
{{ tickets | length }} Ticket(s) &middot; {{ licenses | length }} Lizenz(en)
{% endblock %}
{% block breadcrumb %}
<nav class="breadcrumb">
<span><a href="{{ url_for('customers.overview') }}">Kunden</a></span><span>{{ customer.name }}</span>
</nav>
{% endblock %}
{% block topbar_extra %}
{% if current_user.has('tickets.create') %}
<a class="btn btn--primary btn--sm" href="{{ url_for('tickets.new', kunde=customer.id) }}">
{{ icon('plus', size=15) }} Ticket anlegen
</a>
{% endif %}
{% endblock %}
{% block content %}
<div class="grid grid--split">
<div class="stack">
<section class="card">
<div class="card__header">
<div class="flex-1">
<div class="card__title">{{ icon('ticket', size=17) }} Tickets</div>
<div class="card__sub">Verwaltung auf der eigenen Ticket-Seite</div>
</div>
</div>
{% if tickets %}
<div class="table-wrap">
<table class="table table--compact">
<thead><tr><th>Ticket</th><th>Typ</th><th>Rolle</th><th>Laufzeit</th><th>Zustand</th></tr></thead>
<tbody>
{% for ticket in tickets %}
<tr>
<td class="mono fs-sm">
<a href="{{ url_for('tickets.detail', ticket_id=ticket.id) }}">
{{ ticket.id[:12] }}&hellip;</a>
</td>
<td class="fs-sm">{{ ticket.type }}</td>
<td class="fs-sm">
{% if ticket.role != 'customer' %}{{ pill(ticket.role, 'accent') }}
{% else %}Kunde{% endif %}
</td>
<td class="fs-sm">
{% if ticket.lifetime %}unbefristet{% else %}{{ ticket.valid_days }} Tage{% endif %}
</td>
<td>
{% if ticket.status == 'blocked' %}{{ pill('gesperrt', 'danger') }}
{% else %}{{ pill('offen', 'online') }}{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
{{ empty_state('Keine Tickets', 'Ohne Ticket kann keine Lizenz ausgestellt werden.', 'ticket') }}
{% endif %}
</section>
<section class="card">
<div class="card__header">
<div class="flex-1"><div class="card__title">{{ icon('shield', size=17) }} Lizenzen</div></div>
</div>
{% if licenses %}
<div class="table-wrap">
<table class="table table--compact">
<thead><tr><th>Lizenz</th><th>Typ</th><th>Zustand</th><th>Laeuft ab</th><th>Host</th></tr></thead>
<tbody>
{% for entry in licenses %}
<tr>
<td class="mono fs-sm">
<a href="{{ url_for('licenses.detail', license_id=entry.license_id) }}">
{{ entry.license_id[:12] }}&hellip;</a>
</td>
<td class="fs-sm">{{ entry.type }}</td>
<td>
{% if entry.status == 'active' %}{{ pill('aktiv', 'online') }}
{% elif entry.status == 'issued' %}{{ pill('ausgestellt', 'info') }}
{% elif entry.status == 'revoked' %}{{ pill('widerrufen', 'danger') }}
{% else %}{{ pill('deaktiviert', '') }}{% endif %}
</td>
<td class="fs-sm">
{% if entry.lifetime %}unbefristet
{% else %}
{{ entry.expires_at | date }}
{% if entry.color == 'red' %}{{ pill('kritisch', 'danger') }}
{% elif entry.color == 'orange' %}{{ pill('bald', 'warning') }}{% endif %}
{% endif %}
</td>
<td class="fs-sm">{{ entry.hostname or '&ndash;' | safe }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
{{ empty_state('Keine Lizenzen', '', 'shield') }}
{% endif %}
</section>
{% if reported.customers %}
<section class="card">
<div class="card__header">
<div class="flex-1">
<div class="card__title">{{ icon('share', size=17) }} Von diesem Lizenzserver verwaltete Kunden</div>
<div class="card__sub">
Gemeldeter Bestand &middot; Quelle {{ reported.source_license_id[:12] }}&hellip;
{% if reported.last_reported_at %}
&middot; <span data-relative-time="{{ reported.last_reported_at }}">{{ reported.last_reported_at | dt }}</span>
{% endif %}
</div>
</div>
</div>
<div class="card__body stack">
{{ alert('Diese Daten verwaltet der Sub-Lizenzserver selbst. Sie sind hier rein
informativ und werden bei jedem Bericht vollstaendig ersetzt.', 'info') }}
{% for entry in reported.customers %}
<details class="card">
<summary class="card__header" style="cursor:pointer">
<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 %}
<div class="stack stack--tight mb-3">
<div class="row row--tight fs-sm">
<span class="mono">{{ ticket.remote_ref[:12] }}&hellip;</span>
<span>{{ ticket.type }}</span>
{% if ticket.status == 'blocked' %}{{ pill('gesperrt', 'danger') }}
{% else %}{{ pill('offen', 'online') }}{% endif %}
</div>
{% for entry_license in ticket.licenses %}
<div class="row row--tight fs-xs faint" style="padding-left:var(--sp-4)">
<span class="mono">{{ entry_license.remote_ref[:12] }}&hellip;</span>
<span>{{ entry_license.status }}</span>
{% if entry_license.lifetime %}{{ pill('unbefristet', 'online') }}
{% elif entry_license.days_left is not none %}
{{ pill(entry_license.days_left ~ ' Tage',
'danger' if entry_license.color == 'red' else
('warning' if entry_license.color == 'orange' else 'online')) }}
{% endif %}
<span>{{ entry_license.hostname or '' }}</span>
</div>
{% endfor %}
</div>
{% endfor %}
</div>
</details>
{% endfor %}
</div>
</section>
{% endif %}
</div>
<section class="card">
<div class="card__header">
<div class="flex-1"><div class="card__title">{{ icon('building', size=17) }} Stammdaten</div></div>
</div>
<form method="post" action="{{ url_for('customers.update', customer_id=customer.id) }}">
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
<div class="card__body stack">
{{ field('name', 'Firmenname', value=customer.name, required=True,
disabled=not current_user.has('customers.edit')) }}
{{ field('contact_email', 'E-Mail', value=customer.contact_email, type='email',
disabled=not current_user.has('customers.edit')) }}
{{ field('contact_person', 'Ansprechpartner', value=customer.contact_person,
disabled=not current_user.has('customers.edit')) }}
{{ field('contact_phone', 'Telefon', value=customer.contact_phone,
disabled=not current_user.has('customers.edit')) }}
<div class="form-grid">
{{ field('street', 'Strasse', value=customer.street,
disabled=not current_user.has('customers.edit')) }}
{{ field('house_number', 'Nr.', value=customer.house_number,
disabled=not current_user.has('customers.edit')) }}
{{ field('postal_code', 'PLZ', value=customer.postal_code,
disabled=not current_user.has('customers.edit')) }}
{{ field('city', 'Ort', value=customer.city,
disabled=not current_user.has('customers.edit')) }}
</div>
{{ textarea_field('notes', 'Notizen', value=customer.notes, rows=3,
extra_class='field--full') }}
<div class="divider"></div>
<dl class="kv fs-xs">
<dt>Angelegt</dt><dd>{{ customer.created_at | dt }} von {{ customer.created_by or '-' }}</dd>
<dt>Geaendert</dt><dd>{{ customer.updated_at | dt }}</dd>
<dt>Portal-Zugang</dt>
<dd><span class="mono break-all">{{ portal_url }}</span>
<button class="btn btn--sm btn--ghost" type="button" data-copy="{{ portal_url }}">
{{ icon('copy', size=13) }}</button></dd>
</dl>
</div>
{% if current_user.has('customers.edit') %}
<div class="card__footer modal__footer--split">
{% if current_user.has('customers.delete') %}
<button class="btn btn--sm btn--ghost danger-text" type="submit"
formaction="{{ url_for('customers.delete', customer_id=customer.id) }}"
data-confirm="Kunde {{ customer.name }} loeschen? Das ist nur moeglich, solange kein Ticket mehr an ihm haengt."
data-confirm-title="Kunde loeschen" data-confirm-ok="Loeschen">
Loeschen
</button>
{% else %}<span></span>{% endif %}
<button class="btn btn--primary" type="submit">Speichern</button>
</div>
{% endif %}
</form>
</section>
</div>
{% endblock %}
@@ -0,0 +1,86 @@
{% extends "tesm_core/base.html" %}
{% from "tesm_core/macros.html" import field, textarea_field, pill, search_box, modal,
empty_state %}
{% macro customer_form(customer=None) %}
<div class="form-grid">
{{ field('name', 'Firmenname', value=customer.name if customer else '', required=True) }}
{{ field('contact_email', 'E-Mail', value=customer.contact_email if customer else '',
type='email', required=not customer,
hint_text='Pflicht bei der Neuanlage -- ueber diese Adresse laeuft das Kundenportal.') }}
{{ field('contact_person', 'Ansprechpartner', value=customer.contact_person if customer else '') }}
{{ field('contact_phone', 'Telefon', value=customer.contact_phone if customer else '') }}
{{ field('street', 'Strasse', value=customer.street if customer else '') }}
{{ field('house_number', 'Hausnummer', value=customer.house_number if customer else '') }}
{{ field('postal_code', 'PLZ', value=customer.postal_code if customer else '') }}
{{ field('city', 'Ort', value=customer.city if customer else '') }}
{{ textarea_field('notes', 'Notizen', value=customer.notes if customer else '', rows=2,
extra_class='field--full') }}
</div>
{% endmacro %}
{% block page_title %}Kunden{% endblock %}
{% block page_sub %}{{ customers | length }} Kunden{% endblock %}
{% block topbar_extra %}
{{ search_box('customers-table', 'Firma, Ort, Ansprechpartner ...') }}
{% if current_user.has('customers.create') %}
<button class="btn btn--primary btn--sm" type="button" data-dialog-open="customer-new">
{{ icon('plus', size=15) }} Kunde anlegen
</button>
{% endif %}
{% endblock %}
{% block content %}
<section class="card">
<div class="table-wrap">
<table class="table" id="customers-table" data-behavior="sortable">
<thead>
<tr>
<th class="sortable">Firma</th>
<th class="sortable">Ansprechpartner</th>
<th class="sortable">E-Mail</th>
<th class="sortable">Ort</th>
<th class="sortable numeric">Tickets</th>
<th class="sortable numeric">Offene Lizenzen</th>
</tr>
</thead>
<tbody>
{% for customer in customers %}
<tr data-filter-text="{{ customer.name }} {{ customer.city }} {{ customer.contact_person }} {{ customer.contact_email }}">
<td>
<a class="fw-600" href="{{ url_for('customers.detail', customer_id=customer.id) }}">
{{ customer.name }}
</a>
{% if customer.license_server_id %}{{ pill('Lizenzserver', 'accent') }}{% endif %}
</td>
<td class="fs-sm">{{ customer.contact_person or '&ndash;' | safe }}</td>
<td class="fs-sm">{{ customer.contact_email or '&ndash;' | safe }}</td>
<td class="fs-sm">{{ customer.city or '&ndash;' | safe }}</td>
<td class="numeric">{{ customer.ticket_count }}</td>
<td class="numeric">{{ customer.open_licenses }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% if not customers %}
{{ empty_state('Keine Kunden', 'Legen Sie den ersten Kunden an.', 'building') }}
{% endif %}
</section>
{% endblock %}
{% block dialogs %}
{% if current_user.has('customers.create') %}
{% call modal('customer-new', 'Kunde anlegen', 'Adresse in Einzelfeldern -- fuer Rechnungen weiterverwendbar', 'wide') %}
<form method="post" action="{{ url_for('customers.create') }}">
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
<div class="modal__body">{{ customer_form() }}</div>
<div class="modal__footer">
<button class="btn" type="button" data-dialog-close>Abbrechen</button>
<button class="btn btn--primary" type="submit">Anlegen</button>
</div>
</form>
{% endcall %}
{% endif %}
{% endblock %}
@@ -0,0 +1,124 @@
{% extends "tesm_core/base.html" %}
{% from "tesm_core/macros.html" import pill, empty_state, alert, stat %}
{% block page_title %}Uebersicht{% endblock %}
{% block page_sub %}
{% if summary %}
{{ summary.counts.customers }} Kunden &middot; {{ summary.counts.licenses_active }} aktive Lizenzen
{% else %}Willkommen{% endif %}
{% endblock %}
{% block content %}
{% if not summary %}
{{ empty_state('Keine Lizenzrechte',
'Ihrem Konto fehlt das Recht, Lizenzen zu sehen. Wenden Sie sich an die Administration.',
'lock') }}
{% 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')) }}
{{ stat('Aktive Lizenzen', summary.counts.licenses_active, kind='ok',
href=url_for('licenses.overview', status='active')) }}
{{ stat('Widerrufen', summary.counts.licenses_revoked, kind='muted',
href=url_for('licenses.overview', status='revoked')) }}
</div>
{% if summary.counts.tickets_blocked %}
{{ alert(summary.counts.tickets_blocked ~ ' Ticket(s) sind gesperrt -- fuer diese Kunden
lassen sich keine neuen Lizenzen ausstellen.', 'warning') }}
{% endif %}
{% for key, label, kind, hint in [
('red', 'Kritisch', 'danger', 'Weniger als 30 Tage Restlaufzeit oder bereits abgelaufen'),
('orange', 'Bald faellig', 'warning', '30 bis 90 Tage Restlaufzeit'),
('green', 'In Ordnung', 'ok', 'Mehr als 90 Tage oder unbefristet')] %}
{% 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">
<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">
{% for entry in entries %}
<a class="card" style="padding:var(--sp-4)" data-filter-item
href="{{ url_for('licenses.detail', license_id=entry.license_id) }}">
<div class="row row--between" style="align-items:flex-start">
<div class="flex-1" style="min-width:0">
<div class="fw-600 truncate">{{ entry.customer_name }}</div>
<div class="fs-xs faint truncate">
{{ entry.hostname or 'kein Host gemeldet' }}
</div>
</div>
{% if entry.lifetime %}{{ pill('unbefristet', 'online') }}
{% elif entry.is_expired %}{{ pill('abgelaufen', 'danger') }}
{% else %}{{ pill(entry.days_left ~ ' Tage', kind if kind != 'ok' else 'online') }}
{% endif %}
</div>
<dl class="kv fs-xs mt-3">
<dt>Typ</dt><dd>{{ entry.type }}{% if entry.role != 'customer' %}
&middot; {{ entry.role }}{% endif %}</dd>
<dt>Zustand</dt><dd>{{ entry.status }}</dd>
{% if entry.last_heartbeat_at %}
<dt>Heartbeat</dt>
<dd><span data-relative-time="{{ entry.last_heartbeat_at }}">{{ entry.last_heartbeat_at | dt }}</span></dd>
{% endif %}
</dl>
</a>
{% endfor %}
</div>
</div>
</details>
{% endif %}
{% endfor %}
{% if not summary.buckets.red and not summary.buckets.orange and not summary.buckets.green %}
{{ empty_state('Noch keine Lizenzen',
'Legen Sie einen Kunden an, erstellen Sie ein Ticket und stellen Sie daraus eine Lizenz aus.',
'shield') }}
{% endif %}
{% if sources %}
<section class="card">
<div class="card__header">
<div class="flex-1">
<div class="card__title">{{ icon('share', size=17) }} Sub-Lizenzserver</div>
<div class="card__sub">Von untergeordneten Lizenzservern gemeldeter Bestand</div>
</div>
<a class="btn btn--sm" href="{{ url_for('licenses.reports') }}">Alle anzeigen</a>
</div>
<div class="table-wrap">
<table class="table table--compact">
<thead><tr><th>Kunde</th><th>Host</th><th class="numeric">Kunden</th>
<th class="numeric">Tickets</th><th class="numeric">Lizenzen</th><th>Gemeldet</th></tr></thead>
<tbody>
{% for source in sources %}
<tr>
<td class="fw-600">
<a href="{{ url_for('licenses.report_detail', license_id=source.license_id) }}">
{{ source.customer_name }}</a>
</td>
<td class="fs-sm">{{ source.hostname or '&ndash;' | safe }}</td>
<td class="numeric">{{ source.customers }}</td>
<td class="numeric">{{ source.tickets }}</td>
<td class="numeric">{{ source.licenses }}</td>
<td class="fs-sm">
{% if source.last_reported_at %}
<span data-relative-time="{{ source.last_reported_at }}">{{ source.last_reported_at | dt }}</span>
{% else %}<span class="faint">noch nie</span>{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</section>
{% endif %}
{% endif %}
{% endblock %}
@@ -0,0 +1,258 @@
{% extends "tesm_core/base.html" %}
{% from "tesm_core/macros.html" import field, checkbox, pill, alert, empty_state, modal %}
{% block page_title %}Lizenz {{ license.license_id[:12] }}&hellip;{% endblock %}
{% block page_sub %}
{{ license.customer_name }} &middot; {{ type_labels.get(license.type, license.type) }}
{% if license.role != 'customer' %}&middot; {{ role_labels.get(license.role, license.role) }}{% endif %}
{% endblock %}
{% block breadcrumb %}
<nav class="breadcrumb">
<span><a href="{{ url_for('licenses.overview') }}">Lizenzen</a></span>
<span><a href="{{ url_for('customers.detail', customer_id=license.customer_id) }}">{{ license.customer_name }}</a></span>
<span>Lizenz</span>
</nav>
{% endblock %}
{% block topbar_extra %}
{% if current_user.has('licenses.export') %}
<a class="btn btn--sm btn--primary" href="{{ url_for('licenses.download', license_id=license.license_id) }}">
{{ icon('download', size=15) }} Datei
</a>
{% endif %}
{% endblock %}
{% block content %}
{% if state.revision_pending %}
{{ alert('Es wurde eine neue Revision signiert, die der Kunde noch nicht abgeholt hat.
Eine erreichbare Installation holt sie beim naechsten Heartbeat; eine Installation ohne
Verbindung braucht dafuer den Offline-Weg -- der Kunde findet ihn in seinem Portal.',
'warning', 'Revision ausstehend') }}
{% endif %}
{% if license.status == 'revoked' %}
{{ alert('Diese Lizenz ist widerrufen. Der Client erfaehrt das beim naechsten Heartbeat und
schaltet seine lizenzpflichtigen Funktionen ab.' ~ ((' Grund: ' ~ license.revoke_reason) if
license.revoke_reason else ''), 'danger', 'Widerrufen') }}
{% endif %}
<div class="grid grid--split">
<div class="stack">
<section class="card">
<div class="card__header">
<div class="flex-1">
<div class="card__title">
{{ icon('shield', size=17) }} Zustand
{% if license.status == 'active' %}{{ pill('aktiv', 'online') }}
{% elif license.status == 'issued' %}{{ pill('ausgestellt', 'info') }}
{% elif license.status == 'revoked' %}{{ pill('widerrufen', 'danger') }}
{% else %}{{ pill('deaktiviert', '') }}{% endif %}
{% if state.revision_pending %}
{{ pill('Revision ausstehend', 'warning') }}
{% endif %}
</div>
</div>
</div>
<div class="card__body">
<dl class="kv fs-sm">
<dt>Lizenz-ID</dt><dd class="mono break-all">{{ license.license_id }}</dd>
<dt>Revision</dt><dd>{{ license.seq }}</dd>
<dt>Ticket</dt>
<dd>
{% if license.ticket_id %}
<a class="mono" href="{{ url_for('tickets.detail', ticket_id=license.ticket_id) }}">
{{ license.ticket_id[:12] }}&hellip;</a>
{% else %}<span class="faint">kein Ticket (importiert)</span>{% endif %}
</dd>
<dt>Module</dt>
<dd>
{% if modules %}
{% for module in modules %}{{ pill(module_labels.get(module, module), 'online') }}{% endfor %}
{% else %}<span class="faint">keine</span>{% endif %}
</dd>
<dt>Ausgestellt</dt><dd>{{ license.issued_at | dt }} von {{ license.created_by or '-' }}</dd>
<dt>Laeuft ab</dt>
<dd>
{% if license.lifetime %}{{ pill('unbefristet', 'online') }}
{% else %}
{{ license.expires_at | dt }}
{% if state.is_expired %}{{ pill('abgelaufen', 'danger') }}
{% elif state.color == 'red' %}{{ pill(state.days_left ~ ' Tage', 'danger') }}
{% elif state.color == 'orange' %}{{ pill(state.days_left ~ ' Tage', 'warning') }}
{% else %}{{ pill(state.days_left ~ ' Tage', 'online') }}{% endif %}
{% endif %}
</dd>
<dt>Maschine</dt>
<dd>
{% if license.fingerprint %}
<span class="mono fs-xs break-all">{{ license.fingerprint }}</span>
{% else %}<span class="faint">noch nicht gebunden</span>{% endif %}
</dd>
<dt>Host (gemeldet)</dt><dd>{{ license.hostname or '&ndash;' | safe }}</dd>
<dt>Aktiviert</dt><dd>{{ license.activated_at | dt }}</dd>
<dt>Letzter Heartbeat</dt>
<dd>
{% if license.last_heartbeat_at %}
<span data-relative-time="{{ license.last_heartbeat_at }}">{{ license.last_heartbeat_at | dt }}</span>
{% else %}<span class="faint">nie</span>{% endif %}
</dd>
</dl>
</div>
</section>
{% if current_user.has('licenses.edit') and license.status != 'revoked' %}
<section class="card">
<div class="card__header">
<div class="flex-1">
<div class="card__title">{{ icon('refresh', size=17) }} Nachtraeglich anpassen</div>
<div class="card__sub">
Signiert eine neue Revision. Der Client erhaelt sie beim naechsten Heartbeat --
ohne erneute Aktivierung.
</div>
</div>
</div>
<form method="post" action="{{ url_for('licenses.resign', license_id=license.license_id) }}">
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
<div class="card__body stack">
{% if license.role == 'customer' %}
<fieldset class="fieldset">
<legend>Module</legend>
<div class="stack stack--tight">
{% for module in all_modules %}
{{ checkbox('modules', module_labels[module], value=module,
checked=module in modules) }}
{% endfor %}
</div>
</fieldset>
{% endif %}
{{ field('extend_days', 'Laufzeit verlaengern (Tage)', type='number',
placeholder='0',
hint_text='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>
{% endif %}
<section class="card">
<div class="card__header">
<div class="flex-1">
<div class="card__title">{{ icon('transfer', size=17) }} Zustellungen</div>
<div class="card__sub">{{ deliveries | length }} Vorgaenge</div>
</div>
</div>
{% if deliveries %}
<div class="table-wrap">
<table class="table table--compact">
<thead><tr><th>Zeit</th><th>Weg</th><th>Empfaenger</th><th>Durch</th><th>Detail</th></tr></thead>
<tbody>
{% for entry in deliveries %}
<tr>
<td class="fs-sm nowrap">{{ entry.delivered_at | dt }}</td>
<td class="fs-sm">{{ entry.channel }}</td>
<td class="fs-sm">{{ entry.recipient or '&ndash;' | safe }}</td>
<td class="fs-sm">{{ entry.delivered_by }}</td>
<td class="fs-xs muted">{{ entry.detail }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
{{ empty_state('Noch nicht zugestellt', 'Die Datei wurde bisher nicht herausgegeben.', 'transfer') }}
{% endif %}
</section>
<details class="card" data-persist="license-document">
<summary class="card__header" style="cursor:pointer">
<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 Aenderung bricht die Signatur</div>
</div>
{{ icon('chevron-down', size=16) }}
</summary>
<pre class="log-view">{{ document_json }}</pre>
</details>
</div>
<div class="stack">
{% if current_user.has('licenses.export') %}
<section class="card">
<div class="card__header">
<div class="flex-1">
<div class="card__title">{{ icon('mail', size=17) }} Per E-Mail versenden</div>
<div class="card__sub">
{% if mail_ready %}Ueber Microsoft Graph{% else %}Nicht konfiguriert{% endif %}
</div>
</div>
</div>
<form method="post" action="{{ url_for('licenses.send_mail', license_id=license.license_id) }}"
data-no-guard>
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
<div class="card__body">
{{ field('recipient', 'Empfaenger', value=license.contact_email, type='email',
disabled=not mail_ready) }}
</div>
<div class="card__footer">
<button class="btn btn--primary" type="submit" {% if not mail_ready %}disabled{% endif %}
data-busy-label="Sende ...">Versenden</button>
</div>
</form>
</section>
{% endif %}
{% if current_user.has('licenses.edit') or current_user.has('licenses.delete') %}
<section class="card">
<div class="card__header">
<div class="flex-1"><div class="card__title">{{ icon('alert', size=17) }} Lebenszyklus</div></div>
</div>
<div class="card__body stack stack--tight">
{% if current_user.has('licenses.edit') and license.status != 'revoked' %}
<button class="btn btn--sm btn--danger btn--block" type="button"
data-dialog-open="revoke-dialog">Widerrufen</button>
<p class="fs-xs faint">
Das Ticket bleibt offen -- eine neue Lizenz kann sofort ausgestellt werden. Genau so
laeuft ein Systemwechsel.
</p>
{% endif %}
{% if current_user.has('licenses.delete') %}
<form method="post" action="{{ url_for('licenses.delete', license_id=license.license_id) }}"
data-no-guard>
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
<button class="btn btn--sm btn--ghost danger-text btn--block" type="submit"
{% if license.status not in ('revoked', 'issued') %}disabled
title="Eine aktive Lizenz muss erst widerrufen werden"{% endif %}
data-confirm="Lizenz endgueltig aus der Datenbank entfernen? Die Historie geht verloren."
data-confirm-title="Lizenz loeschen" data-confirm-ok="Loeschen">
Endgueltig loeschen
</button>
</form>
{% endif %}
</div>
</section>
{% endif %}
</div>
</div>
{% endblock %}
{% block dialogs %}
{% if current_user.has('licenses.edit') and license.status != 'revoked' %}
{% call modal('revoke-dialog', 'Lizenz widerrufen', license.customer_name, 'narrow') %}
<form method="post" action="{{ url_for('licenses.revoke', license_id=license.license_id) }}">
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
<div class="modal__body">
{{ alert('Der Client erfaehrt den Widerruf beim naechsten Heartbeat und schaltet
lizenzpflichtige Funktionen ab. Das Ticket bleibt offen.', 'warning') }}
{{ field('reason', 'Grund (erscheint im Protokoll)', placeholder='Systemwechsel') }}
</div>
<div class="modal__footer">
<button class="btn" type="button" data-dialog-close>Abbrechen</button>
<button class="btn btn--danger" type="submit">Widerrufen</button>
</div>
</form>
{% endcall %}
{% endif %}
{% endblock %}
@@ -0,0 +1,77 @@
{% extends "tesm_core/base.html" %}
{% from "tesm_core/macros.html" import pill, search_box, empty_state %}
{% block page_title %}Lizenzen{% endblock %}
{% block page_sub %}{{ licenses | length }} Lizenzen{% if active_filter %} &middot; Filter: {{ active_filter }}{% endif %}{% endblock %}
{% block topbar_extra %}
{{ search_box('licenses-table', 'Kunde, Lizenz-ID, Host ...') }}
{% if current_user.has('licenses.execute') %}
<a class="btn btn--sm" href="{{ url_for('licenses.manual_code') }}">
{{ icon('transfer', size=15) }} Offline-Code
</a>
{% endif %}
{% endblock %}
{% block content %}
<section class="card">
<div class="table-toolbar">
<div class="btn-group">
<a class="btn btn--sm {{ 'btn--subtle' if not active_filter }}" href="{{ url_for('licenses.overview') }}">alle</a>
{% for state in states %}
<a class="btn btn--sm {{ 'btn--subtle' if active_filter == state }}"
href="{{ url_for('licenses.overview', status=state) }}">{{ state }}</a>
{% endfor %}
</div>
</div>
<div class="table-wrap">
<table class="table" id="licenses-table" data-behavior="sortable">
<thead>
<tr>
<th class="sortable">Kunde</th>
<th class="sortable">Lizenz</th>
<th class="sortable">Typ</th>
<th class="sortable">Zustand</th>
<th class="sortable">Restlaufzeit</th>
<th class="sortable">Host</th>
<th class="sortable">Heartbeat</th>
</tr>
</thead>
<tbody>
{% for entry in licenses %}
<tr data-filter-text="{{ entry.customer_name }} {{ entry.license_id }} {{ entry.hostname }} {{ entry.type }}">
<td class="fw-600">
<a href="{{ url_for('customers.detail', customer_id=entry.customer_id) }}">{{ entry.customer_name }}</a>
{% if entry.role != 'customer' %}{{ pill(role_labels.get(entry.role, entry.role), 'accent') }}{% endif %}
</td>
<td class="mono fs-sm">
<a href="{{ url_for('licenses.detail', license_id=entry.license_id) }}">{{ entry.license_id[:12] }}&hellip;</a>
</td>
<td class="fs-sm">{{ type_labels.get(entry.type, entry.type) }}</td>
<td>
{% if entry.status == 'active' %}{{ pill('aktiv', 'online') }}
{% elif entry.status == 'issued' %}{{ pill('ausgestellt', 'info') }}
{% elif entry.status == 'revoked' %}{{ pill('widerrufen', 'danger') }}
{% else %}{{ pill('deaktiviert', '') }}{% endif %}
</td>
<td data-sort-value="{{ entry.days_left if entry.days_left is not none else 99999 }}">
{% if entry.lifetime %}{{ pill('unbefristet', 'online') }}
{% elif entry.is_expired %}{{ pill('abgelaufen', 'danger') }}
{% elif entry.color == 'red' %}{{ pill(entry.days_left ~ ' Tage', 'danger') }}
{% elif entry.color == 'orange' %}{{ pill(entry.days_left ~ ' Tage', 'warning') }}
{% else %}{{ pill(entry.days_left ~ ' Tage', 'online') }}{% endif %}
</td>
<td class="fs-sm">{{ entry.hostname or '&ndash;' | safe }}</td>
<td class="fs-sm" data-sort-value="{{ entry.last_heartbeat_at or '' }}">
{% if entry.last_heartbeat_at %}
<span data-relative-time="{{ entry.last_heartbeat_at }}">{{ entry.last_heartbeat_at | dt }}</span>
{% else %}<span class="faint">nie</span>{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% if not licenses %}{{ empty_state('Keine Lizenzen', 'Zu diesem Filter gibt es nichts.', 'shield') }}{% endif %}
</section>
{% endblock %}
@@ -0,0 +1,68 @@
{% extends "tesm_core/base.html" %}
{% from "tesm_core/macros.html" import alert %}
{% block page_title %}Offline-Aktivierung{% endblock %}
{% block page_sub %}Code des Kunden verarbeiten und Antwortcode zurueckgeben{% endblock %}
{% block content %}
{{ alert('Beide Codes sind signierte Protokollobjekte -- inhaltlich identisch mit dem
Online-Weg, nur als kopierbarer Text. Die Pruefungen (Signatur, Frische, Nonce, Fingerprint)
laufen exakt gleich; es gibt hier keine Abkuerzung.', 'info') }}
<div class="grid grid--split">
<section class="card">
<div class="card__header">
<div class="flex-1"><div class="card__title">{{ icon('upload', size=17) }} Code des Kunden</div></div>
</div>
<form method="post">
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
<div class="card__body">
<label class="field">
<span class="field__label">Anfragecode</span>
<textarea class="textarea input--mono" name="code" rows="8" required
placeholder="Vom Kunden erhaltenen Code einfuegen ..."></textarea>
<span class="field__hint">Umbrueche und Leerzeichen sind erlaubt.</span>
</label>
</div>
<div class="card__footer">
<button class="btn btn--primary" type="submit" data-busy-label="Verarbeite ...">
Verarbeiten
</button>
</div>
</form>
</section>
<div class="stack">
{% if summary %}
<section class="card">
<div class="card__header">
<div class="flex-1"><div class="card__title">{{ icon('info', size=17) }} Inhalt der Anfrage</div></div>
</div>
<div class="card__body">
<dl class="kv fs-sm">
{% for key, value in summary.items() %}
<dt>{{ key }}</dt><dd class="mono break-all">{{ value }}</dd>
{% endfor %}
</dl>
</div>
</section>
{% endif %}
{% if response_code %}
<section class="card card--accent">
<div class="card__header">
<div class="flex-1">
<div class="card__title">{{ icon('download', size=17) }} Antwortcode</div>
<div class="card__sub">{{ message }}</div>
</div>
<button class="btn btn--sm" type="button" data-copy="#response-code">
{{ icon('copy', size=14) }} Kopieren
</button>
</div>
<div class="card__body">
<pre class="code-block code-block--wrap" id="response-code">{{ response_code }}</pre>
</div>
</section>
{% endif %}
</div>
</div>
{% endblock %}
@@ -0,0 +1,241 @@
{% extends "tesm_core/base.html" %}
{% from "tesm_core/macros.html" import field, checkbox, alert, pill, empty_state %}
{% block page_title %}Eigene Lizenz{% endblock %}
{% block page_sub %}
{% if state and state.valid and state.activated %}
aktiv &middot; Rolle {{ role_labels.get(state.role, state.role) }}
{% elif state and state.valid %}gueltig, nicht aktiviert
{% elif state and state.present %}ungueltig
{% else %}keine Lizenz installiert{% endif %}
{% endblock %}
{% block topbar_extra %}
<a class="btn btn--sm" href="{{ url_for('own_license.signing_key_info') }}">
{{ icon('key', size=15) }} Signaturschluessel
</a>
{% endblock %}
{% block content %}
{% if signing_key_health.problems %}
{% for problem in signing_key_health.problems %}{{ alert(problem, 'danger') }}{% endfor %}
{% endif %}
{% if state and state.error %}{{ alert(state.error, 'danger') }}{% endif %}
{% if state and state.role == 'master' %}
{{ alert('Diese Instanz ist die Wurzelinstanz. Nur sie darf Lizenzen fuer
weitere Lizenzserver ausstellen -- diese Grenze haengt an der signierten Rolle dieser
Lizenzdatei, nicht an einem Gruppenrecht. Ein lokaler Administrator eines Sub-Lizenzservers
kann sie sich damit nicht selbst einraeumen.', 'info', 'Rolle: Master') }}
{% elif state and state.role == 'license_server' %}
{{ alert('Diese Instanz ist ein Sub-Lizenzserver. Sie darf eigene Kunden,
Tickets und Lizenzen verwalten -- aber keine weiteren Lizenzserver-Lizenzen ausstellen. Der
eigene Bestand wird automatisch an den uebergeordneten Master gemeldet.', 'info',
'Rolle: Lizenzserver') }}
{% endif %}
<div class="grid grid--split">
<div class="stack">
{% if state and state.valid and state.status %}
<section class="card">
<div class="card__header">
<div class="flex-1">
<div class="card__title">
{{ icon('key', size=17) }} Installierte Lizenz
{% if state.activated %}{{ pill('aktiviert', 'online') }}
{% else %}{{ pill('nicht aktiviert', 'warning') }}{% endif %}
{% if state.issuer_pinned %}{{ pill('Aussteller gepinnt', 'info') }}{% endif %}
</div>
<div class="card__sub">
{{ type_labels.get(state.status.type, state.status.type) }} &middot;
{{ role_labels.get(state.role, state.role) }}
</div>
</div>
</div>
<div class="card__body">
<dl class="kv fs-sm">
<dt>Inhaber</dt><dd>{{ state.customer_name or '&ndash;' | safe }}</dd>
<dt>Lizenz-ID</dt><dd class="mono fs-xs break-all">{{ state.status.license_id }}</dd>
<dt>Revision</dt><dd>{{ state.status.seq }}</dd>
<dt>Laeuft ab</dt>
<dd>
{% if state.status.lifetime %}{{ pill('unbefristet', 'online') }}
{% else %}
{{ state.status.expires_at | dt }}
{% if state.status.expired %}
{{ pill('abgelaufen', 'danger') }}
{% if state.status.grace_active %}
<div class="fs-xs warning-text">
Kulanzfrist laeuft ({{ grace_days }} Tage).
</div>
{% endif %}
{% elif state.status.expiring_soon %}
{{ pill('in ' ~ state.status.days_left ~ ' Tagen', 'warning') }}
{% else %}
{{ pill('in ' ~ state.status.days_left ~ ' Tagen', 'online') }}
{% endif %}
{% endif %}
</dd>
<dt>Uebergeordneter Server</dt>
<dd class="break-all">
{{ state.status.master_endpoint or 'keiner (Wurzelinstanz oder Offline-Betrieb)' }}
</dd>
<dt>Letzter Heartbeat</dt>
<dd>
{{ state.last_heartbeat_at | dt }}
{% if state.heartbeat_error %}
<div class="fs-xs danger-text">{{ state.heartbeat_error }}</div>
{% endif %}
</dd>
</dl>
</div>
{% if current_user.has('settings_license.edit') %}
<div class="card__footer">
{% if not state.activated %}
<form method="post" action="{{ url_for('own_license.activate') }}" data-no-guard>
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
<button class="btn btn--primary" type="submit">Aktivieren</button>
</form>
{% elif state.status.master_endpoint %}
<form method="post" action="{{ url_for('own_license.heartbeat') }}" data-no-guard>
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
<button class="btn btn--sm" type="submit">
{{ icon('refresh', size=14) }} Jetzt melden
</button>
</form>
<form method="post" action="{{ url_for('own_license.deactivate') }}" data-no-guard>
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
<button class="btn btn--sm btn--ghost danger-text" type="submit"
data-confirm="Eigene Lizenz deaktivieren? Lizenzpflichtige Funktionen werden abgeschaltet."
data-confirm-title="Deaktivieren" data-confirm-ok="Deaktivieren">
Deaktivieren
</button>
</form>
{% endif %}
</div>
{% endif %}
</section>
{% else %}
{{ empty_state('Keine gueltige Lizenz',
'Spielen Sie eine Lizenzdatei ein oder erzeugen Sie mit "tesm-license-admin bootstrap"
die eigene Lizenz der Wurzelinstanz.', 'key') }}
{% endif %}
{% if pending %}
<section class="card card--accent">
<div class="card__header">
<div class="flex-1">
<div class="card__title">{{ icon('transfer', size=17) }} Offene Anfrage (Offline-Weg)</div>
<div class="card__sub">Aktion: {{ pending.action }}</div>
</div>
</div>
<form method="post" action="{{ url_for('own_license.offline_response') }}">
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
<div class="card__body">
<label class="field">
<span class="field__label">Antwortcode des uebergeordneten Servers</span>
<textarea class="textarea input--mono" name="code" rows="4" required></textarea>
</label>
</div>
<div class="card__footer">
<button class="btn btn--primary" type="submit">Antwort uebernehmen</button>
</div>
</form>
</section>
{% endif %}
{% if current_user.has('settings_license.edit') %}
<section class="card">
<div class="card__header">
<div class="flex-1">
<div class="card__title">{{ icon('upload', size=17) }} Lizenzdatei einspielen</div>
<div class="card__sub">
Fuer einen Sub-Lizenzserver: die Datei, die der Master ausgestellt hat
</div>
</div>
</div>
<form method="post" enctype="multipart/form-data" data-no-guard
action="{{ url_for('own_license.upload') }}">
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
<div class="card__body stack">
<input class="input" type="file" name="license_file" accept=".json,.lic" required>
{% if trusted_keys and current_user.is_admin %}
{{ checkbox('allow_new_issuer', 'Wechsel des Ausstellers ausdruecklich zulassen') }}
{% endif %}
</div>
<div class="card__footer">
<button class="btn btn--primary" type="submit">Einspielen</button>
</div>
</form>
</section>
{% endif %}
</div>
<div class="stack">
<section class="card">
<div class="card__header">
<div class="flex-1">
<div class="card__title">{{ icon('hard-drive', size=17) }} Diese Maschine</div>
</div>
</div>
<div class="card__body">
<dl class="kv fs-sm">
<dt>Hostname</dt><dd class="mono">{{ hostname }}</dd>
<dt>Fingerprint</dt><dd class="mono fs-xs break-all">{{ fingerprint }}</dd>
<dt>Signaturschluessel</dt>
<dd class="mono">{{ signing_key_health.get('key_id') or 'fehlt' }}</dd>
</dl>
</div>
</section>
<section class="card">
<div class="card__header">
<div class="flex-1">
<div class="card__title">{{ icon('shield', size=17) }} Vertrauenswuerdige Aussteller</div>
<div class="card__sub">{{ trusted_keys | length }} gepinnte Schluessel</div>
</div>
</div>
<div class="card__body">
{% if trusted_keys %}
<dl class="kv fs-sm">
{% for key in trusted_keys %}
<dt class="mono">{{ key.key_id }}</dt>
<dd>{{ key.label }} <span class="faint">({{ key.source }}, {{ key.added_at | date }})</span></dd>
{% endfor %}
</dl>
{% else %}
<p class="muted fs-sm">Noch kein Aussteller gepinnt.</p>
{% endif %}
</div>
</section>
<section class="card">
<div class="card__header">
<div class="flex-1">
<div class="card__title">{{ icon('puzzle', size=17) }} Was die Lizenz freischaltet</div>
</div>
</div>
<div class="card__body">
<ul class="stack stack--tight fs-sm">
{% for key, label in features.items() %}
<li class="row row--tight">
{% if feature_allowed(key) %}
<span class="success-text">{{ icon('check', size=14) }}</span>
{% else %}
<span class="faint">{{ icon('lock', size=14) }}</span>
{% endif %}
<span>{{ label }}</span>
</li>
{% endfor %}
</ul>
{% if may_issue_license_server %}
<div class="divider"></div>
<p class="fs-sm success-text">
{{ icon('check', size=14) }} Diese Instanz darf Lizenzserver-Lizenzen ausstellen.
</p>
{% endif %}
</div>
</section>
</div>
</div>
{% endblock %}
@@ -0,0 +1,11 @@
{% extends "tesm_core/standalone.html" %}
{% block heading %}Kundenportal{% endblock %}
{% block content %}
<div class="alert alert--info">
{{ icon('info', size=18) }}
<div class="alert__body">
Das Kundenportal ist bei diesem Anbieter derzeit nicht aktiv. Bitte wenden Sie sich an
Ihren Ansprechpartner.
</div>
</div>
{% endblock %}
@@ -0,0 +1,96 @@
{% extends "tesm_core/standalone.html" %}
{% from "tesm_core/macros.html" import field, pill %}
{% block card_class %}auth-card--wide{% endblock %}
{% block heading %}{{ customer.name }}{% endblock %}
{% block subheading %}Ihre Lizenzen und Stammdaten{% endblock %}
{% block content %}
<div class="alert alert--info">
{{ icon('clock', size=18) }}
<div class="alert__body">
Diese Sitzung endet
<span data-relative-time="{{ expires_at }}">{{ expires_at | dt }}</span>. Danach fordern Sie
einfach einen neuen Zugangslink an.
</div>
</div>
<section class="card">
<div class="card__header">
<div class="flex-1">
<div class="card__title">{{ icon('shield', size=17) }} Ihre Lizenzen</div>
<div class="card__sub">{{ tickets | length }} Vertrag/Vertraege</div>
</div>
</div>
<div class="card__body stack stack--tight">
{% for ticket in tickets %}
{% set entry = licenses.get(ticket.id) %}
<div class="card" style="padding:var(--sp-4)">
<div class="row row--between">
<div>
<div class="fw-600">{{ ticket.type | capitalize }}</div>
<div class="fs-xs faint">
{% if ticket.lifetime %}unbefristet{% else %}{{ ticket.valid_days }} Tage Laufzeit{% endif %}
</div>
</div>
{% if ticket.status == 'blocked' %}
{{ pill('gesperrt', 'danger') }}
{% elif entry %}
{% if entry.lifetime %}{{ pill('unbefristet', 'online') }}
{% elif entry.is_expired %}{{ pill('abgelaufen', 'danger') }}
{% elif entry.color == 'red' %}{{ pill('noch ' ~ entry.days_left ~ ' Tage', 'danger') }}
{% elif entry.color == 'orange' %}{{ pill('noch ' ~ entry.days_left ~ ' Tage', 'warning') }}
{% else %}{{ pill('noch ' ~ entry.days_left ~ ' Tage', 'online') }}{% endif %}
{% else %}
{{ pill('keine Lizenz', 'warning') }}
{% endif %}
</div>
{% if self_service_enabled and ticket.role == 'customer' %}
<a class="btn btn--sm mt-3" href="{{ url_for('selfservice.overview', ticket_id=ticket.id) }}">
Lizenz verwalten
</a>
{% endif %}
</div>
{% else %}
<p class="muted fs-sm">Zu Ihrem Konto ist noch kein Vertrag hinterlegt.</p>
{% endfor %}
</div>
</section>
<section class="card">
<div class="card__header">
<div class="flex-1"><div class="card__title">{{ icon('building', size=17) }} Ihre Angaben</div></div>
</div>
<form method="post">
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
<div class="card__body stack">
<div class="form-grid">
{{ field('contact_person', 'Ansprechpartner', value=customer.contact_person) }}
{{ field('contact_phone', 'Telefon', value=customer.contact_phone) }}
{{ field('contact_email', 'E-Mail', value=customer.contact_email, type='email',
hint_text='Ueber diese Adresse laeuft der Zugang zu diesem Portal.') }}
{{ field('street', 'Strasse', value=customer.street) }}
{{ field('house_number', 'Hausnummer', value=customer.house_number) }}
{{ field('postal_code', 'PLZ', value=customer.postal_code) }}
{{ field('city', 'Ort', value=customer.city) }}
</div>
</div>
<div class="card__footer">
<button class="btn btn--primary" type="submit">Speichern</button>
</div>
</form>
</section>
<form method="post" action="{{ url_for('portal.logout') }}" data-no-guard>
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
<button class="btn btn--block" type="submit">Abmelden</button>
</form>
{% if vendor.name %}
<p class="auth-footnote">
Fragen? {{ vendor.name }}
{% if vendor.phone %} &middot; {{ vendor.phone }}{% endif %}
{% if vendor.email %} &middot; {{ vendor.email }}{% endif %}
</p>
{% endif %}
{% endblock %}
@@ -0,0 +1,28 @@
{% extends "tesm_core/standalone.html" %}
{% block heading %}Kundenportal{% endblock %}
{% block subheading %}Zugangslink anfordern{% endblock %}
{% block content %}
<p class="fs-sm muted center">
Geben Sie die bei uns hinterlegte E-Mail-Adresse ein. Sie erhalten einen Link, der
{{ minutes }} Minuten gueltig ist und sich einmal verwenden laesst.
</p>
<form method="post" class="stack" data-no-guard>
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
<label class="field">
<span class="field__label">E-Mail-Adresse</span>
<input class="input" type="email" name="email" required autofocus autocomplete="email"
placeholder="ihre.adresse@firma.de">
</label>
<button class="btn btn--primary btn--lg btn--block" type="submit" data-busy-label="Sende ...">
Zugangslink anfordern
</button>
</form>
<p class="auth-footnote">
Aus Datenschutzgruenden bekommen Sie immer dieselbe Rueckmeldung -- unabhaengig davon, ob die
Adresse bei uns bekannt ist.
</p>
{% endblock %}
@@ -0,0 +1,77 @@
{% extends "tesm_core/base.html" %}
{% from "tesm_core/macros.html" import empty_state, pill, alert %}
{% block page_title %}{{ license.customer_name }}{% endblock %}
{% block page_sub %}Gemeldeter Bestand &middot; Lizenz {{ license.license_id[:12] }}&hellip;{% endblock %}
{% block breadcrumb %}
<nav class="breadcrumb">
<span><a href="{{ url_for('licenses.reports') }}">Sub-Lizenzserver</a></span>
<span>{{ license.customer_name }}</span>
</nav>
{% endblock %}
{% block content %}
{{ alert('Diese Daten verwaltet der Sub-Lizenzserver selbst. Sie sind hier rein informativ und
werden bei jedem Bericht vollstaendig ersetzt.', 'info') }}
{% if reported.customers %}
<div class="stack">
{% for entry in reported.customers %}
<details class="card" open>
<summary class="card__header" style="cursor:pointer">
<div class="flex-1">
<div class="card__title">{{ entry.name }}</div>
<div class="card__sub">
{{ entry.contact_email or 'ohne E-Mail' }}
{% if entry.city %}&middot; {{ entry.city }}{% endif %}
&middot; {{ entry.tickets | length }} Ticket(s)
</div>
</div>
{{ icon('chevron-down', size=16) }}
</summary>
<div class="table-wrap">
<table class="table table--compact">
<thead><tr><th>Ticket</th><th>Typ</th><th>Zustand</th><th>Lizenzen</th></tr></thead>
<tbody>
{% for ticket in entry.tickets %}
<tr>
<td class="mono fs-sm">{{ ticket.remote_ref[:12] }}&hellip;</td>
<td class="fs-sm">{{ ticket.type }}</td>
<td>
{% if ticket.status == 'blocked' %}{{ pill('gesperrt', 'danger') }}
{% else %}{{ pill('offen', 'online') }}{% endif %}
</td>
<td class="fs-sm">
{% for entry_license in ticket.licenses %}
<div class="row row--tight">
<span class="mono fs-xs">{{ entry_license.remote_ref[:10] }}&hellip;</span>
<span>{{ entry_license.status }}</span>
{% if entry_license.lifetime %}{{ pill('unbefristet', 'online') }}
{% elif entry_license.days_left is not none %}
{{ pill(entry_license.days_left ~ ' Tage',
'danger' if entry_license.color == 'red' else
('warning' if entry_license.color == 'orange' else 'online')) }}
{% endif %}
{% if entry_license.hostname %}
<span class="fs-xs faint">{{ entry_license.hostname }}</span>
{% endif %}
</div>
{% else %}
<span class="faint">keine</span>
{% endfor %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</details>
{% endfor %}
</div>
{% else %}
{{ empty_state('Noch kein Bericht',
'Dieser Lizenzserver hat noch nichts gemeldet. Der erste Bericht kommt mit dem naechsten
Heartbeat.', 'share') }}
{% endif %}
{% endblock %}
@@ -0,0 +1,49 @@
{% extends "tesm_core/base.html" %}
{% from "tesm_core/macros.html" import empty_state, alert, pill %}
{% block page_title %}Sub-Lizenzserver{% endblock %}
{% block page_sub %}{{ sources | length }} untergeordnete Lizenzserver{% endblock %}
{% block content %}
{{ alert('Ein Sub-Lizenzserver meldet seinen kompletten Bestand -- huckepack auf dem
' ~ heartbeat_hours ~ '-Stunden-Heartbeat und zusaetzlich sofort nach jeder Aenderung. Jeder
Bericht ist ein vollstaendiger Schnappschuss: Geloeschtes verschwindet automatisch auch hier.',
'info') }}
{% if sources %}
<section class="card">
<div class="table-wrap">
<table class="table" data-behavior="sortable">
<thead><tr>
<th class="sortable">Kunde</th><th class="sortable">Lizenz</th><th class="sortable">Host</th>
<th class="sortable numeric">Kunden</th><th class="sortable numeric">Tickets</th>
<th class="sortable numeric">Lizenzen</th><th class="sortable">Gemeldet</th>
</tr></thead>
<tbody>
{% for source in sources %}
<tr>
<td class="fw-600">
<a href="{{ url_for('licenses.report_detail', license_id=source.license_id) }}">
{{ source.customer_name }}</a>
</td>
<td class="mono fs-sm">{{ source.license_id[:12] }}&hellip;</td>
<td class="fs-sm">{{ source.hostname or '&ndash;' | safe }}</td>
<td class="numeric">{{ source.customers }}</td>
<td class="numeric">{{ source.tickets }}</td>
<td class="numeric">{{ source.licenses }}</td>
<td class="fs-sm" data-sort-value="{{ source.last_reported_at or '' }}">
{% if source.last_reported_at %}
<span data-relative-time="{{ source.last_reported_at }}">{{ source.last_reported_at | dt }}</span>
{% else %}{{ pill('noch nie', 'warning') }}{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</section>
{% else %}
{{ empty_state('Keine Sub-Lizenzserver',
'Es wurde noch keine Lizenz mit der Rolle "Lizenzserver" ausgestellt.', 'share') }}
{% endif %}
{% endblock %}
@@ -0,0 +1,135 @@
{% extends "tesm_core/standalone.html" %}
{% from "tesm_core/macros.html" import pill %}
{% block card_class %}auth-card--wide{% endblock %}
{% block heading %}Ihre TESM-Lizenz{% endblock %}
{% block subheading %}{{ ticket.customer_name }}{% endblock %}
{% block content %}
{% if blocked %}
<div class="alert alert--danger">
{{ icon('alert', size=18) }}
<div class="alert__body">
Dieser Vertrag ist derzeit gesperrt. Bitte wenden Sie sich an Ihren Ansprechpartner.
</div>
</div>
{% endif %}
{% if current %}
<section class="card">
<div class="card__header">
<div class="flex-1">
<div class="card__title">
{{ icon('shield', size=17) }} Aktuelle Lizenz
{% if current.status == 'active' %}{{ pill('aktiv', 'online') }}
{% else %}{{ pill('bereit zum Einspielen', 'info') }}{% endif %}
</div>
<div class="card__sub">
{{ type_labels.get(current.type, current.type) }} &middot;
{% if current.lifetime %}unbefristet
{% elif current.is_expired %}abgelaufen
{% else %}noch {{ current.days_left }} Tage{% endif %}
</div>
</div>
</div>
<div class="card__body stack">
<dl class="kv fs-sm">
<dt>Gueltig bis</dt>
<dd>{% if current.lifetime %}unbefristet{% else %}{{ current.expires_at | date }}{% endif %}</dd>
{% if current.hostname %}<dt>Installiert auf</dt><dd>{{ current.hostname }}</dd>{% endif %}
<dt>Ausgestellt</dt><dd>{{ current.issued_at | date }}</dd>
</dl>
<a class="btn btn--primary btn--block"
href="{{ url_for('selfservice.download', ticket_id=ticket.id, license_id=current.license_id) }}">
{{ icon('download', size=16) }} Lizenzdatei herunterladen
</a>
{% if offline_needed %}
<a class="btn btn--block mt-2"
href="{{ url_for('selfservice.offline', ticket_id=ticket.id) }}">
{{ icon('transfer', size=16) }} Offline aktivieren
</a>
<p class="fs-xs faint mt-2">
Nutzen Sie das, wenn Ihre TESM-Installation uns nicht direkt erreicht.
</p>
{% endif %}
<p class="fs-xs faint">
Spielen Sie die Datei in Ihrer TESM-Installation unter
<strong>Verwaltung &rarr; Lizenz</strong> ein und aktivieren Sie sie dort.
</p>
{% if allow_revoke %}
<div class="divider"></div>
<form method="post" data-no-guard
action="{{ url_for('selfservice.revoke', ticket_id=ticket.id, license_id=current.license_id) }}">
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
<button class="btn btn--ghost danger-text btn--block" type="submit"
data-confirm="Lizenz freigeben? Ihre bisherige Installation verliert damit die lizenzpflichtigen Funktionen. Danach koennen Sie sofort eine neue Lizenz fuer Ihr neues System ausstellen."
data-confirm-title="Lizenz freigeben" data-confirm-ok="Freigeben">
Lizenz freigeben (Systemwechsel)
</button>
</form>
<p class="fs-xs faint">
Nutzen Sie das, wenn Sie TESM auf einem anderen Server neu aufsetzen. Ihr Vertrag
bleibt bestehen.
</p>
{% endif %}
</div>
</section>
{% else %}
<section class="card">
<div class="card__header">
<div class="flex-1">
<div class="card__title">{{ icon('shield', size=17) }} Noch keine Lizenz</div>
<div class="card__sub">Sie koennen Ihre Lizenz jetzt erstellen</div>
</div>
</div>
<div class="card__body">
<form method="post" action="{{ url_for('selfservice.issue', ticket_id=ticket.id) }}"
data-no-guard>
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
<button class="btn btn--primary btn--block" type="submit" {% if blocked %}disabled{% endif %}
data-busy-label="Erstelle ...">
Lizenz erstellen
</button>
</form>
</div>
</section>
{% endif %}
{% if history %}
<details class="card">
<summary class="card__header" style="cursor:pointer">
<div class="flex-1">
<div class="card__title">{{ icon('history', size=17) }} Frueher ausgestellt</div>
<div class="card__sub">{{ history | length }} Eintraege</div>
</div>
{{ icon('chevron-down', size=16) }}
</summary>
<div class="table-wrap">
<table class="table table--compact">
<thead><tr><th>Ausgestellt</th><th>Zustand</th><th>System</th></tr></thead>
<tbody>
{% for entry in history %}
<tr>
<td class="fs-sm">{{ entry.issued_at | date }}</td>
<td class="fs-sm">
{% if entry.status == 'revoked' %}freigegeben
{% elif entry.status == 'deactivated' %}abgeloest
{% else %}{{ entry.status }}{% endif %}
</td>
<td class="fs-sm">{{ entry.hostname or '&ndash;' | safe }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</details>
{% endif %}
<p class="auth-footnote">
<a href="{{ portal_url }}">Stammdaten pflegen</a>
{% if vendor.name %} &middot; {{ vendor.name }}{% endif %}
{% if vendor.phone %} &middot; {{ vendor.phone }}{% endif %}
</p>
{% endblock %}
@@ -0,0 +1,51 @@
{% extends "tesm_core/standalone.html" %}
{% from "tesm_core/macros.html" import alert %}
{% block card_class %}auth-card--wide{% endblock %}
{% block heading %}Offline aktivieren{% endblock %}
{% block subheading %}{{ ticket.customer_name }}{% endblock %}
{% block content %}
<section class="card">
<div class="card__body stack">
{{ alert('In Ihrer TESM-Installation unter Verwaltung -- Lizenz auf "Lizenz aktivieren"
klicken. Erscheint dort ein Anfragecode, fuegen Sie ihn hier ein. Sie bekommen einen
Antwortcode zurueck, den Sie in TESM eintragen. Beide Codes sind signiert und lassen
sich unterwegs nicht veraendern.', 'info', 'So geht es') }}
{% if response_code %}
<div class="field">
<span class="field__label">Antwortcode -- in TESM eintragen</span>
<pre class="log-view" id="offline-answer" style="max-height:16dvh">{{ response_code }}</pre>
<div class="row row--end mt-2">
<button class="btn btn--sm" type="button" data-copy="#offline-answer">
Code kopieren
</button>
</div>
<p class="fs-sm muted mt-2">
Tragen Sie ihn in TESM unter Verwaltung -- Lizenz bei
&bdquo;Antwortcode des Lizenzservers&ldquo; ein.
{% if summary.hostname %}
Die Anfrage kam von <span class="mono">{{ summary.hostname }}</span>.
{% endif %}
</p>
</div>
{% else %}
<form method="post">
<label class="field">
<span class="field__label">Anfragecode aus Ihrer TESM-Installation</span>
<textarea class="textarea input--mono" name="code" rows="6" required
placeholder="Code einfuegen ..."></textarea>
</label>
<div class="row row--end mt-3">
<button class="btn btn--primary" type="submit">Antwortcode erzeugen</button>
</div>
</form>
{% endif %}
<p class="fs-xs faint">
<a href="{{ url_for('selfservice.overview', ticket_id=ticket.id) }}">Zurueck zur Uebersicht</a>
</p>
</div>
</section>
{% endblock %}
@@ -0,0 +1,81 @@
{% extends "tesm_core/base.html" %}
{% from "tesm_core/macros.html" import field, checkbox, alert, pill %}
{% block page_title %}E-Mail-Versand{% endblock %}
{% block page_sub %}
Microsoft Graph (App-only) &middot;
{% if ready %}einsatzbereit{% else %}nicht vollstaendig konfiguriert{% endif %}
{% endblock %}
{% block topbar_extra %}
{% if current_user.has('settings_mail.execute') %}
<form method="post" action="{{ url_for('settings.mail_test') }}" data-no-guard>
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
<button class="btn btn--sm" type="submit" data-busy-label="Teste ...">
{{ icon('activity', size=15) }} Verbindung testen
</button>
</form>
{% endif %}
{% endblock %}
{% block content %}
{{ alert('Der Versand laeuft ueber den Client-Credentials-Flow von Microsoft Graph -- kein SMTP,
kein zusaetzliches Paket. Die Anwendung braucht in Azure die Anwendungsberechtigung
Mail.Send. Der Verbindungstest holt nur ein Token und verschickt
bewusst keine echte Nachricht.', 'info') }}
<div class="grid grid--split">
<section class="card">
<div class="card__header">
<div class="flex-1"><div class="card__title">{{ icon('mail', size=17) }} Zugang</div></div>
</div>
<form method="post" action="{{ url_for('settings.mail_save') }}">
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
<div class="card__body stack">
{{ checkbox('graph_enabled', 'E-Mail-Versand aktiv', checked=values.graph_enabled,
disabled=not current_user.has('settings_mail.edit')) }}
<div class="form-grid">
{{ field('graph_tenant_id', 'Verzeichnis-ID (Tenant)', value=values.graph_tenant_id,
disabled=not current_user.has('settings_mail.edit')) }}
{{ field('graph_client_id', 'Anwendungs-ID (Client)', value=values.graph_client_id,
disabled=not current_user.has('settings_mail.edit')) }}
{{ field('graph_sender', 'Absender-Postfach', value=values.graph_sender, type='email',
disabled=not current_user.has('settings_mail.edit')) }}
{{ field('graph_reply_to', 'Antwortadresse', value=values.graph_reply_to, type='email',
disabled=not current_user.has('settings_mail.edit')) }}
</div>
<fieldset class="fieldset">
<legend>Client-Secret {% if has_secret %}{{ pill('gesetzt', 'online') }}{% else %}{{ pill('fehlt', 'warning') }}{% endif %}</legend>
{{ field('client_secret', 'Neues Secret (leer = unveraendert)', type='password',
autocomplete='new-password', disabled=not can_secrets) }}
{% if not can_secrets %}
<p class="fs-xs faint mt-2">
Zum Setzen des Secrets fehlt Ihnen das Recht &bdquo;Geheimnisse&ldquo;.
</p>
{% endif %}
</fieldset>
</div>
{% if current_user.has('settings_mail.edit') %}
<div class="card__footer"><button class="btn btn--primary" type="submit">Speichern</button></div>
{% endif %}
</form>
</section>
<section class="card">
<div class="card__header">
<div class="flex-1"><div class="card__title">{{ icon('info', size=17) }} Wofuer der Versand genutzt wird</div></div>
</div>
<div class="card__body">
<ul class="stack stack--tight fs-sm muted">
<li>Lizenzdatei an den Kunden senden (mit Anhang).</li>
<li>Zugangslink fuer das Kundenportal.</li>
</ul>
<div class="divider"></div>
<p class="fs-sm muted">
Das Secret liegt AES-256-GCM-verschluesselt in der Datenbank, gebunden an seinen
Verwendungszweck. Ohne die Schluesseldatei dieser Installation ist es nicht lesbar.
</p>
</div>
</section>
</div>
{% endblock %}
@@ -0,0 +1,134 @@
{% extends "tesm_core/base.html" %}
{% from "tesm_core/macros.html" import field, select_field, checkbox, alert %}
{% block page_title %}Systemeinstellungen{% endblock %}
{% block page_sub %}Portal, Self-Service, Protokolle, Host{% endblock %}
{% block content %}
{% if not sysops_available %}
{{ alert('Systemhelfer nicht verfuegbar -- Hostname und Zeitzone koennen hier nur angezeigt,
nicht geaendert werden.', 'info') }}
{% endif %}
<div class="grid grid--split">
<div class="stack">
<section class="card">
<div class="card__header">
<div class="flex-1">
<div class="card__title">{{ icon('users', size=17) }} Kundenportal und Self-Service</div>
<div class="card__sub">Zwei unabhaengige Zeitfenster: Link und Sitzung</div>
</div>
</div>
<form method="post" action="{{ url_for('settings.system_save') }}">
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
<div class="card__body stack">
{{ checkbox('portal_enabled', 'Kundenportal aktiv', checked=values.portal_enabled) }}
<div class="form-grid">
{{ field('portal_link_minutes', 'Zugangslink gueltig (Minuten)',
value=values.portal_link_minutes, type='number',
hint_text=specs['portal_link_minutes'].hint) }}
{{ field('portal_session_minutes', 'Portalsitzung gueltig (Minuten)',
value=values.portal_session_minutes, type='number',
hint_text=specs['portal_session_minutes'].hint) }}
</div>
<div class="divider"></div>
{{ checkbox('self_service_enabled', 'Self-Service-Link je Ticket aktiv',
checked=values.self_service_enabled,
hint_text=specs['self_service_enabled'].hint) }}
{{ checkbox('allow_customer_revoke', 'Kunden duerfen ihre Lizenz selbst freigeben',
checked=values.allow_customer_revoke,
hint_text=specs['allow_customer_revoke'].hint) }}
</div>
<div class="card__footer"><button class="btn btn--primary" type="submit">Speichern</button></div>
</form>
</section>
<section class="card">
<div class="card__header">
<div class="flex-1">
<div class="card__title">{{ icon('ticket', size=17) }} Vorgaben und Protokolle</div>
</div>
</div>
<form method="post" action="{{ url_for('settings.system_save') }}">
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
<div class="card__body">
<div class="form-grid">
{{ field('default_valid_days', 'Vorgeschlagene Laufzeit neuer Tickets (Tage)',
value=values.default_valid_days, type='number') }}
{{ select_field('log_rotate_interval', 'Rotationsintervall',
specs['log_rotate_interval'].choices,
selected=values.log_rotate_interval) }}
{{ field('log_rotate_keep', 'Aufbewahrte Rotationen', value=values.log_rotate_keep,
type='number') }}
{{ field('trash_retention_days', 'Papierkorb-Aufbewahrung (Tage)',
value=values.trash_retention_days, type='number') }}
</div>
</div>
<div class="card__footer"><button class="btn btn--primary" type="submit">Speichern</button></div>
</form>
</section>
</div>
<div class="stack">
<section class="card">
<div class="card__header">
<div class="flex-1"><div class="card__title">{{ icon('server', size=17) }} Host</div></div>
</div>
<div class="card__body stack">
<form method="post" action="{{ url_for('settings.set_hostname') }}" class="stack stack--tight">
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
{{ field('hostname', 'Hostname', value=hostname, disabled=not sysops_available) }}
<button class="btn btn--sm" type="submit" {% if not sysops_available %}disabled{% endif %}>
Hostname setzen
</button>
</form>
<div class="divider"></div>
<form method="post" action="{{ url_for('settings.set_timezone') }}" class="stack stack--tight">
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
<label class="field">
<span class="field__label">Zeitzone</span>
<select class="select" name="timezone" {% if not sysops_available %}disabled{% endif %}>
{% for zone in timezones %}
<option value="{{ zone }}" {% if zone == timezone %}selected{% endif %}>{{ zone }}</option>
{% endfor %}
</select>
</label>
<button class="btn btn--sm" type="submit" {% if not sysops_available %}disabled{% endif %}>
Zeitzone setzen
</button>
</form>
</div>
</section>
{% if current_user.is_admin %}
<section class="card">
<div class="card__header">
<div class="flex-1">
<div class="card__title">{{ icon('sliders', size=17) }} Navigationsreihenfolge</div>
<div class="card__sub">Gilt fuer alle Benutzer</div>
</div>
</div>
<form method="post" action="{{ url_for('admin.nav_order') }}">
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
<div class="card__body">
<ol class="stack stack--tight">
{% for key in nav_order %}
{% set item = nav_items | selectattr('key', 'equalto', key) | list | first %}
{% if item %}
<li class="row row--tight">
<input type="hidden" name="order" value="{{ item.key }}">
<span class="flex-1">{{ icon(item.icon, size=15) }} {{ item.label }}</span>
</li>
{% endif %}
{% endfor %}
</ol>
</div>
<div class="card__footer">
<button class="btn btn--sm" type="submit">Reihenfolge speichern</button>
</div>
</form>
</section>
{% endif %}
</div>
</div>
{% endblock %}
@@ -0,0 +1,84 @@
{% extends "tesm_core/base.html" %}
{% from "tesm_core/macros.html" import field, textarea_field, alert %}
{% block page_title %}Anbieterdaten{% endblock %}
{% block page_sub %}Reisen in jeder ausgestellten Lizenz mit{% endblock %}
{% block content %}
{{ alert('Diese Angaben werden in jede Lizenzdatei eingebettet und beim Kunden auf der
Lizenzseite angezeigt -- inklusive Logo. Der oeffentliche Endpunkt entscheidet zusaetzlich,
welche Basis-URL in kundengerichteten Links steht.', 'info') }}
<div class="grid grid--split">
<section class="card">
<div class="card__header">
<div class="flex-1"><div class="card__title">{{ icon('building', size=17) }} Kontakt</div></div>
</div>
<form method="post" action="{{ url_for('settings.vendor_save') }}" enctype="multipart/form-data">
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
<div class="card__body stack">
<div class="form-grid">
{{ field('vendor_name', 'Anbietername', value=values.vendor_name,
disabled=not current_user.has('settings_vendor.edit')) }}
{{ field('vendor_email', 'E-Mail', value=values.vendor_email, type='email',
disabled=not current_user.has('settings_vendor.edit')) }}
{{ field('vendor_phone', 'Telefon', value=values.vendor_phone,
disabled=not current_user.has('settings_vendor.edit')) }}
</div>
{{ textarea_field('vendor_address', 'Adresse', value=values.vendor_address, rows=3) }}
{{ field('master_endpoint', 'Oeffentlicher Endpunkt', value=values.master_endpoint,
placeholder='https://lizenz.firma.de',
disabled=not current_user.has('settings_vendor.edit'),
hint_text='Basis-URL, unter der Kundeninstanzen diesen Server erreichen. Wird in
jede Lizenz eingebettet und fuer alle kundengerichteten Links verwendet -- statt
des Host-Headers der aktuellen Anfrage, der bei internen Aufrufen falsch waere.',
extra_class='field--full') }}
<fieldset class="fieldset">
<legend>Logo (maximal {{ max_logo_kb }} KB)</legend>
<div class="stack stack--tight">
{% if values.vendor_logo_base64 %}
<img src="{{ values.vendor_logo_base64 }}" alt="Aktuelles Logo"
style="max-height:64px;background:var(--surface-2);padding:var(--sp-2);border-radius:var(--r-sm)">
{% else %}
<p class="faint fs-sm">Kein Logo hinterlegt.</p>
{% endif %}
<input class="input" type="file" name="vendor_logo" accept="image/png,image/jpeg,image/svg+xml"
{% if not current_user.has('settings_vendor.edit') %}disabled{% endif %}>
<span class="field__hint">
Ein zu grosses Logo wird abgelehnt, die uebrigen Angaben werden trotzdem gespeichert.
</span>
</div>
</fieldset>
</div>
{% if current_user.has('settings_vendor.edit') %}
<div class="card__footer modal__footer--split">
{% if values.vendor_logo_base64 %}
<button class="btn btn--sm btn--ghost" type="submit"
formaction="{{ url_for('settings.vendor_logo_remove') }}">Logo entfernen</button>
{% else %}<span></span>{% endif %}
<button class="btn btn--primary" type="submit">Speichern</button>
</div>
{% endif %}
</form>
</section>
<section class="card">
<div class="card__header">
<div class="flex-1"><div class="card__title">{{ icon('info', size=17) }} Wo das erscheint</div></div>
</div>
<div class="card__body">
<ul class="stack stack--tight fs-sm muted">
<li>Auf der Lizenzseite der Kundeninstanz (Name, Kontakt, Logo).</li>
<li>In der E-Mail, die eine Lizenz ausliefert.</li>
<li>Im Self-Service und im Kundenportal.</li>
</ul>
<div class="divider"></div>
<p class="fs-sm muted">
Die Angaben werden zum Zeitpunkt der Ausstellung in die Lizenz kopiert und signiert. Eine
Aenderung hier wirkt auf neue Lizenzen -- bestehende behalten ihren Stand, bis eine neue
Revision signiert wird.
</p>
</div>
</section>
</div>
{% endblock %}
@@ -0,0 +1,34 @@
{% extends "tesm_core/base.html" %}
{% from "tesm_core/macros.html" import alert %}
{% block page_title %}Signaturschluessel{% endblock %}
{% block page_sub %}Schluessel-ID {{ key_id }}{% endblock %}
{% block breadcrumb %}
<nav class="breadcrumb">
<span><a href="{{ url_for('own_license.overview') }}">Eigene Lizenz</a></span><span>Signaturschluessel</span>
</nav>
{% endblock %}
{% block content %}
{{ alert('Der oeffentliche Teil ist kein Geheimnis -- er reist in jeder ausgestellten Lizenz mit.
Ihn hier anzuzeigen erlaubt es, ihn unabhaengig zu vergleichen, bevor ein Kunde ihn als
vertrauenswuerdig hinterlegt.', 'info') }}
{% if health.problems %}
{% for problem in health.problems %}{{ alert(problem, 'danger') }}{% endfor %}
{% endif %}
<section class="card" style="max-width:760px">
<div class="card__header">
<div class="flex-1"><div class="card__title">{{ icon('key', size=17) }} Oeffentlicher Schluessel</div>
<div class="card__sub">Erzeugt {{ created_at | dt }}</div></div>
<button class="btn btn--sm" type="button" data-copy="#pubkey">{{ icon('copy', size=14) }} Kopieren</button>
</div>
<div class="card__body stack">
<dl class="kv fs-sm">
<dt>Schluessel-ID</dt><dd class="mono">{{ key_id }}</dd>
<dt>Verfahren</dt><dd>Ed25519 (EdDSA)</dd>
</dl>
<pre class="code-block code-block--wrap" id="pubkey">{{ public_key }}</pre>
{{ alert('Der private Teil liegt ausschliesslich auf diesem Host (Modus 0600) und ist aus dem
Deployment-Abgleich ausgeschlossen. Ginge er verloren, waere jede
ausgestellte Lizenz nicht mehr pruefbar -- bitte getrennt sichern.', 'warning') }}
</div>
</section>
{% endblock %}
@@ -0,0 +1,259 @@
{% extends "tesm_core/base.html" %}
{% from "tesm_core/macros.html" import field, checkbox, textarea_field, pill, alert, empty_state %}
{% block page_title %}Ticket {{ ticket.id[:12] }}&hellip;{% endblock %}
{% block page_sub %}
{{ ticket.customer_name }} &middot; {{ type_labels.get(ticket.type, ticket.type) }}
{% if ticket.role != 'customer' %}&middot; {{ role_labels.get(ticket.role, ticket.role) }}{% endif %}
{% endblock %}
{% block breadcrumb %}
<nav class="breadcrumb">
<span><a href="{{ url_for('tickets.overview') }}">Tickets</a></span>
<span><a href="{{ url_for('customers.detail', customer_id=ticket.customer_id) }}">{{ ticket.customer_name }}</a></span>
<span>Ticket</span>
</nav>
{% endblock %}
{% block content %}
{% if endpoint_missing %}
{{ alert(endpoint_missing, 'danger', 'Keine Ausstellung moeglich') }}
{% endif %}
{% if ticket.status == 'blocked' %}
{{ alert('Dieses Ticket ist gesperrt. Es lassen sich keine neuen Lizenzen ausstellen -- weder
hier noch ueber den Self-Service des Kunden. Bereits ausgestellte Lizenzen bleiben gueltig.',
'danger', 'Gesperrt') }}
{% endif %}
<div class="grid grid--split">
<div class="stack">
<section class="card">
<div class="card__header">
<div class="flex-1">
<div class="card__title">{{ icon('shield', size=17) }} Lizenz ausstellen</div>
<div class="card__sub">
{% if has_open_license %}
Es existiert bereits eine offene Lizenz -- widerrufen Sie sie zuerst.
{% else %}
Erzeugt eine signierte Lizenzdatei mit den Werten dieses Tickets.
{% endif %}
</div>
</div>
</div>
<div class="card__body row row--tight">
{% if current_user.has('licenses.create') %}
<form method="post" action="{{ url_for('licenses.issue', ticket_id=ticket.id) }}"
data-no-guard>
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
<button class="btn btn--primary" type="submit"
{% if has_open_license or ticket.status == 'blocked' %}disabled{% endif %}
data-busy-label="Stelle aus ...">
{{ icon('plus', size=15) }} Lizenz ausstellen
</button>
</form>
{% if ticket.role == 'customer' %}
<form method="post" action="{{ url_for('licenses.issue', ticket_id=ticket.id) }}"
data-no-guard>
<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 unveraendert."
data-confirm-title="Testlizenz" data-confirm-ok="Ausstellen"
data-confirm-style="primary">
Als Testlizenz ({{ trial_days }} Tage, alle Module)
</button>
</form>
{% endif %}
{% else %}
<p class="muted fs-sm">Ihnen fehlt das Recht, Lizenzen auszustellen.</p>
{% endif %}
</div>
</section>
<section class="card">
<div class="card__header">
<div class="flex-1">
<div class="card__title">{{ icon('history', size=17) }} Lizenzen dieses Tickets</div>
<div class="card__sub">{{ licenses | length }} insgesamt</div>
</div>
</div>
{% if licenses %}
<div class="table-wrap">
<table class="table table--compact">
<thead><tr><th>Lizenz</th><th>Ausgestellt</th><th>Zustand</th><th>Laeuft ab</th><th>Host</th></tr></thead>
<tbody>
{% for entry in licenses %}
<tr>
<td class="mono fs-sm">
<a href="{{ url_for('licenses.detail', license_id=entry.license_id) }}">
{{ entry.license_id[:12] }}&hellip;</a>
{% if entry.type != ticket.type %}{{ pill(entry.type, 'accent') }}{% endif %}
</td>
<td class="fs-sm">{{ entry.issued_at | dt }}</td>
<td>
{% if entry.status == 'active' %}{{ pill('aktiv', 'online') }}
{% elif entry.status == 'issued' %}{{ pill('ausgestellt', 'info') }}
{% elif entry.status == 'revoked' %}{{ pill('widerrufen', 'danger') }}
{% else %}{{ pill('deaktiviert', '') }}{% endif %}
</td>
<td class="fs-sm">
{% if entry.lifetime %}unbefristet{% else %}{{ entry.expires_at | date }}{% endif %}
</td>
<td class="fs-sm">{{ entry.hostname or '&ndash;' | safe }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
{{ empty_state('Noch keine Lizenz', 'Stellen Sie die erste Lizenz aus.', 'shield') }}
{% endif %}
</section>
{% if self_service_url %}
<section class="card">
<div class="card__header">
<div class="flex-1">
<div class="card__title">{{ icon('link', size=17) }} Self-Service fuer den Kunden</div>
<div class="card__sub">
Ohne Anmeldung nutzbar &ndash; die Berechtigung ist der Besitz dieses Links
</div>
</div>
</div>
<div class="card__body stack">
<div class="input-group">
<input class="input input--mono" id="ss-url" value="{{ self_service_url }}" readonly>
<button class="btn" type="button" data-copy="#ss-url">{{ icon('copy', size=15) }}</button>
</div>
<p class="fs-xs faint">
Damit kann der Kunde seine Lizenz herunterladen, bei einem Systemwechsel freigeben und
neu ausstellen. Der Link enthaelt 192 Bit Zufall und ist nicht ratbar -- behandeln Sie
ihn wie ein Passwort.
</p>
<div class="divider"></div>
<div class="input-group">
<input class="input input--mono" id="portal-url" value="{{ portal_url }}" readonly>
<button class="btn" type="button" data-copy="#portal-url">{{ icon('copy', size=15) }}</button>
</div>
<p class="fs-xs faint">
Kundenportal: der Kunde fordert dort selbst einen zeitlich begrenzten Zugangslink an
und pflegt seine Stammdaten.
</p>
</div>
</section>
{% elif ticket.role != 'customer' %}
{{ alert('Fuer Lizenzserver-Tickets ist der Self-Service vollstaendig gesperrt -- diese
Lizenzen werden ausschliesslich hier verwaltet.', 'info') }}
{% endif %}
</div>
<div class="stack">
<section class="card">
<div class="card__header">
<div class="flex-1"><div class="card__title">{{ icon('sliders', size=17) }} Ticketdaten</div></div>
{% if ticket.status == 'blocked' %}{{ pill('gesperrt', 'danger') }}
{% else %}{{ pill('offen', 'online') }}{% endif %}
</div>
<form method="post" action="{{ url_for('tickets.update', ticket_id=ticket.id) }}">
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
<div class="card__body stack">
{{ alert('Aenderungen wirken auf die naechste Ausstellung. Kunde und Rolle sind
unveraenderlich.', 'info') }}
<label class="field">
<span class="field__label">Lizenztyp</span>
<select class="select" name="type" id="ticket-type"
{% if not current_user.has('tickets.edit') %}disabled{% endif %}>
{% for value in types %}
<option value="{{ value }}" {% if value == ticket.type %}selected{% endif %}>
{{ type_labels.get(value, value) }}
</option>
{% endfor %}
</select>
</label>
{% if ticket.role == 'customer' %}
<fieldset class="fieldset" id="module-box">
<legend>Module</legend>
<div class="stack stack--tight">
{% for module in modules %}
{{ checkbox('modules', module_labels[module], value=module,
checked=module in ticket_modules,
disabled=not current_user.has('tickets.edit')) }}
{% endfor %}
</div>
</fieldset>
{% endif %}
{{ field('valid_days', 'Laufzeit (Tage)', value=ticket.valid_days, type='number',
disabled=not current_user.has('tickets.edit')) }}
{{ checkbox('lifetime', 'Unbefristet', checked=ticket.lifetime,
disabled=not current_user.has('tickets.edit')) }}
{{ textarea_field('notes', 'Interne Notiz', value=ticket.notes, rows=2) }}
<div class="divider"></div>
<dl class="kv fs-xs">
<dt>Ticket-ID</dt><dd class="mono break-all">{{ ticket.id }}</dd>
<dt>Rolle</dt><dd>{{ role_labels.get(ticket.role, ticket.role) }}</dd>
<dt>Angelegt</dt><dd>{{ ticket.created_at | dt }} von {{ ticket.created_by or '-' }}</dd>
</dl>
</div>
{% if current_user.has('tickets.edit') %}
<div class="card__footer">
<button class="btn btn--primary" type="submit">Speichern</button>
</div>
{% endif %}
</form>
</section>
{% if current_user.has('tickets.edit') or current_user.has('tickets.delete') %}
<section class="card">
<div class="card__header">
<div class="flex-1"><div class="card__title">{{ icon('lock', size=17) }} Sperre und Loeschung</div></div>
</div>
<div class="card__body stack stack--tight">
{% if current_user.has('tickets.edit') %}
<form method="post" action="{{ url_for('tickets.toggle_block', ticket_id=ticket.id) }}"
data-no-guard>
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
<button class="btn btn--sm btn--block {{ 'btn--danger' if ticket.status != 'blocked' }}"
type="submit"
data-confirm="{% if ticket.status == 'blocked' %}Ticket entsperren?{% else %}Ticket sperren? Der Kunde kann danach keine neuen Lizenzen mehr ausstellen.{% endif %}"
data-confirm-title="Ticketsperre" data-confirm-ok="Bestaetigen"
data-confirm-style="{{ 'primary' if ticket.status == 'blocked' else 'danger' }}">
{{ 'Entsperren' if ticket.status == 'blocked' else 'Sperren' }}
</button>
</form>
{% endif %}
{% if current_user.has('tickets.delete') %}
<form method="post" action="{{ url_for('tickets.delete', ticket_id=ticket.id) }}"
data-no-guard>
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
<button class="btn btn--sm btn--ghost danger-text btn--block" type="submit"
{% if has_open_license %}disabled title="Erst die offene Lizenz widerrufen"{% endif %}
data-confirm="Ticket und seine gesamte Lizenzhistorie loeschen? Das laesst sich nicht rueckgaengig machen."
data-confirm-title="Ticket loeschen" data-confirm-ok="Loeschen">
Ticket loeschen
</button>
</form>
{% endif %}
</div>
</section>
{% endif %}
</div>
</div>
{% endblock %}
{% block scripts %}
{% if ticket.role == 'customer' %}
<script type="module" nonce="{{ csp_nonce }}">
const type = document.getElementById("ticket-type");
const moduleBox = document.getElementById("module-box");
if (type && moduleBox) {
const sync = () => {
const custom = type.value === "custom";
moduleBox.hidden = !custom;
};
type.addEventListener("change", sync);
sync();
}
</script>
{% endif %}
{% endblock %}
@@ -0,0 +1,139 @@
{% extends "tesm_core/base.html" %}
{% from "tesm_core/macros.html" import field, checkbox, textarea_field, alert, pill %}
{% block page_title %}Ticket anlegen{% endblock %}
{% block page_sub %}Kunde, Typ, Module und Laufzeit{% endblock %}
{% block breadcrumb %}
<nav class="breadcrumb">
<span><a href="{{ url_for('tickets.overview') }}">Tickets</a></span><span>Neu</span>
</nav>
{% endblock %}
{% block content %}
<form method="post" action="{{ url_for('tickets.create') }}">
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
<div class="grid grid--split">
<div class="stack">
<section class="card">
<div class="card__header">
<div class="flex-1"><div class="card__title">{{ icon('ticket', size=17) }} Grunddaten</div></div>
</div>
<div class="card__body stack">
<label class="field">
<span class="field__label">Kunde <span class="req">*</span></span>
<select class="select" name="customer_id" required>
<option value="">bitte waehlen</option>
{% for customer in customers %}
<option value="{{ customer.id }}"
{% if preselected_customer == customer.id %}selected{% endif %}>
{{ customer.name }}{% if customer.city %} &middot; {{ customer.city }}{% endif %}
</option>
{% endfor %}
</select>
<span class="field__hint">
Der Kunde ist nach dem Anlegen unveraenderlich -- der Self-Service-Link ist an ihn
gebunden.
</span>
</label>
<label class="field">
<span class="field__label">Lizenztyp</span>
<select class="select" name="type" id="ticket-type">
{% for value in types %}
<option value="{{ value }}" {% if value == 'standard' %}selected{% endif %}>
{{ type_labels.get(value, value) }}
</option>
{% endfor %}
</select>
<span class="field__hint">
Standard: Grundfunktionen. Individuell: Module frei waehlbar. Enterprise: alle
Module. Testlizenz: 30 Tage.
</span>
</label>
<fieldset class="fieldset" id="module-box">
<legend>Module (nur bei &bdquo;Individuell&ldquo;)</legend>
<div class="stack stack--tight">
{% for module in modules %}
{{ checkbox('modules', module_labels[module], value=module) }}
{% endfor %}
</div>
</fieldset>
<div class="form-grid">
{{ field('valid_days', 'Laufzeit (Tage)', value=default_valid_days, type='number') }}
</div>
{{ checkbox('lifetime', 'Unbefristet',
hint_text='Nicht kombinierbar mit einer Testlizenz -- das waere eine
permanente Lizenz ohne Module.') }}
{{ textarea_field('notes', 'Interne Notiz', rows=2) }}
</div>
</section>
{% if may_create_license_server %}
<section class="card card--accent">
<div class="card__header">
<div class="flex-1">
<div class="card__title">{{ icon('share', size=17) }} Lizenzserver-Lizenz</div>
<div class="card__sub">
Erzeugt eine neue Vertrauensgrenze -- der Empfaenger verwaltet danach eigene Kunden
</div>
</div>
</div>
<div class="card__body stack">
{{ alert('Ein so lizenzierter Lizenzserver darf alles, was ein Lizenzserver koennen
soll -- ausser selbst weitere Lizenzserver-Lizenzen auszustellen. Er meldet seinen
Bestand automatisch hierher zurueck. Module sind fuer diese Rolle bedeutungslos und
werden ignoriert. Self-Service und Kundenportal sind fuer solche Tickets gesperrt.',
'warning') }}
<label class="check">
<input type="checkbox" name="role" value="license_server">
<span>Dies ist eine Lizenzserver-Lizenz</span>
</label>
</div>
</section>
{% endif %}
</div>
<section class="card">
<div class="card__header">
<div class="flex-1"><div class="card__title">{{ icon('info', size=17) }} Wie es weitergeht</div></div>
</div>
<div class="card__body">
<ol class="stack stack--tight fs-sm muted" style="list-style:decimal;padding-left:1.2em">
<li>Ticket anlegen (dieser Schritt).</li>
<li>Auf der Ticketseite eine Lizenz ausstellen -- optional als 30-Tage-Testlizenz mit
allen Modulen.</li>
<li>Lizenzdatei herunterladen oder per E-Mail versenden.</li>
<li>Der Kunde spielt sie ein und aktiviert sie; die Aktivierung bindet sie an seine
Maschine.</li>
</ol>
<div class="divider"></div>
<p class="fs-sm muted">
Der Self-Service-Link des Tickets erlaubt dem Kunden, seine Lizenz selbst
herunterzuladen und bei einem Systemwechsel freizugeben -- ohne Support-Anruf.
</p>
</div>
<div class="card__footer">
<a class="btn" href="{{ url_for('tickets.overview') }}">Abbrechen</a>
<button class="btn btn--primary" type="submit">Ticket anlegen</button>
</div>
</section>
</div>
</form>
{% endblock %}
{% block scripts %}
<script type="module" nonce="{{ csp_nonce }}">
const type = document.getElementById("ticket-type");
const moduleBox = document.getElementById("module-box");
const sync = () => {
const custom = type.value === "custom";
moduleBox.hidden = !custom;
moduleBox.querySelectorAll("input").forEach((box) => (box.disabled = !custom));
};
type.addEventListener("change", sync);
sync();
</script>
{% endblock %}
@@ -0,0 +1,91 @@
{% extends "tesm_core/base.html" %}
{% from "tesm_core/macros.html" import pill, search_box, empty_state, alert %}
{% block page_title %}Tickets{% endblock %}
{% block page_sub %}
{{ tickets | length }} Tickets &middot; ein Ticket ist der dauerhafte Anspruch auf eine Lizenz
{% endblock %}
{% block topbar_extra %}
{{ search_box('tickets-table', 'Kunde, Typ, ID ...') }}
{% if current_user.has('tickets.create') %}
<a class="btn btn--primary btn--sm" href="{{ url_for('tickets.new') }}">
{{ icon('plus', size=15) }} Ticket anlegen
</a>
{% endif %}
{% endblock %}
{% block content %}
{% if endpoint_missing %}
{{ alert(endpoint_missing, 'danger', 'Keine Ausstellung moeglich') }}
{% endif %}
{{ alert('Ein Ticket beschreibt den Anspruch (Typ, Module, Laufzeit); die Lizenz ist die daraus
erzeugte, signierte Datei. Aenderungen am Ticket wirken auf die naechste
Ausstellung -- bereits ausgestellte Lizenzen bleiben unveraendert.', 'info') }}
<section class="card">
<div class="table-wrap">
<table class="table" id="tickets-table" data-behavior="sortable">
<thead>
<tr>
<th class="sortable">Kunde</th>
<th class="sortable">Ticket</th>
<th class="sortable">Typ</th>
<th>Module</th>
<th class="sortable">Laufzeit</th>
<th class="sortable">Zustand</th>
<th>Offene Lizenz</th>
</tr>
</thead>
<tbody>
{% for ticket in tickets %}
<tr data-filter-text="{{ ticket.customer_name }} {{ ticket.type }} {{ ticket.id }} {{ ticket.role }}">
<td>
<a class="fw-600" href="{{ url_for('customers.detail', customer_id=ticket.customer_id) }}">
{{ ticket.customer_name }}
</a>
{% if ticket.role != 'customer' %}
{{ pill(role_labels.get(ticket.role, ticket.role), 'accent') }}
{% endif %}
</td>
<td class="mono fs-sm">
<a href="{{ url_for('tickets.detail', ticket_id=ticket.id) }}">{{ ticket.id[:12] }}&hellip;</a>
</td>
<td class="fs-sm">{{ type_labels.get(ticket.type, ticket.type) }}</td>
<td class="fs-xs">
{% set mods = ticket.modules | from_json %}
{% if mods %}{{ mods | map('replace', '_', ' ') | join(', ') }}
{% else %}<span class="faint">keine</span>{% endif %}
</td>
<td class="fs-sm">
{% if ticket.lifetime %}{{ pill('unbefristet', 'online') }}
{% else %}{{ ticket.valid_days }} Tage{% endif %}
</td>
<td>
{% if ticket.status == 'blocked' %}{{ pill('gesperrt', 'danger') }}
{% else %}{{ pill('offen', 'online') }}{% endif %}
</td>
<td class="fs-sm">
{% set entry = open_licenses.get(ticket.id) %}
{% if entry %}
<a href="{{ url_for('licenses.detail', license_id=entry.license_id) }}">
{{ entry.status }}
</a>
{% if entry.lifetime %}{{ pill('unbefristet', 'online') }}
{% elif entry.color == 'red' %}{{ pill(entry.days_left ~ ' Tage', 'danger') }}
{% elif entry.color == 'orange' %}{{ pill(entry.days_left ~ ' Tage', 'warning') }}
{% else %}{{ pill(entry.days_left ~ ' Tage', 'online') }}{% endif %}
{% else %}
<span class="faint">keine</span>
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% if not tickets %}
{{ empty_state('Keine Tickets', 'Legen Sie das erste Ticket fuer einen Kunden an.', 'ticket') }}
{% endif %}
</section>
{% endblock %}
@@ -0,0 +1,7 @@
"""WSGI-Einstiegspunkt: ``gunicorn tesm_license.wsgi:app``."""
from __future__ import annotations
from . import create_app
app = create_app()