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:
@@ -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))
|
||||
@@ -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 · 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) · {{ 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] }}…</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] }}…</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 '–' | 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 · Quelle {{ reported.source_license_id[:12] }}…
|
||||
{% if reported.last_reported_at %}
|
||||
· <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' }} · {{ 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] }}…</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] }}…</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 '–' | safe }}</td>
|
||||
<td class="fs-sm">{{ customer.contact_email or '–' | safe }}</td>
|
||||
<td class="fs-sm">{{ customer.city or '–' | 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 · {{ 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' %}
|
||||
· {{ 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 '–' | 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] }}…{% endblock %}
|
||||
{% block page_sub %}
|
||||
{{ license.customer_name }} · {{ type_labels.get(license.type, license.type) }}
|
||||
{% if license.role != 'customer' %}· {{ 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] }}…</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 '–' | 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 '–' | 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 %} · 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] }}…</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 '–' | 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 · 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) }} ·
|
||||
{{ 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 '–' | 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 %} · {{ vendor.phone }}{% endif %}
|
||||
{% if vendor.email %} · {{ 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 · Lizenz {{ license.license_id[:12] }}…{% 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 %}· {{ entry.city }}{% endif %}
|
||||
· {{ 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] }}…</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] }}…</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] }}…</td>
|
||||
<td class="fs-sm">{{ source.hostname or '–' | 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) }} ·
|
||||
{% 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 → 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 '–' | safe }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</details>
|
||||
{% endif %}
|
||||
|
||||
<p class="auth-footnote">
|
||||
<a href="{{ portal_url }}">Stammdaten pflegen</a>
|
||||
{% if vendor.name %} · {{ vendor.name }}{% endif %}
|
||||
{% if vendor.phone %} · {{ 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
|
||||
„Antwortcode des Lizenzservers“ 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) ·
|
||||
{% 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 „Geheimnisse“.
|
||||
</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] }}…{% endblock %}
|
||||
{% block page_sub %}
|
||||
{{ ticket.customer_name }} · {{ type_labels.get(ticket.type, ticket.type) }}
|
||||
{% if ticket.role != 'customer' %}· {{ 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] }}…</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 '–' | 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 – 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 %} · {{ 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 „Individuell“)</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 · 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] }}…</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()
|
||||
Reference in New Issue
Block a user