Compare commits

...
59 Commits
Author SHA1 Message Date
alientim 1a13ce4c2a Version 2.8.44 / Lizenzserver 2.8.22: Client-Konfiguration am richtigen Ort, Fusszeile bleibt stehen 2026-09-17 11:24:18 +02:00
alientim 7f8f4f9de8 Version 2.8.42: sichtbare Pfeile an allem Aufklappbaren 2026-09-17 10:42:02 +02:00
alientim e098306aef Version 2.8.39: gescheitertes pip bricht ab, keine ==-Bindung mehr 2026-09-17 09:39:04 +02:00
alientim 68bda48d72 Version 2.8.38: eine Rechnung fuer die Resttage, Module benannt 2026-09-17 09:26:34 +02:00
alientim 50b212caec Version 2.8.37: eigene Sprachumgebung fuer die Skripte 2026-09-17 09:01:07 +02:00
alientim 0117489b5a Version 2.8.36: Lizenzabfrage fuer ein RMM auf der Maschine 2026-09-17 08:49:27 +02:00
alientim f0f0d05e19 Lizenzserver 2.8.16: kein Abgleich in die Vergangenheit 2026-09-16 22:47:00 +02:00
alientim 0024a116ce Lizenzserver 2.8.15: auch eine unbefristete Lizenz laesst sich zurueckholen 2026-09-16 22:40:32 +02:00
alientim 34a4923304 Lizenzserver 2.8.14: die Ticketgrenze wandert nicht mit den Revisionen 2026-09-16 22:26:30 +02:00
alientim 98c2b2aa14 Lizenzserver 2.8.13: Ticket um Tage verlaengern, Lizenz auf das Ticket setzen 2026-09-16 22:19:12 +02:00
alientim 641ea22b3c Lizenzserver 2.8.12: Ticket als Anspruch, Lizenz als Urkunde daraus 2026-09-16 22:00:59 +02:00
alientim b984e22074 Version 2.8.35: der reparierte venv bleibt fuer den Dienstbenutzer lesbar 2026-09-16 21:32:23 +02:00
alientim 1d4e22b082 Version 2.8.34: Skripte aus dem Baum ueber bash aufrufen 2026-09-16 21:23:54 +02:00
alientim 23c091e99f Version 2.8.33: Selbstreparatur der Python-Umgebung nach einem Release-Upgrade 2026-09-16 21:17:09 +02:00
alientim 617fe10d8d Version 2.8.32: Rueckbau einer Installation (deploy/uninstall.sh) 2026-09-16 19:55:06 +02:00
alientim 7fd5ba8b54 Version 2.8.31: Protokollverlauf verlangt keine Bestaetigung mehr 2026-09-16 18:07:33 +02:00
alientim a60cc07ab8 Version 2.8.30: Begruessungstext schweigt ueber entfernte Installationen 2026-09-16 17:56:57 +02:00
alientim 0c9e346b66 Version 2.8.29 / Lizenzserver 2.8.5: Release-Upgrade von Ubuntu bricht den venv nicht mehr 2026-09-16 17:44:35 +02:00
alientim cf5cf8622b Version 2.8.27: Live-Protokoll mit Stufen, Auftraege fallen nicht mehr heraus 2026-09-16 11:33:13 +02:00
alientim 4c1d6387c0 Version 2.8.24: Massenbefehl, Mitlesen, F5 2026-09-13 15:11:23 +02:00
alientim 91711a51fc Version 2.8.23: nicht das teure Verfahren zuerst 2026-09-13 14:52:12 +02:00
alientim 1c90251b0f Version 2.8.22: Alt+F4, Sitzung im Fenster, bewaehrtes Verfahren zuerst 2026-09-13 14:30:39 +02:00
alientim 4c9ed20da4 Version 2.8.21: die Seite wartet nicht mehr stumm 2026-09-13 14:14:39 +02:00
alientim b266bac055 Version 2.8.20: einstellbares Leerlauflimit, Stille beim Aufbau benannt 2026-09-13 14:05:23 +02:00
alientim 051d3bc649 Version 2.8.18: Versionspruefung im Update, zweiter Anlauf beim Bildschirm 2026-09-13 13:23:41 +02:00
alientim 4041156a19 Version 2.8.17: keine Vorauswahl, keine Messung im Seitenaufbau 2026-09-13 13:07:06 +02:00
alientim ec5cbb2e17 Version 2.8.15: Probe der Strecke, Grund aus _fail, RFB 3.3 2026-09-13 12:04:51 +02:00
alientim f551ce0ee6 Version 2.8.14: Diagnose in der Bildschirmsitzung, Vorauswahl ohne Passwort 2026-09-13 11:31:42 +02:00
alientim 0c47e1d645 Version 2.8.13: Bildschirmsitzung im Browser (noVNC) 2026-09-13 10:35:14 +02:00
alientim db3558dcbd Version 2.8.12 / tesm-core 2.8.4 2026-09-13 10:10:31 +02:00
alientim 9d819aaa3a Version 2.8.11: die Kachel oeffnet den Bildschirm 2026-09-13 10:09:55 +02:00
alientim 48ce56fe7d Version 2.8.7: drei Standardgruppen, Module abschaltbar 2026-09-13 00:19:55 +02:00
alientim 294d47684b Verzeichnisanmeldung: Wiederholversuch, weniger Runden, Zeiten 2026-09-12 23:46:53 +02:00
alientim e589b4aadb Verzeichniskonfiguration aus 1.2.8 uebernehmen; Sicherungen setzten Schalter falsch 2026-09-12 19:26:43 +02:00
alientim f6b0bbaad1 Version 2.8.0: Rechtekonzept -- eine Zeile je Funktion, Administrator heisst Verwaltung 2026-09-12 13:27:46 +02:00
alientim c3c6eae72d Version 2.7.7: Schriftgroesse der Vorschau wird gerechnet 2026-09-12 01:34:04 +02:00
alientim a1987aeb93 Version 2.7.6: die Vorschau der Unterschrift zeigt das Ergebnis 2026-09-12 01:12:06 +02:00
alientim 1d67c36ed2 Version 2.7.5: jede Aenderung nennt alt und neu 2026-09-12 00:43:10 +02:00
alientim e68f22fd88 Version 2.7.5: die Ordnerzeile sagt, was im Ordner liegt 2026-09-12 00:30:36 +02:00
alientim 2d234571d2 Version 2.7.4: Unterschriften in der Sicherung, unterschrieben ist abgeschlossen 2026-09-12 00:26:24 +02:00
alientim fafda84bc7 Version 2.7.3: Zeugenvermerk einmal und in den Dokumenteigenschaften 2026-09-11 23:45:19 +02:00
alientim cdc3b2cd6e Zahlen im Kommentar geradegerueckt 2026-09-11 23:28:29 +02:00
alientim 35247c895b Version 2.7.2: Umlaute in der Oberflaeche, Einbindungen an der Sitzung 2026-09-11 23:24:29 +02:00
alientim e5ae10814f Version 2.7.1: Signaturverzeichnis ueberlebt die Archivierung 2026-09-11 21:36:25 +02:00
alientim 37a200e983 Version 2.7.0: Pruefung beim Oeffnen, Unterschrift in menschlichem Mass 2026-09-11 21:23:12 +02:00
alientim de9c0d4fbd Version 2.6.1: die Speichernzeile fuehrt zur Entscheidung 2026-09-11 21:03:25 +02:00
alientim 724052ae28 Version 2.6.0: Unterschreiben mit dem Finger, Ablage in eine andere Freigabe 2026-09-11 20:41:48 +02:00
alientim e16236fa65 Version 2.5.2: Wartung ist auch in der Rechtematrix ein eigener Bereich 2026-09-11 20:16:38 +02:00
alientim c52b3626b2 Version 2.5.1: Sitzungsdauer im Lizenzserver, Umlaute, Umleitung alter Adressen 2026-09-11 19:16:27 +02:00
alientim 1ac5dc1637 Version 2.5.0: englische Adressen entlang der Navigation, sparsameres Protokoll 2026-09-11 18:57:23 +02:00
alientim f2d5324449 Version 2.4.1: die Wiederanmeldung kommt aus dem Rechtemodell 2026-09-10 02:00:28 +02:00
alientim 342abb0206 Version 2.4.0: eigene Rechtezeile fuer PDF-Formulare, Download ist Export 2026-09-10 00:08:23 +02:00
alientim 8dd8df9e9f Version 2.3.3: weiter ausfuellbar oder abgeschlossen 2026-09-09 23:43:47 +02:00
alientim 0de90ab6d4 Version 2.3.2: Namen ohne Endung, lesbare Haken, hochkant mehr Hoehe 2026-09-09 23:35:43 +02:00
alientim e60a93120c Version 2.3.1: Formularfelder sitzen richtig, Bildfeld nutzbar 2026-09-09 23:11:50 +02:00
alientim e7ad00a5e5 Version 2.3.0: Speichern mit Ordner und Namen 2026-09-09 22:41:08 +02:00
alientim e70c6e27c1 Version 2.2.1: pdf.js unter .js -- .mjs kennt kein Webserver 2026-09-09 22:06:17 +02:00
alientim e96e4837f1 Version 2.2.0: Vorschau mit Zoom und PDF-Formularen, eine Uploadgrenze 2026-09-09 21:48:07 +02:00
alientim 119293e5e3 Version 2.1.2: Wurzelinstanz auch an der Lizenz erkennen 2026-09-09 00:27:01 +02:00
229 changed files with 36354 additions and 1001 deletions

No files matched your search

+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project] [project]
name = "tesm-license" name = "tesm-license"
version = "2.1.1" version = "2.8.22"
description = "TESM-Lizenzserver -- Kunden, Tickets, Lizenzen, Aktivierung" description = "TESM-Lizenzserver -- Kunden, Tickets, Lizenzen, Aktivierung"
requires-python = ">=3.11" requires-python = ">=3.11"
dependencies = [ dependencies = [
@@ -180,7 +180,7 @@ def _reencrypt_secrets(conn: Any, keystore: Any) -> int:
def _status_pills(user: Any) -> list[dict[str, str]]: def _status_pills(user: Any) -> list[dict[str, str]]:
if not user.is_authenticated or not user.has_any(("licenses.view",)): if not user.is_authenticated or not user.has_any(("licenses.read",)):
return [] return []
from tesm_core.extension import db from tesm_core.extension import db
@@ -204,7 +204,7 @@ def _status_pills(user: Any) -> list[dict[str, str]]:
def _startup_tasks(app: Flask) -> None: def _startup_tasks(app: Flask) -> None:
extension = core(app) extension = core(app)
if not extension.config.web_process: if not extension.config.web_process:
logger.info("Startaufgaben uebersprungen -- kein Webprozess.") logger.info("Startaufgaben übersprungen – kein Webprozess.")
return return
from .services import activation from .services import activation
@@ -59,7 +59,7 @@ def _handle(action: str):
) )
if request.content_length and request.content_length > MAX_BODY_BYTES: if request.content_length and request.content_length > MAX_BODY_BYTES:
return jsonify({"error": "too_large", "message": "Anfrage zu gross."}), 413 return jsonify({"error": "too_large", "message": "Anfrage zu groß."}), 413
payload = request.get_json(silent=True) payload = request.get_json(silent=True)
if not isinstance(payload, dict): if not isinstance(payload, dict):
@@ -13,7 +13,7 @@ from tesm_core.extension import core, db, transaction
from ..services import transfer from ..services import transfer
from .common import log_event from .common import log_event
bp = Blueprint("backup", __name__, url_prefix="/sicherung") bp = Blueprint("backup", __name__, url_prefix="/administration/backup")
MAX_IMPORT_BYTES = 64 * 1024 * 1024 MAX_IMPORT_BYTES = 64 * 1024 * 1024
PREVIEW_SESSION_KEY = "_import_token" PREVIEW_SESSION_KEY = "_import_token"
@@ -24,14 +24,14 @@ def _allowed_categories() -> list[transfer.Category]:
for category in transfer.CATEGORIES: for category in transfer.CATEGORIES:
if category.admin_only and not current_user.is_admin: if category.admin_only and not current_user.is_admin:
continue continue
if category.sensitive and not current_user.has("backup.secrets"): if category.sensitive and not current_user.has("backup_export.secrets"):
continue continue
allowed.append(category) allowed.append(category)
return allowed return allowed
@bp.get("/") @bp.get("/")
@permission_required("backup.view", "backup.export", "backup.execute", mode="any") @permission_required("backup_export.read", "backup_import.read", mode="any")
def overview(): def overview():
conn = db() conn = db()
preview = None preview = None
@@ -50,25 +50,25 @@ def overview():
preview=preview, preview=preview,
min_passphrase=transfer.MIN_PASSPHRASE_LENGTH, min_passphrase=transfer.MIN_PASSPHRASE_LENGTH,
preview_ttl=transfer.PREVIEW_TTL_MINUTES, preview_ttl=transfer.PREVIEW_TTL_MINUTES,
can_secrets=current_user.has("backup.secrets"), can_secrets=current_user.has("backup_export.secrets"),
signing_key_path=str(core().registry["master_key_path"]), signing_key_path=str(core().registry["master_key_path"]),
) )
@bp.post("/export") @bp.post("/export")
@permission_required("backup.export") @permission_required("backup_export.export")
@reauth_required # Die frische Bestaetigung verlangt das Recht selbst (SENSITIVE_ACTIONS).
def export(): def export():
conn = db() conn = db()
allowed = {category.key for category in _allowed_categories()} allowed = {category.key for category in _allowed_categories()}
chosen = [key for key in request.form.getlist("categories") if key in allowed] chosen = [key for key in request.form.getlist("categories") if key in allowed]
if not chosen: if not chosen:
flash("Es wurde keine Kategorie ausgewaehlt.", "warning") flash("Es wurde keine Kategorie ausgewählt.", "warning")
return redirect(url_for("backup.overview")) return redirect(url_for("backup.overview"))
passphrase = request.form.get("passphrase") or "" passphrase = request.form.get("passphrase") or ""
if passphrase != (request.form.get("passphrase_repeat") or ""): if passphrase != (request.form.get("passphrase_repeat") or ""):
flash("Die beiden Passphrasen stimmen nicht ueberein.", "danger") flash("Die beiden Passphrasen stimmen nicht überein.", "danger")
return redirect(url_for("backup.overview")) return redirect(url_for("backup.overview"))
try: try:
@@ -94,18 +94,18 @@ def export():
) )
@bp.post("/import/vorschau") @bp.post("/import/preview")
@permission_required("backup.execute") @permission_required("backup_import.execute")
@reauth_required @reauth_required
def import_preview(): def import_preview():
conn = db() conn = db()
upload = request.files.get("backup_file") upload = request.files.get("backup_file")
if upload is None or not upload.filename: if upload is None or not upload.filename:
flash("Keine Datei ausgewaehlt.", "danger") flash("Keine Datei ausgewählt.", "danger")
return redirect(url_for("backup.overview")) return redirect(url_for("backup.overview"))
raw = upload.stream.read(MAX_IMPORT_BYTES + 1) raw = upload.stream.read(MAX_IMPORT_BYTES + 1)
if len(raw) > MAX_IMPORT_BYTES: if len(raw) > MAX_IMPORT_BYTES:
flash("Die Datei ist zu gross.", "danger") flash("Die Datei ist zu groß.", "danger")
return redirect(url_for("backup.overview")) return redirect(url_for("backup.overview"))
try: try:
@@ -124,28 +124,28 @@ def import_preview():
scope="sicherung", scope="sicherung",
) )
flash( flash(
f"Datei geoeffnet. Sie haben {transfer.PREVIEW_TTL_MINUTES} Minuten Zeit, die " f"Datei geöffnet. Sie haben {transfer.PREVIEW_TTL_MINUTES} Minuten Zeit, die "
"Uebernahme zu bestaetigen.", "Übernahme zu bestätigen.",
"success", "success",
) )
return redirect(url_for("backup.overview")) return redirect(url_for("backup.overview"))
@bp.post("/import/uebernehmen") @bp.post("/import/apply")
@permission_required("backup.execute") @permission_required("backup_import.execute")
@reauth_required @reauth_required
def import_apply(): def import_apply():
extension = core() extension = core()
conn = db() conn = db()
token = session.get(PREVIEW_SESSION_KEY, "") token = session.get(PREVIEW_SESSION_KEY, "")
if not token: if not token:
flash("Es liegt keine geoeffnete Sicherung vor.", "danger") flash("Es liegt keine geöffnete Sicherung vor.", "danger")
return redirect(url_for("backup.overview")) return redirect(url_for("backup.overview"))
allowed = {category.key for category in _allowed_categories()} allowed = {category.key for category in _allowed_categories()}
chosen = [key for key in request.form.getlist("categories") if key in allowed] chosen = [key for key in request.form.getlist("categories") if key in allowed]
if not chosen: if not chosen:
flash("Es wurde keine Kategorie ausgewaehlt.", "warning") flash("Es wurde keine Kategorie ausgewählt.", "warning")
return redirect(url_for("backup.overview")) return redirect(url_for("backup.overview"))
try: try:
@@ -172,7 +172,7 @@ def import_apply():
action="backup.imported", action="backup.imported",
detail="; ".join( detail="; ".join(
f"{key}: {counts['created']} neu, {counts['updated']} aktualisiert, " f"{key}: {counts['created']} neu, {counts['updated']} aktualisiert, "
f"{counts['skipped']} uebersprungen" f"{counts['skipped']} übersprungen"
for key, counts in report.items() for key, counts in report.items()
), ),
ip=client_ip(), ip=client_ip(),
@@ -182,16 +182,16 @@ def import_apply():
lines = [ lines = [
f"{transfer.CATEGORY_BY_KEY[key].label}: {counts['created']} neu, " f"{transfer.CATEGORY_BY_KEY[key].label}: {counts['created']} neu, "
f"{counts['updated']} aktualisiert" f"{counts['updated']} aktualisiert"
+ (f", {counts['skipped']} uebersprungen" if counts["skipped"] else "") + (f", {counts['skipped']} übersprungen" if counts["skipped"] else "")
for key, counts in report.items() for key, counts in report.items()
if key in transfer.CATEGORY_BY_KEY if key in transfer.CATEGORY_BY_KEY
] ]
flash("Uebernahme abgeschlossen. " + " | ".join(lines), "success") flash("Übernahme abgeschlossen. " + " – ".join(lines), "success")
return redirect(url_for("backup.overview")) return redirect(url_for("backup.overview"))
@bp.post("/import/verwerfen") @bp.post("/import/discard")
@permission_required("backup.execute") @permission_required("backup_import.execute")
def import_discard(): def import_discard():
token = session.pop(PREVIEW_SESSION_KEY, "") token = session.pop(PREVIEW_SESSION_KEY, "")
if token: if token:
@@ -9,7 +9,7 @@ from tesm_core.extension import db, transaction
from ..services import issuance, reporting from ..services import issuance, reporting
from .common import customer_facing_url, log_event from .common import customer_facing_url, log_event
bp = Blueprint("customers", __name__, url_prefix="/kunden") bp = Blueprint("customers", __name__, url_prefix="/customers")
FIELDS = ( FIELDS = (
"name", "name",
@@ -29,13 +29,13 @@ def _form() -> dict[str, str]:
@bp.get("/") @bp.get("/")
@permission_required("customers.view") @permission_required("customers.read")
def overview(): def overview():
return render_template("license/customers.html", customers=issuance.list_customers(db())) return render_template("license/customers.html", customers=issuance.list_customers(db()))
@bp.get("/<int:customer_id>") @bp.get("/<int:customer_id>")
@permission_required("customers.view") @permission_required("customers.read")
def detail(customer_id: int): def detail(customer_id: int):
conn = db() conn = db()
customer = issuance.get_customer(conn, customer_id) customer = issuance.get_customer(conn, customer_id)
@@ -48,7 +48,7 @@ def detail(customer_id: int):
] ]
reported = ( reported = (
reporting.reported_for_customer(conn, customer_id) reporting.reported_for_customer(conn, customer_id)
if current_user.has("reports.view") if current_user.has("reports.read")
else {"customers": [], "source_license_id": ""} else {"customers": [], "source_license_id": ""}
) )
return render_template( return render_template(
@@ -61,7 +61,7 @@ def detail(customer_id: int):
) )
@bp.post("/neu") @bp.post("/new")
@permission_required("customers.create") @permission_required("customers.create")
def create(): def create():
try: try:
@@ -83,7 +83,7 @@ def create():
@bp.post("/<int:customer_id>") @bp.post("/<int:customer_id>")
@permission_required("customers.edit") @permission_required("customers.write")
def update(customer_id: int): def update(customer_id: int):
try: try:
with transaction() as conn: with transaction() as conn:
@@ -96,14 +96,14 @@ def update(customer_id: int):
severity="notice", severity="notice",
scope="kunde", scope="kunde",
) )
flash("Aenderungen gespeichert." if changes else "Keine Aenderungen.", flash("Änderungen gespeichert." if changes else "Keine Änderungen.",
"success" if changes else "info") "success" if changes else "info")
except issuance.IssuanceError as exc: except issuance.IssuanceError as exc:
flash(str(exc), "danger") flash(str(exc), "danger")
return redirect(url_for("customers.detail", customer_id=customer_id)) return redirect(url_for("customers.detail", customer_id=customer_id))
@bp.post("/<int:customer_id>/loeschen") @bp.post("/<int:customer_id>/delete")
@permission_required("customers.delete") @permission_required("customers.delete")
def delete(customer_id: int): def delete(customer_id: int):
conn = db() conn = db()
@@ -119,7 +119,7 @@ def delete(customer_id: int):
severity="warning", severity="warning",
scope="kunde", scope="kunde",
) )
flash("Kunde geloescht.", "success") flash("Kunde gelöscht.", "success")
return redirect(url_for("customers.overview")) return redirect(url_for("customers.overview"))
except issuance.IssuanceError as exc: except issuance.IssuanceError as exc:
flash(str(exc), "danger") flash(str(exc), "danger")
@@ -16,12 +16,12 @@ from ..services import issuance, reporting
bp = Blueprint("dashboard", __name__) bp = Blueprint("dashboard", __name__)
@bp.get("/uebersicht") @bp.get("/overview")
@login_required @login_required
def index(): def index():
conn = db() conn = db()
extension = core() extension = core()
if not current_user.has("licenses.view"): if not current_user.has("licenses.read"):
# Leere Huelle statt 403: die Startseite soll fuer jeden erreichbar # Leere Huelle statt 403: die Startseite soll fuer jeden erreichbar
# bleiben, auch fuer ein Konto ohne Lizenzrechte. # bleiben, auch fuer ein Konto ohne Lizenzrechte.
return render_template("license/dashboard.html", summary=None, sources=[]) return render_template("license/dashboard.html", summary=None, sources=[])
@@ -30,7 +30,7 @@ def index():
return render_template( return render_template(
"license/dashboard.html", "license/dashboard.html",
summary=summary, summary=summary,
sources=reporting.reporting_sources(conn) if current_user.has("reports.view") else [], sources=reporting.reporting_sources(conn) if current_user.has("reports.read") else [],
own_role=(extension.license.state.role if extension.license else "master"), own_role=(extension.license.state.role if extension.license else "master"),
type_labels=dict(), type_labels=dict(),
) )
@@ -38,18 +38,18 @@ from .common import (
vendor_info, vendor_info,
) )
bp = Blueprint("licenses", __name__, url_prefix="/lizenzen") bp = Blueprint("licenses", __name__, url_prefix="/licenses")
@bp.get("/") @bp.get("/")
@permission_required("licenses.view") @permission_required("licenses.read")
def overview(): def overview():
conn = db() conn = db()
rows = [ rows = [
{**row, **issuance.display_state(row)} {**row, **issuance.display_state(row)}
for row in issuance.list_licenses( for row in issuance.list_licenses(
conn, conn,
customer_id=request.args.get("kunde", type=int), customer_id=request.args.get("customer", type=int),
ticket_id=request.args.get("ticket"), ticket_id=request.args.get("ticket"),
status=request.args.get("status"), status=request.args.get("status"),
) )
@@ -65,7 +65,7 @@ def overview():
@bp.get("/<license_id>") @bp.get("/<license_id>")
@permission_required("licenses.view") @permission_required("licenses.read")
def detail(license_id: str): def detail(license_id: str):
conn = db() conn = db()
row = issuance.get_license(conn, license_id) row = issuance.get_license(conn, license_id)
@@ -78,6 +78,11 @@ def detail(license_id: str):
"license/license_detail.html", "license/license_detail.html",
license=row, license=row,
state=issuance.display_state(row), state=issuance.display_state(row),
# Bis wohin traegt das Ticket, und weicht die Lizenz davon ab? Die
# Seite zeigt es an, statt es erst in einer Fehlermeldung auftauchen zu
# lassen.
ticket_grenze=issuance.ticket_grenze(conn, license_id),
abweichung=issuance.ticket_abweichung(conn, license_id),
document=document, document=document,
document_json=json.dumps(document, indent=2, ensure_ascii=False, sort_keys=True), document_json=json.dumps(document, indent=2, ensure_ascii=False, sort_keys=True),
deliveries=Database.all( deliveries=Database.all(
@@ -99,8 +104,8 @@ def detail(license_id: str):
# --------------------------------------------------------------------------- # # --------------------------------------------------------------------------- #
@bp.post("/aus-ticket/<ticket_id>") @bp.post("/from-ticket/<ticket_id>")
@permission_required("licenses.create") @permission_required("license_issue.execute")
def issue(ticket_id: str): def issue(ticket_id: str):
conn = db() conn = db()
as_trial = request.form.get("as_trial") == "1" as_trial = request.form.get("as_trial") == "1"
@@ -124,7 +129,7 @@ def issue(ticket_id: str):
if result.already_existed: if result.already_existed:
flash( flash(
"Zu diesem Ticket war bereits eine Lizenz offen -- sie wird angezeigt statt eine " "Zu diesem Ticket war bereits eine Lizenz offen – sie wird angezeigt statt eine "
"zweite auszustellen.", "zweite auszustellen.",
"info", "info",
) )
@@ -140,9 +145,9 @@ def issue(ticket_id: str):
return redirect(url_for("licenses.detail", license_id=result.license_id)) return redirect(url_for("licenses.detail", license_id=result.license_id))
@bp.get("/<license_id>/datei") @bp.get("/<license_id>/file")
@permission_required("licenses.export") @permission_required("licenses.export")
@reauth_required # Die frische Bestaetigung verlangt das Recht selbst (SENSITIVE_ACTIONS).
def download(license_id: str): def download(license_id: str):
"""Laedt das vollstaendige Auslieferungs-Bundle herunter. """Laedt das vollstaendige Auslieferungs-Bundle herunter.
@@ -166,7 +171,7 @@ def download(license_id: str):
channel="download", channel="download",
recipient=current_user.username, recipient=current_user.username,
actor=current_user.username, actor=current_user.username,
detail="Vollstaendiges Bundle" if "client_key" in bundle else "Nur Lizenzdokument", detail="Vollständiges Bundle" if "client_key" in bundle else "Nur Lizenzdokument",
) )
log_event("license.downloaded", target=license_id[:12], severity="notice") log_event("license.downloaded", target=license_id[:12], severity="notice")
return Response( return Response(
@@ -181,8 +186,55 @@ def download(license_id: str):
) )
@bp.post("/<license_id>/neu-signieren") @bp.post("/<license_id>/align-ticket")
@permission_required("licenses.edit") @permission_required("license_renew.execute")
@reauth_required
def align_ticket(license_id: str):
"""Bringt die Laufzeit der Lizenz mit ihrem Ticket zur Deckung.
In beide Richtungen. Verkuerzt sie, ist das eine Ruecknahme von Laufzeit --
deshalb nur auf ausdruecklichen Zugriff, mit Rueckfrage in der Oberflaeche
und mit Zeile im Aenderungsprotokoll.
"""
conn = db()
if issuance.get_license(conn, license_id) is None:
abort(404)
key = signing_key()
try:
with transaction():
neu = issuance.lizenz_auf_ticket_setzen(
conn, license_id, master_private_key=key.private_key
)
except (issuance.IssuanceError, ValueError) as exc:
flash(str(exc), "danger")
return redirect(url_for("licenses.detail", license_id=license_id))
if neu is None:
flash("Die Laufzeit entspricht bereits dem Ticket.", "info")
return redirect(url_for("licenses.detail", license_id=license_id))
log_event(
"license.resigned",
target=license_id[:12],
detail=(
f"Revision {neu['seq']}: Laufzeit auf die Ticketlaufzeit gesetzt "
f"({'unbefristet' if neu.get('lifetime') else neu.get('expires_at')})"
),
# Was etwas wegnimmt, steht als Warnung im Protokoll -- was etwas gibt,
# als Notiz. Beides ist derselbe Knopf, aber nicht dasselbe Ereignis.
severity="notice" if neu.get("lifetime") else "warning",
)
reporting.report_soon(core())
flash(
f"Neue Revision {neu['seq']}: Die Laufzeit entspricht jetzt dem Ticket. Der Client "
"erhält sie beim nächsten Heartbeat.",
"success",
)
return redirect(url_for("licenses.detail", license_id=license_id))
@bp.post("/<license_id>/resign")
@permission_required("license_renew.execute")
@reauth_required @reauth_required
def resign(license_id: str): def resign(license_id: str):
"""Signiert eine neue Revision -- z. B. nach einer Modulerweiterung. """Signiert eine neue Revision -- z. B. nach einer Modulerweiterung.
@@ -202,7 +254,7 @@ def resign(license_id: str):
try: try:
days = int(request.form["extend_days"]) days = int(request.form["extend_days"])
except ValueError: except ValueError:
flash("Die Verlaengerung muss eine Zahl sein.", "danger") flash("Die Verlängerung muss eine Zahl sein.", "danger")
return redirect(url_for("licenses.detail", license_id=license_id)) return redirect(url_for("licenses.detail", license_id=license_id))
if days > 0: if days > 0:
document = json.loads(str(row["license_document"])) document = json.loads(str(row["license_document"]))
@@ -212,7 +264,7 @@ def resign(license_id: str):
changes["expires_at"] = lic.iso(base + timedelta(days=days)) changes["expires_at"] = lic.iso(base + timedelta(days=days))
if not changes: if not changes:
flash("Es wurde keine Aenderung angegeben.", "info") flash("Es wurde keine Änderung angegeben.", "info")
return redirect(url_for("licenses.detail", license_id=license_id)) return redirect(url_for("licenses.detail", license_id=license_id))
key = signing_key() key = signing_key()
@@ -233,15 +285,15 @@ def resign(license_id: str):
) )
reporting.report_soon(core()) reporting.report_soon(core())
flash( flash(
f"Neue Revision {updated['seq']} signiert. Der Client erhaelt sie beim naechsten " f"Neue Revision {updated['seq']} signiert. Der Client erhält sie beim nächsten "
"Heartbeat.", "Heartbeat.",
"success", "success",
) )
return redirect(url_for("licenses.detail", license_id=license_id)) return redirect(url_for("licenses.detail", license_id=license_id))
@bp.post("/<license_id>/widerrufen") @bp.post("/<license_id>/revoke")
@permission_required("licenses.edit") @permission_required("license_revoke.execute")
def revoke(license_id: str): def revoke(license_id: str):
conn = db() conn = db()
try: try:
@@ -258,14 +310,14 @@ def revoke(license_id: str):
) )
reporting.report_soon(core()) reporting.report_soon(core())
flash( flash(
"Lizenz widerrufen. Das Ticket bleibt offen -- eine neue Lizenz kann sofort " "Lizenz widerrufen. Das Ticket bleibt offen – eine neue Lizenz kann sofort "
"ausgestellt werden.", "ausgestellt werden.",
"success", "success",
) )
return redirect(url_for("licenses.detail", license_id=license_id)) return redirect(url_for("licenses.detail", license_id=license_id))
@bp.post("/<license_id>/loeschen") @bp.post("/<license_id>/delete")
@permission_required("licenses.delete") @permission_required("licenses.delete")
def delete(license_id: str): def delete(license_id: str):
conn = db() conn = db()
@@ -277,7 +329,7 @@ def delete(license_id: str):
return redirect(url_for("licenses.detail", license_id=license_id)) return redirect(url_for("licenses.detail", license_id=license_id))
log_event("license.deleted", target=license_id[:12], severity="warning") log_event("license.deleted", target=license_id[:12], severity="warning")
reporting.report_soon(core()) reporting.report_soon(core())
flash("Lizenz geloescht.", "success") flash("Lizenz gelöscht.", "success")
return redirect(url_for("licenses.overview")) return redirect(url_for("licenses.overview"))
@@ -286,7 +338,7 @@ def delete(license_id: str):
# --------------------------------------------------------------------------- # # --------------------------------------------------------------------------- #
@bp.post("/<license_id>/versenden") @bp.post("/<license_id>/send")
@permission_required("licenses.export") @permission_required("licenses.export")
def send_mail(license_id: str): def send_mail(license_id: str):
conn = db() conn = db()
@@ -295,7 +347,7 @@ def send_mail(license_id: str):
abort(404) abort(404)
recipient = (request.form.get("recipient") or str(row["contact_email"] or "")).strip() recipient = (request.form.get("recipient") or str(row["contact_email"] or "")).strip()
if not recipient: if not recipient:
flash("Es ist keine Empfaengeradresse hinterlegt.", "danger") flash("Es ist keine Empfängeradresse hinterlegt.", "danger")
return redirect(url_for("licenses.detail", license_id=license_id)) return redirect(url_for("licenses.detail", license_id=license_id))
try: try:
@@ -315,7 +367,7 @@ def send_mail(license_id: str):
"", "",
f"Lizenz-ID: {license_id}", f"Lizenz-ID: {license_id}",
f"Typ: {type_labels().get(str(row['type']), str(row['type']))}", f"Typ: {type_labels().get(str(row['type']), str(row['type']))}",
f"Gueltig bis: {row['expires_at']}" f"Gültig bis: {row['expires_at']}"
+ (" (unbefristet)" if row["lifetime"] else ""), + (" (unbefristet)" if row["lifetime"] else ""),
] ]
if is_customer and row["ticket_id"]: if is_customer and row["ticket_id"]:
@@ -329,7 +381,7 @@ def send_mail(license_id: str):
"Ihre Stammdaten pflegen:", "Ihre Stammdaten pflegen:",
customer_facing_url("portal.request_link"), customer_facing_url("portal.request_link"),
] ]
lines += ["", "Freundliche Gruesse", vendor.get("name") or "Ihr Lizenzgeber"] lines += ["", "Freundliche Grüße", vendor.get("name") or "Ihr Lizenzgeber"]
if vendor.get("phone"): if vendor.get("phone"):
lines.append(f"Telefon: {vendor['phone']}") lines.append(f"Telefon: {vendor['phone']}")
if vendor.get("email"): if vendor.get("email"):
@@ -369,7 +421,7 @@ def send_mail(license_id: str):
@bp.route("/offline-code", methods=["GET", "POST"]) @bp.route("/offline-code", methods=["GET", "POST"])
@permission_required("licenses.execute") @permission_required("license_activation.execute")
def manual_code(): def manual_code():
"""Verarbeitet einen Offline-Code des Kunden. """Verarbeitet einen Offline-Code des Kunden.
@@ -403,7 +455,7 @@ def manual_code():
if outcome.ok: if outcome.ok:
response_code = lic.encode_code(outcome.response) response_code = lic.encode_code(outcome.response)
ok = True ok = True
message = "Antwortcode erzeugt. Bitte an den Kunden zuruecksenden." message = "Antwortcode erzeugt. Bitte an den Kunden zurücksenden."
log_event( log_event(
f"license.offline_{payload.get('action')}", f"license.offline_{payload.get('action')}",
target=str(payload.get("license_id") or "")[:12], target=str(payload.get("license_id") or "")[:12],
@@ -428,8 +480,8 @@ def manual_code():
# --------------------------------------------------------------------------- # # --------------------------------------------------------------------------- #
@bp.get("/sub-lizenzserver") @bp.get("/sub-servers")
@permission_required("reports.view") @permission_required("reports.read")
def reports(): def reports():
conn = db() conn = db()
sources = reporting.reporting_sources(conn) sources = reporting.reporting_sources(conn)
@@ -440,8 +492,8 @@ def reports():
) )
@bp.get("/sub-lizenzserver/<license_id>") @bp.get("/sub-servers/<license_id>")
@permission_required("reports.view") @permission_required("reports.read")
def report_detail(license_id: str): def report_detail(license_id: str):
conn = db() conn = db()
row = issuance.get_license(conn, license_id) row = issuance.get_license(conn, license_id)
@@ -36,13 +36,13 @@ from .. import is_master_edition
from ..services import master_key from ..services import master_key
from .common import can_issue_license_server_licenses, log_event, signing_key from .common import can_issue_license_server_licenses, log_event, signing_key
bp = Blueprint("own_license", __name__, url_prefix="/eigene-lizenz") bp = Blueprint("own_license", __name__, url_prefix="/own-license")
MAX_UPLOAD_BYTES = 256 * 1024 MAX_UPLOAD_BYTES = 256 * 1024
@bp.get("/") @bp.get("/")
@permission_required("settings_license.view") @permission_required("settings_license.read")
def overview(): def overview():
extension = core() extension = core()
conn = db() conn = db()
@@ -68,8 +68,8 @@ def overview():
) )
@bp.post("/einspielen") @bp.post("/install")
@permission_required("settings_license.edit") @permission_required("settings_license.write")
def upload(): def upload():
extension = core() extension = core()
manager = extension.license manager = extension.license
@@ -79,11 +79,11 @@ def upload():
upload_file = request.files.get("license_file") upload_file = request.files.get("license_file")
if upload_file is None or not upload_file.filename: if upload_file is None or not upload_file.filename:
flash("Keine Datei ausgewaehlt.", "danger") flash("Keine Datei ausgewählt.", "danger")
return redirect(url_for("own_license.overview")) return redirect(url_for("own_license.overview"))
raw = upload_file.stream.read(MAX_UPLOAD_BYTES + 1) raw = upload_file.stream.read(MAX_UPLOAD_BYTES + 1)
if len(raw) > MAX_UPLOAD_BYTES: if len(raw) > MAX_UPLOAD_BYTES:
flash("Die Datei ist zu gross fuer eine Lizenzdatei.", "danger") flash("Die Datei ist zu groß für eine Lizenzdatei.", "danger")
return redirect(url_for("own_license.overview")) return redirect(url_for("own_license.overview"))
try: try:
@@ -129,27 +129,27 @@ def _handshake(action: str):
return redirect(url_for("own_license.overview")) return redirect(url_for("own_license.overview"))
@bp.post("/aktivieren") @bp.post("/activate")
@permission_required("settings_license.edit") @permission_required("settings_license.write")
def activate(): def activate():
return _handshake("activate") return _handshake("activate")
@bp.post("/deaktivieren") @bp.post("/deactivate")
@permission_required("settings_license.edit") @permission_required("settings_license.write")
@reauth_required @reauth_required
def deactivate(): def deactivate():
return _handshake("deactivate") return _handshake("deactivate")
@bp.post("/heartbeat") @bp.post("/heartbeat")
@permission_required("settings_license.edit") @permission_required("settings_license.write")
def heartbeat(): def heartbeat():
return _handshake("heartbeat") return _handshake("heartbeat")
@bp.post("/offline-antwort") @bp.post("/offline-response")
@permission_required("settings_license.edit") @permission_required("settings_license.write")
def offline_response(): def offline_response():
manager = core().license manager = core().license
if manager is None: if manager is None:
@@ -166,8 +166,8 @@ def offline_response():
return redirect(url_for("own_license.overview")) return redirect(url_for("own_license.overview"))
@bp.get("/signaturschluessel") @bp.get("/signing-key")
@permission_required("settings_license.view") @permission_required("settings_license.read")
def signing_key_info(): def signing_key_info():
"""Zeigt den oeffentlichen Signaturschluessel -- zum Pinnen beim Kunden. """Zeigt den oeffentlichen Signaturschluessel -- zum Pinnen beim Kunden.
@@ -45,7 +45,7 @@ RATE_WINDOW_MINUTES = 15
GENERIC_ANSWER = ( GENERIC_ANSWER = (
"Falls diese E-Mail-Adresse bei uns hinterlegt ist, wurde soeben eine Nachricht mit " "Falls diese E-Mail-Adresse bei uns hinterlegt ist, wurde soeben eine Nachricht mit "
"einem {minutes} Minuten gueltigen Zugangslink verschickt." "einem {minutes} Minuten gültigen Zugangslink verschickt."
) )
EDITABLE_FIELDS = ( EDITABLE_FIELDS = (
@@ -150,13 +150,13 @@ def request_link():
f"Guten Tag{(' ' + str(customer['contact_person'])) if customer['contact_person'] else ''},", f"Guten Tag{(' ' + str(customer['contact_person'])) if customer['contact_person'] else ''},",
"", "",
"mit dem folgenden Link erreichen Sie Ihre Daten im Kundenportal.", "mit dem folgenden Link erreichen Sie Ihre Daten im Kundenportal.",
f"Der Link ist {minutes} Minuten gueltig und laesst sich nur einmal verwenden:", f"Der Link ist {minutes} Minuten gültig und lässt sich nur einmal verwenden:",
"", "",
access_url, access_url,
"", "",
"Wenn Sie diesen Zugang nicht angefragt haben, ignorieren Sie die Nachricht.", "Wenn Sie diesen Zugang nicht angefragt haben, ignorieren Sie die Nachricht.",
"", "",
"Freundliche Gruesse", "Freundliche Grüße",
vendor.get("name") or "Ihr Lizenzgeber", vendor.get("name") or "Ihr Lizenzgeber",
] ]
), ),
@@ -185,7 +185,7 @@ def access(token: str):
expires = _parse(row["expires_at"]) if row is not None else None 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 valid = row is not None and row["used_at"] is None and expires is not None and _now() < expires
if not valid: if not valid:
flash("Dieser Link ist ungueltig oder abgelaufen.", "danger") flash("Dieser Link ist ungültig oder abgelaufen.", "danger")
return redirect(url_for("portal.request_link")) return redirect(url_for("portal.request_link"))
minutes = int(extension.settings.get(conn, "portal_session_minutes") or 60) minutes = int(extension.settings.get(conn, "portal_session_minutes") or 60)
@@ -247,7 +247,7 @@ def me():
ip=client_ip(), ip=client_ip(),
severity="notice", severity="notice",
) )
flash("Ihre Angaben wurden gespeichert." if changes else "Keine Aenderungen.", "success") flash("Ihre Angaben wurden gespeichert." if changes else "Keine Änderungen.", "success")
except issuance.IssuanceError as exc: except issuance.IssuanceError as exc:
flash(str(exc), "danger") flash(str(exc), "danger")
return redirect(url_for("portal.me")) return redirect(url_for("portal.me"))
@@ -31,6 +31,8 @@ from .common import (
vendor_info, vendor_info,
) )
# Die Adresse steht in verschickten Mails und in Tickets -- sie bleibt
# deutsch, waehrend der Rest der Anwendung auf englische Pfade wechselt.
bp = Blueprint("selfservice", __name__, url_prefix="/lizenz") bp = Blueprint("selfservice", __name__, url_prefix="/lizenz")
RATE_BUCKET = "selfservice" RATE_BUCKET = "selfservice"
@@ -60,7 +62,7 @@ def _rate_guard() -> None:
with transaction(): with transaction():
ratelimit.record(conn, RATE_BUCKET, client_ip() or "unbekannt") ratelimit.record(conn, RATE_BUCKET, client_ip() or "unbekannt")
if verdict.blocked: if verdict.blocked:
abort(429, description="Zu viele Anfragen. Bitte spaeter erneut versuchen.") abort(429, description="Zu viele Anfragen. Bitte später erneut versuchen.")
def _needs_offline(conn, ticket_id: str): # type: ignore[no-untyped-def] def _needs_offline(conn, ticket_id: str): # type: ignore[no-untyped-def]
@@ -139,8 +141,8 @@ def issue(ticket_id: str):
return redirect(url_for("selfservice.overview", ticket_id=ticket_id)) return redirect(url_for("selfservice.overview", ticket_id=ticket_id))
if issuance.ticket_has_open_license(conn, ticket_id): if issuance.ticket_has_open_license(conn, ticket_id):
flash( flash(
"Es existiert bereits eine gueltige Lizenz. Geben Sie diese zuerst frei, wenn Sie " "Es existiert bereits eine gültige Lizenz. Geben Sie diese zuerst frei, wenn Sie "
"auf ein anderes System wechseln moechten.", "auf ein anderes System wechseln möchten.",
"warning", "warning",
) )
return redirect(url_for("selfservice.overview", ticket_id=ticket_id)) return redirect(url_for("selfservice.overview", ticket_id=ticket_id))
@@ -236,12 +238,12 @@ def offline(ticket_id: str):
_log( _log(
"selfservice.offline_rejected", "selfservice.offline_rejected",
ticket, ticket,
"Code gehoert nicht zu diesem Vorgang", "Code gehört nicht zu diesem Vorgang",
severity="warning", severity="warning",
) )
flash( flash(
"Dieser Code gehoert nicht zu Ihrem Vorgang. Bitte pruefen Sie, ob Sie ihn " "Dieser Code gehört nicht zu Ihrem Vorgang. Bitte prüfen Sie, ob Sie ihn "
"vollstaendig kopiert haben.", "vollständig kopiert haben.",
"danger", "danger",
) )
return redirect(url_for("selfservice.offline", ticket_id=ticket_id)) return redirect(url_for("selfservice.offline", ticket_id=ticket_id))
@@ -297,7 +299,7 @@ def revoke(ticket_id: str, license_id: str):
_log("license.revoked_self_service", ticket, license_id[:12], severity="warning") _log("license.revoked_self_service", ticket, license_id[:12], severity="warning")
reporting.report_soon(core()) reporting.report_soon(core())
flash( flash(
"Die Lizenz wurde freigegeben. Sie koennen jetzt eine neue Lizenz fuer Ihr neues System " "Die Lizenz wurde freigegeben. Sie können jetzt eine neue Lizenz für Ihr neues System "
"ausstellen.", "ausstellen.",
"success", "success",
) )
@@ -17,7 +17,7 @@ from tesm_core.extension import core, db, transaction
from ..services import mail from ..services import mail
from .common import log_event, master_endpoint, vendor_info from .common import log_event, master_endpoint, vendor_info
bp = Blueprint("settings", __name__, url_prefix="/einstellungen") bp = Blueprint("settings", __name__, url_prefix="/administration")
MAX_LOGO_BYTES = 200 * 1024 MAX_LOGO_BYTES = 200 * 1024
LOGO_MIME = { LOGO_MIME = {
@@ -35,7 +35,7 @@ LOGO_MAGIC = {
@bp.get("/system") @bp.get("/system")
@permission_required("settings_system.view") @permission_required("settings_system.read")
def system(): def system():
extension = core() extension = core()
conn = db() conn = db()
@@ -54,6 +54,8 @@ def system():
"trash_retention_days", "trash_retention_days",
"log_rotate_interval", "log_rotate_interval",
"log_rotate_keep", "log_rotate_keep",
"session_idle_minutes",
"session_absolute_hours",
], ],
), ),
specs={spec.key: spec for spec in extension.settings.specs()}, specs={spec.key: spec for spec in extension.settings.specs()},
@@ -66,8 +68,13 @@ def system():
else "" else ""
), ),
timezones=_timezone_choices(), timezones=_timezone_choices(),
nav_items=extension.nav.items, # Nicht ``nav_items``: dieser Name gehoert dem Seitenrahmen. Wer ihn hier
# belegt, ueberschreibt die gefilterte Navigation aus dem Kontext --
# und die Seitenleiste zeigte dann jeden Punkt, auch den, den dieser
# Benutzer nicht darf und den die Lizenz nicht freischaltet.
nav_baum=extension.nav.items,
nav_order=extension.settings.get(conn, "nav_order") or extension.nav.default_order(), nav_order=extension.settings.get(conn, "nav_order") or extension.nav.default_order(),
nav_child_order=extension.settings.get(conn, "nav_child_order") or {},
sysops_available=bool(sysops and sysops.available), sysops_available=bool(sysops and sysops.available),
) )
@@ -82,7 +89,7 @@ def _timezone_choices() -> list[str]:
@bp.post("/system") @bp.post("/system")
@permission_required("settings_system.edit") @permission_required("settings_system.write")
def system_save(): def system_save():
extension = core() extension = core()
conn = db() conn = db()
@@ -97,6 +104,8 @@ def system_save():
"trash_retention_days", "trash_retention_days",
"log_rotate_interval", "log_rotate_interval",
"log_rotate_keep", "log_rotate_keep",
"session_idle_minutes",
"session_absolute_hours",
): ):
spec = extension.settings.spec(key) spec = extension.settings.spec(key)
if spec.kind == "bool": if spec.kind == "bool":
@@ -116,7 +125,7 @@ def system_save():
ip=client_ip(), ip=client_ip(),
severity="notice", severity="notice",
) )
flash("Einstellungen gespeichert." if changed else "Keine Aenderungen.", flash("Einstellungen gespeichert." if changed else "Keine Änderungen.",
"success" if changed else "info") "success" if changed else "info")
return redirect(url_for("settings.system")) return redirect(url_for("settings.system"))
@@ -128,10 +137,10 @@ def set_hostname():
sysops = extension.sysops sysops = extension.sysops
hostname = (request.form.get("hostname") or "").strip() hostname = (request.form.get("hostname") or "").strip()
if not hostname or len(hostname) > 63 or not hostname.replace("-", "").isalnum(): if not hostname or len(hostname) > 63 or not hostname.replace("-", "").isalnum():
flash("Der Hostname ist ungueltig.", "danger") flash("Der Hostname ist ungültig.", "danger")
return redirect(url_for("settings.system")) return redirect(url_for("settings.system"))
if sysops is None or not sysops.available: if sysops is None or not sysops.available:
flash("Systemaktionen sind in dieser Umgebung nicht verfuegbar.", "warning") flash("Systemaktionen sind in dieser Umgebung nicht verfügbar.", "warning")
return redirect(url_for("settings.system")) return redirect(url_for("settings.system"))
result = sysops.set_hostname(hostname) result = sysops.set_hostname(hostname)
if result.ok: if result.ok:
@@ -142,7 +151,7 @@ def set_hostname():
return redirect(url_for("settings.system")) return redirect(url_for("settings.system"))
@bp.post("/system/zeitzone") @bp.post("/system/timezone")
@permission_required("settings_system.execute") @permission_required("settings_system.execute")
def set_timezone(): def set_timezone():
import time import time
@@ -153,7 +162,7 @@ def set_timezone():
flash("Unbekannte Zeitzone.", "danger") flash("Unbekannte Zeitzone.", "danger")
return redirect(url_for("settings.system")) return redirect(url_for("settings.system"))
if sysops is None or not sysops.available: if sysops is None or not sysops.available:
flash("Systemaktionen sind in dieser Umgebung nicht verfuegbar.", "warning") flash("Systemaktionen sind in dieser Umgebung nicht verfügbar.", "warning")
return redirect(url_for("settings.system")) return redirect(url_for("settings.system"))
result = sysops.set_timezone(timezone) result = sysops.set_timezone(timezone)
if result.ok: if result.ok:
@@ -173,8 +182,8 @@ def set_timezone():
# --------------------------------------------------------------------------- # # --------------------------------------------------------------------------- #
@bp.get("/anbieter") @bp.get("/vendor")
@permission_required("settings_vendor.view") @permission_required("settings_vendor.read")
def vendor(): def vendor():
extension = core() extension = core()
conn = db() conn = db()
@@ -195,8 +204,8 @@ def vendor():
) )
@bp.post("/anbieter") @bp.post("/vendor")
@permission_required("settings_vendor.edit") @permission_required("settings_vendor.write")
def vendor_save(): def vendor_save():
extension = core() extension = core()
conn = db() conn = db()
@@ -222,7 +231,7 @@ def vendor_save():
if suffix not in LOGO_MIME: if suffix not in LOGO_MIME:
logo_problem = "Erlaubt sind PNG, JPG und SVG." logo_problem = "Erlaubt sind PNG, JPG und SVG."
elif len(data) > MAX_LOGO_BYTES: elif len(data) > MAX_LOGO_BYTES:
logo_problem = f"Das Logo ist zu gross (maximal {MAX_LOGO_BYTES // 1024} KB)." logo_problem = f"Das Logo ist zu groß (maximal {MAX_LOGO_BYTES // 1024} KB)."
elif not any(head.startswith(magic) for magic in LOGO_MAGIC): elif not any(head.startswith(magic) for magic in LOGO_MAGIC):
logo_problem = "Die Datei ist kein Bild." logo_problem = "Die Datei ist kein Bild."
else: else:
@@ -240,13 +249,13 @@ def vendor_save():
log_event( log_event(
"settings.vendor_saved", detail=", ".join(changed), severity="notice", scope="anbieter" "settings.vendor_saved", detail=", ".join(changed), severity="notice", scope="anbieter"
) )
flash("Anbieterdaten gespeichert." if changed else "Keine Aenderungen.", flash("Anbieterdaten gespeichert." if changed else "Keine Änderungen.",
"success" if changed else "info") "success" if changed else "info")
return redirect(url_for("settings.vendor")) return redirect(url_for("settings.vendor"))
@bp.post("/anbieter/logo-entfernen") @bp.post("/vendor/remove-logo")
@permission_required("settings_vendor.edit") @permission_required("settings_vendor.write")
def vendor_logo_remove(): def vendor_logo_remove():
with transaction() as conn: with transaction() as conn:
core().settings.set(conn, "vendor_logo_base64", "", actor=current_user.username) core().settings.set(conn, "vendor_logo_base64", "", actor=current_user.username)
@@ -260,7 +269,7 @@ def vendor_logo_remove():
@bp.get("/email") @bp.get("/email")
@permission_required("settings_mail.view") @permission_required("settings_mail.read")
def mail_settings(): def mail_settings():
extension = core() extension = core()
conn = db() conn = db()
@@ -275,7 +284,7 @@ def mail_settings():
@bp.post("/email") @bp.post("/email")
@permission_required("settings_mail.edit") @permission_required("settings_mail.write")
def mail_save(): def mail_save():
extension = core() extension = core()
conn = db() conn = db()
@@ -292,7 +301,7 @@ def mail_save():
mail.store_secret(conn, request.form.get("client_secret")) mail.store_secret(conn, request.form.get("client_secret"))
changed.append("client_secret") changed.append("client_secret")
log_event("settings.mail_saved", detail=", ".join(changed), severity="notice", scope="mail") log_event("settings.mail_saved", detail=", ".join(changed), severity="notice", scope="mail")
flash("E-Mail-Einstellungen gespeichert." if changed else "Keine Aenderungen.", flash("E-Mail-Einstellungen gespeichert." if changed else "Keine Änderungen.",
"success" if changed else "info") "success" if changed else "info")
return redirect(url_for("settings.mail_settings")) return redirect(url_for("settings.mail_settings"))
@@ -18,6 +18,7 @@ from .common import (
master_endpoint, master_endpoint,
module_labels, module_labels,
role_labels, role_labels,
signing_key,
type_labels, type_labels,
) )
@@ -25,7 +26,7 @@ bp = Blueprint("tickets", __name__, url_prefix="/tickets")
@bp.get("/") @bp.get("/")
@permission_required("tickets.view") @permission_required("tickets.read")
def overview(): def overview():
conn = db() conn = db()
tickets = issuance.list_tickets(conn) tickets = issuance.list_tickets(conn)
@@ -45,7 +46,7 @@ def overview():
) )
@bp.get("/neu") @bp.get("/new")
@permission_required("tickets.create") @permission_required("tickets.create")
def new(): def new():
conn = db() conn = db()
@@ -60,11 +61,11 @@ def new():
type_labels=type_labels(), type_labels=type_labels(),
default_valid_days=int(extension.settings.get(conn, "default_valid_days") or 365), default_valid_days=int(extension.settings.get(conn, "default_valid_days") or 365),
may_create_license_server=can_issue_license_server_licenses(), may_create_license_server=can_issue_license_server_licenses(),
preselected_customer=request.args.get("kunde", type=int), preselected_customer=request.args.get("customer", type=int),
) )
@bp.post("/neu") @bp.post("/new")
@permission_required("tickets.create") @permission_required("tickets.create")
def create(): def create():
conn = db() conn = db()
@@ -106,14 +107,14 @@ def _endpoint_missing() -> str:
if master_endpoint(): if master_endpoint():
return "" return ""
return ( return (
"Es ist kein oeffentlicher Endpunkt hinterlegt (Einstellungen -> Anbieter). " "Es ist kein öffentlicher Endpunkt hinterlegt (Einstellungen -> Anbieter). "
"Solange er fehlt, koennen keine Lizenzen ausgestellt werden: der Endpunkt wird " "Solange er fehlt, können keine Lizenzen ausgestellt werden: der Endpunkt wird "
"in die Lizenz signiert und laesst sich spaeter nicht nachtragen." "in die Lizenz signiert und lässt sich später nicht nachtragen."
) )
@bp.get("/<ticket_id>") @bp.get("/<ticket_id>")
@permission_required("tickets.view") @permission_required("tickets.read")
def detail(ticket_id: str): def detail(ticket_id: str):
conn = db() conn = db()
ticket = issuance.get_ticket(conn, ticket_id) ticket = issuance.get_ticket(conn, ticket_id)
@@ -131,7 +132,10 @@ def detail(ticket_id: str):
ticket=ticket, ticket=ticket,
ticket_modules=json.loads(str(ticket["modules"] or "[]")), ticket_modules=json.loads(str(ticket["modules"] or "[]")),
licenses=licenses, licenses=licenses,
# Zwei Spuren, zwei Auskuenfte: eine laufende Testlizenz darf die
# richtige Ausstellung nicht sperren und umgekehrt.
has_open_license=issuance.ticket_has_open_license(conn, ticket_id), has_open_license=issuance.ticket_has_open_license(conn, ticket_id),
has_open_trial=issuance.ticket_has_open_license(conn, ticket_id, trial=True),
modules=lic.ALL_MODULES, modules=lic.ALL_MODULES,
module_labels=module_labels(), module_labels=module_labels(),
types=lic.LICENSE_TYPES, types=lic.LICENSE_TYPES,
@@ -150,7 +154,7 @@ def detail(ticket_id: str):
@bp.post("/<ticket_id>") @bp.post("/<ticket_id>")
@permission_required("tickets.edit") @permission_required("tickets.write")
def update(ticket_id: str): def update(ticket_id: str):
conn = db() conn = db()
if issuance.get_ticket(conn, ticket_id) is None: if issuance.get_ticket(conn, ticket_id) is None:
@@ -166,6 +170,20 @@ def update(ticket_id: str):
lifetime=request.form.get("lifetime") == "1", lifetime=request.form.get("lifetime") == "1",
notes=request.form.get("notes", ""), notes=request.form.get("notes", ""),
) )
# Das Ticket ist der Anspruch, die Lizenz die Urkunde daraus. Wird der
# Anspruch laenger, muss die Urkunde nachziehen -- sonst stuende die
# Vereinbarung an einer Stelle und gaelte an einer anderen, und der
# Kunde merkte es an dem Tag, an dem seine Installation stehenbleibt.
#
# Nur verlaengern, nie verkuerzen: eine ausgestellte und signierte
# Zusage einseitig zu beschneiden waere etwas anderes als eine
# Korrektur. Eine Verkuerzung wirkt auf die naechste Ausstellung.
nachgezogen = None
if changes:
with transaction():
nachgezogen = issuance.lizenz_dem_ticket_nachziehen(
conn, ticket_id, master_private_key=signing_key().private_key
)
if changes: if changes:
log_event( log_event(
"ticket.updated", "ticket.updated",
@@ -175,19 +193,82 @@ def update(ticket_id: str):
scope="ticket", scope="ticket",
) )
reporting.report_soon(core()) reporting.report_soon(core())
flash( if nachgezogen is not None:
"Gespeichert. Die Aenderung wirkt auf die naechste Ausstellung." log_event(
if changes "license.resigned",
else "Keine Aenderungen.", target=str(nachgezogen["license_id"])[:12],
"success" if changes else "info", detail=f"Revision {nachgezogen['seq']}: Laufzeit dem Ticket angeglichen",
) severity="notice",
)
flash(
"Gespeichert. Die laufende Lizenz wurde mitverlängert (Revision "
f"{nachgezogen['seq']}) – der Client erhält sie beim nächsten Heartbeat.",
"success",
)
else:
flash(
"Gespeichert. Die Änderung wirkt auf die nächste Ausstellung."
if changes
else "Keine Änderungen.",
"success" if changes else "info",
)
except (issuance.IssuanceError, ValueError) as exc: except (issuance.IssuanceError, ValueError) as exc:
flash(str(exc), "danger") flash(str(exc), "danger")
return redirect(url_for("tickets.detail", ticket_id=ticket_id)) return redirect(url_for("tickets.detail", ticket_id=ticket_id))
@bp.post("/<ticket_id>/sperre") @bp.post("/<ticket_id>/extend")
@permission_required("tickets.edit") @permission_required("tickets.write")
def extend(ticket_id: str):
"""Schlaegt Tage auf die Laufzeit des Tickets -- und zieht die Lizenz nach."""
conn = db()
if issuance.get_ticket(conn, ticket_id) is None:
abort(404)
try:
tage = int(request.form.get("days") or 0)
except ValueError:
flash("Die Verlängerung muss eine Zahl sein.", "danger")
return redirect(url_for("tickets.detail", ticket_id=ticket_id))
try:
with transaction():
neu = issuance.ticket_verlaengern(conn, ticket_id, tage)
nachgezogen = issuance.lizenz_dem_ticket_nachziehen(
conn, ticket_id, master_private_key=signing_key().private_key
)
except (issuance.IssuanceError, ValueError) as exc:
flash(str(exc), "danger")
return redirect(url_for("tickets.detail", ticket_id=ticket_id))
log_event(
"ticket.updated",
target=ticket_id[:12],
detail=f"Laufzeit +{tage} Tage (jetzt {neu})",
severity="notice",
scope="ticket",
)
if nachgezogen is not None:
log_event(
"license.resigned",
target=str(nachgezogen["license_id"])[:12],
detail=f"Revision {nachgezogen['seq']}: Laufzeit dem Ticket angeglichen",
severity="notice",
)
reporting.report_soon(core())
flash(
f"Ticket auf {neu} Tage verlängert."
+ (
f" Die laufende Lizenz wurde mitverlängert (Revision {nachgezogen['seq']})."
if nachgezogen is not None
else ""
),
"success",
)
return redirect(url_for("tickets.detail", ticket_id=ticket_id))
@bp.post("/<ticket_id>/lock")
@permission_required("tickets.write")
def toggle_block(ticket_id: str): def toggle_block(ticket_id: str):
conn = db() conn = db()
ticket = issuance.get_ticket(conn, ticket_id) ticket = issuance.get_ticket(conn, ticket_id)
@@ -205,7 +286,7 @@ def toggle_block(ticket_id: str):
) )
reporting.report_soon(core()) reporting.report_soon(core())
flash( flash(
"Ticket gesperrt -- es lassen sich keine neuen Lizenzen mehr ausstellen." "Ticket gesperrt – es lassen sich keine neuen Lizenzen mehr ausstellen."
if new_status == "blocked" if new_status == "blocked"
else "Ticket entsperrt.", else "Ticket entsperrt.",
"success", "success",
@@ -213,7 +294,7 @@ def toggle_block(ticket_id: str):
return redirect(url_for("tickets.detail", ticket_id=ticket_id)) return redirect(url_for("tickets.detail", ticket_id=ticket_id))
@bp.post("/<ticket_id>/loeschen") @bp.post("/<ticket_id>/delete")
@permission_required("tickets.delete") @permission_required("tickets.delete")
def delete(ticket_id: str): def delete(ticket_id: str):
conn = db() conn = db()
@@ -231,7 +312,7 @@ def delete(ticket_id: str):
scope="ticket", scope="ticket",
) )
reporting.report_soon(core()) reporting.report_soon(core())
flash("Ticket und seine Lizenzhistorie geloescht.", "success") flash("Ticket und seine Lizenzhistorie gelöscht.", "success")
return redirect(url_for("tickets.overview")) return redirect(url_for("tickets.overview"))
except issuance.IssuanceError as exc: except issuance.IssuanceError as exc:
flash(str(exc), "danger") flash(str(exc), "danger")
+36 -36
View File
@@ -42,22 +42,22 @@ def cmd_init_key(args: argparse.Namespace) -> int:
path = master_key_path(extension.config) path = master_key_path(extension.config)
if path.exists() and not args.force: if path.exists() and not args.force:
key = master_key.load(path) key = master_key.load(path)
print(f"Signaturschluessel existiert bereits: {key.key_id} ({path})") print(f"Signaturschlüssel existiert bereits: {key.key_id} ({path})")
print("Ein Austausch macht ALLE bereits ausgestellten Lizenzen unpruefbar.") print("Ein Austausch macht ALLE bereits ausgestellten Lizenzen unprüfbar.")
return 0 return 0
if path.exists() and args.force: if path.exists() and args.force:
print( print(
"ABBRUCH: --force auf einen bestehenden Signaturschluessel wird nicht unterstuetzt.\n" "ABBRUCH: --force auf einen bestehenden Signaturschlüssel wird nicht unterstützt.\n"
"Ein neuer Schluessel entwertet jede ausgestellte Lizenz. Bitte die Datei bewusst\n" "Ein neuer Schlüssel entwertet jede ausgestellte Lizenz. Bitte die Datei bewusst\n"
"von Hand beiseite legen, wenn das wirklich gewollt ist.", "von Hand beiseite legen, wenn das wirklich gewollt ist.",
file=sys.stderr, file=sys.stderr,
) )
return 2 return 2
key = master_key.ensure(path) key = master_key.ensure(path)
print(f"Signaturschluessel erzeugt: {key.key_id}") print(f"Signaturschlüssel erzeugt: {key.key_id}")
print(f"Datei: {path}") print(f"Datei: {path}")
print("Bitte SOFORT sichern -- ohne sie ist keine ausgestellte Lizenz mehr pruefbar.") print("Bitte SOFORT sichern – ohne sie ist keine ausgestellte Lizenz mehr prüfbar.")
print(f"Oeffentlicher Schluessel (zum Pinnen beim Kunden):\n{key.public_key}") print(f"Öffentlicher Schlüssel (zum Pinnen beim Kunden):\n{key.public_key}")
return 0 return 0
@@ -65,11 +65,11 @@ def cmd_bootstrap(args: argparse.Namespace) -> int:
"""Stellt die eigene Lizenz dieser Wurzelinstanz aus -- nur in der Master-Ausgabe.""" """Stellt die eigene Lizenz dieser Wurzelinstanz aus -- nur in der Master-Ausgabe."""
if not is_master_edition(): if not is_master_edition():
for zeile in ( for zeile in (
"Diese Ausgabe ist fuer Sub-Lizenzserver und kann keine Master-Lizenz erzeugen.", "Diese Ausgabe ist für Sub-Lizenzserver und kann keine Master-Lizenz erzeugen.",
"", "",
"Ein Sub-Lizenzserver bekommt seine Lizenz vom Master-Lizenzserver und spielt", "Ein Sub-Lizenzserver bekommt seine Lizenz vom Master-Lizenzserver und spielt",
"sie unter 'Eigene Lizenz' ein. Danach stellt er selbst Kundenlizenzen aus --", "sie unter 'Eigene Lizenz' ein. Danach stellt er selbst Kundenlizenzen aus --",
"mit seinem eigenen Signaturschluessel, der beim Installieren entstanden ist.", "mit seinem eigenen Signaturschlüssel, der beim Installieren entstanden ist.",
"", "",
"Die Master-Ausgabe liegt im Repository tesm-license-master.", "Die Master-Ausgabe liegt im Repository tesm-license-master.",
): ):
@@ -83,10 +83,10 @@ def cmd_bootstrap(args: argparse.Namespace) -> int:
app, extension, days=int(args.days), name=args.name app, extension, days=int(args.days), name=args.name
) )
print(f"Master-Lizenz ausgestellt: {document['license_id']}") print(f"Master-Lizenz ausgestellt: {document['license_id']}")
print(f"Gueltig bis: {document['expires_at']}") print(f"Gültig bis: {document['expires_at']}")
print(f"Datei: {extension.config.license_path}") print(f"Datei: {extension.config.license_path}")
if state is not None: if state is not None:
print(f"Zustand: gueltig={state.valid}, aktiviert={state.activated}, Rolle={state.role}") print(f"Zustand: gültig={state.valid}, aktiviert={state.activated}, Rolle={state.role}")
return 0 return 0
@@ -98,7 +98,7 @@ def cmd_create_admin(args: argparse.Namespace) -> int:
return 2 return 2
password = args.password or getpass.getpass("Passwort: ") password = args.password or getpass.getpass("Passwort: ")
if not args.password and password != getpass.getpass("Passwort wiederholen: "): if not args.password and password != getpass.getpass("Passwort wiederholen: "):
print("Die Passwoerter stimmen nicht ueberein.", file=sys.stderr) print("Die Passwörter stimmen nicht überein.", file=sys.stderr)
return 2 return 2
problems = extension.password_policy.check(password, username=username) problems = extension.password_policy.check(password, username=username)
if problems and not args.force: if problems and not args.force:
@@ -180,7 +180,7 @@ def cmd_status(args: argparse.Namespace) -> int:
+ ("unversehrt" if chain["ok"] else f"GEBROCHEN bei {chain['broken_at']}") + ("unversehrt" if chain["ok"] else f"GEBROCHEN bei {chain['broken_at']}")
) )
print( print(
"Signaturschluessel: " "Signaturschlüssel: "
+ (str(key_health.get("key_id")) if key_health.get("present") else "FEHLT") + (str(key_health.get("key_id")) if key_health.get("present") else "FEHLT")
) )
for problem in key_health.get("problems", []): for problem in key_health.get("problems", []):
@@ -253,17 +253,17 @@ def cmd_migrate_legacy(args: argparse.Namespace) -> int:
except legacy_import.LegacyImportError as exc: except legacy_import.LegacyImportError as exc:
if args.adopt_key: if args.adopt_key:
raise raise
print(f"Hinweis: alter Signaturschluessel nicht lesbar ({exc}).") print(f"Hinweis: alter Signaturschlüssel nicht lesbar ({exc}).")
if key_path.is_file(): if key_path.is_file():
existing = master_key.load(key_path) existing = master_key.load(key_path)
report.key_id = existing.key_id report.key_id = existing.key_id
if legacy_public and existing.public_key == legacy_public: if legacy_public and existing.public_key == legacy_public:
print(f"Signaturschluessel stimmt bereits ueberein ({existing.key_id}).") print(f"Signaturschlüssel stimmt bereits überein ({existing.key_id}).")
elif not legacy_public: elif not legacy_public:
issuer_changes = True issuer_changes = True
print( print(
f"Es wird mit dem vorhandenen Signaturschluessel {existing.key_id} " f"Es wird mit dem vorhandenen Signaturschlüssel {existing.key_id} "
"gearbeitet." "gearbeitet."
) )
else: else:
@@ -271,7 +271,7 @@ def cmd_migrate_legacy(args: argparse.Namespace) -> int:
# ausgestellte Lizenz waere danach unpruefbar. # ausgestellte Lizenz waere danach unpruefbar.
issuer_changes = True issuer_changes = True
print( print(
f"Diese Installation hat einen eigenen Signaturschluessel " f"Diese Installation hat einen eigenen Signaturschlüssel "
f"({existing.key_id}); der alte war {lic.key_id(legacy_public)}. " f"({existing.key_id}); der alte war {lic.key_id(legacy_public)}. "
"Es wird mit dem vorhandenen weitergearbeitet." "Es wird mit dem vorhandenen weitergearbeitet."
) )
@@ -279,12 +279,12 @@ def cmd_migrate_legacy(args: argparse.Namespace) -> int:
legacy_private, legacy_public = legacy_import.read_legacy_key(root) 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_id = legacy_import.adopt_key(legacy_private, legacy_public, key_path)
report.key_adopted = True report.key_adopted = True
print(f"Signaturschluessel uebernommen: {report.key_id}") print(f"Signaturschlüssel übernommen: {report.key_id}")
else: else:
print( print(
"Diese Installation hat noch keinen Signaturschluessel. Mit --adopt-key " "Diese Installation hat noch keinen Signaturschlüssel. Mit --adopt-key "
"wird der alte uebernommen -- dann bleibt die Anbieteridentitaet erhalten " "wird der alte übernommen – dann bleibt die Anbieteridentität erhalten "
"und Kunden muessen keinen Ausstellerwechsel bestaetigen.", "und Kunden müssen keinen Ausstellerwechsel bestätigen.",
file=sys.stderr, file=sys.stderr,
) )
return 2 return 2
@@ -312,7 +312,7 @@ def cmd_migrate_legacy(args: argparse.Namespace) -> int:
# Uebernahme. # Uebernahme.
for einstellung, wert in uebernommen.items(): for einstellung, wert in uebernommen.items():
gekuerzt = wert if len(wert) <= 60 else wert[:57] + "..." gekuerzt = wert if len(wert) <= 60 else wert[:57] + "..."
print(f"Uebernommen: {einstellung} = {gekuerzt}") print(f"Übernommen: {einstellung} = {gekuerzt}")
vendor = { vendor = {
"name": str(extension.settings.get(conn, "vendor_name") or "Eigenbetrieb"), "name": str(extension.settings.get(conn, "vendor_name") or "Eigenbetrieb"),
@@ -326,7 +326,7 @@ def cmd_migrate_legacy(args: argparse.Namespace) -> int:
print( print(
"Es ist kein Endpunkt hinterlegt, und die alte Installation nennt " "Es ist kein Endpunkt hinterlegt, und die alte Installation nennt "
"auch keinen. Bitte unter Einstellungen -> Anbieter eintragen: ohne " "auch keinen. Bitte unter Einstellungen -> Anbieter eintragen: ohne "
"ihn koennen uebernommene Lizenzen sich spaeter nicht aktivieren.", "ihn können übernommene Lizenzen sich später nicht aktivieren.",
file=sys.stderr, file=sys.stderr,
) )
return 2 return 2
@@ -347,10 +347,10 @@ def cmd_migrate_legacy(args: argparse.Namespace) -> int:
result.key_id = report.key_id result.key_id = report.key_id
if issuer_changes: if issuer_changes:
result.warnings.append( result.warnings.append(
"Die Lizenzen sind mit einem anderen Schluessel signiert als " "Die Lizenzen sind mit einem anderen Schlüssel signiert als "
"frueher. Kunden, die den alten Aussteller gepinnt haben, muessen " "früher. Kunden, die den alten Aussteller gepinnt haben, müssen "
"beim Einspielen einmal 'Wechsel des Ausstellers ausdruecklich " "beim Einspielen einmal 'Wechsel des Ausstellers ausdrücklich "
"zulassen' bestaetigen." "zulassen' bestätigen."
) )
if args.dry_run: if args.dry_run:
# Nichts festschreiben -- der Lauf war nur zur Ansicht. # Nichts festschreiben -- der Lauf war nur zur Ansicht.
@@ -385,7 +385,7 @@ def build_parser() -> argparse.ArgumentParser:
) )
sub = parser.add_subparsers(dest="command", required=True) sub = parser.add_subparsers(dest="command", required=True)
init_key = sub.add_parser("init-key", help="Signaturschluessel erzeugen (einmalig)") init_key = sub.add_parser("init-key", help="Signaturschlüssel erzeugen (einmalig)")
init_key.add_argument("--force", action="store_true") init_key.add_argument("--force", action="store_true")
init_key.set_defaults(func=cmd_init_key) init_key.set_defaults(func=cmd_init_key)
@@ -401,7 +401,7 @@ def build_parser() -> argparse.ArgumentParser:
bootstrap.add_argument("--name", default="Lizenzserver (Eigenbetrieb)") bootstrap.add_argument("--name", default="Lizenzserver (Eigenbetrieb)")
bootstrap.set_defaults(func=cmd_bootstrap) bootstrap.set_defaults(func=cmd_bootstrap)
admin = sub.add_parser("create-admin", help="Administratorkonto anlegen oder zuruecksetzen") admin = sub.add_parser("create-admin", help="Administratorkonto anlegen oder zurücksetzen")
admin.add_argument("--username") admin.add_argument("--username")
admin.add_argument("--password") admin.add_argument("--password")
admin.add_argument("--force", action="store_true") admin.add_argument("--force", action="store_true")
@@ -411,16 +411,16 @@ def build_parser() -> argparse.ArgumentParser:
status.add_argument("--json", action="store_true") status.add_argument("--json", action="store_true")
status.set_defaults(func=cmd_status) status.set_defaults(func=cmd_status)
house = sub.add_parser("housekeeping", help="Laufzeitdaten aufraeumen") house = sub.add_parser("housekeeping", help="Laufzeitdaten aufräumen")
house.set_defaults(func=cmd_housekeeping) house.set_defaults(func=cmd_housekeeping)
migrate = sub.add_parser( migrate = sub.add_parser(
"migrate-legacy", "migrate-legacy",
help="Lizenzserver der Vorgaengerfassung uebernehmen", help="Lizenzserver der Vorgängerfassung übernehmen",
description=( description=(
"Uebernimmt Signaturschluessel, Kunden, Tickets und Lizenzen aus einer alten " "Übernimmt Signaturschlüssel, Kunden, Tickets und Lizenzen aus einer alten "
"Installation. Die alte Installation wird nur gelesen. Jede Lizenz wird im " "Installation. Die alte Installation wird nur gelesen. Jede Lizenz wird im "
"neuen Format neu signiert -- die Kunden brauchen danach eine neue " "neuen Format neu signiert – die Kunden brauchen danach eine neue "
"Lizenzdatei, weil die alte Signatur nicht mehr gilt." "Lizenzdatei, weil die alte Signatur nicht mehr gilt."
), ),
) )
@@ -430,13 +430,13 @@ def build_parser() -> argparse.ArgumentParser:
migrate.add_argument( migrate.add_argument(
"--adopt-key", "--adopt-key",
action="store_true", action="store_true",
help="Den alten Signaturschluessel uebernehmen. Nur moeglich, solange diese " help="Den alten Signaturschlüssel übernehmen. Nur möglich, solange diese "
"Installation noch keinen eigenen hat -- ein vorhandener wird nie ueberschrieben.", "Installation noch keinen eigenen hat – ein vorhandener wird nie überschrieben.",
) )
migrate.add_argument( migrate.add_argument(
"--dry-run", "--dry-run",
action="store_true", action="store_true",
help="Nur anzeigen, was passieren wuerde -- nichts schreiben", help="Nur anzeigen, was passieren würde – nichts schreiben",
) )
migrate.set_defaults(func=cmd_migrate_legacy) migrate.set_defaults(func=cmd_migrate_legacy)
@@ -28,41 +28,41 @@ def _is_master(_user: Any) -> bool:
NAVIGATION = NavTree( NAVIGATION = NavTree(
[ [
NavItem(key="index", label="Uebersicht", icon="grid", endpoint="dashboard.index"), NavItem(key="index", label="Übersicht", icon="grid", endpoint="dashboard.index"),
NavItem( NavItem(
key="customers", key="customers",
label="Kunden", label="Kunden",
icon="building", icon="building",
endpoint="customers.overview", endpoint="customers.overview",
permissions=("customers.view",), permissions=("customers.read",),
), ),
NavItem( NavItem(
key="tickets", key="tickets",
label="Tickets", label="Tickets",
icon="ticket", icon="ticket",
endpoint="tickets.overview", endpoint="tickets.overview",
permissions=("tickets.view",), permissions=("tickets.read",),
), ),
NavItem( NavItem(
key="licenses", key="licenses",
label="Lizenzen", label="Lizenzen",
icon="shield", icon="shield",
endpoint="licenses.overview", endpoint="licenses.overview",
permissions=("licenses.view",), permissions=("licenses.read",),
), ),
NavItem( NavItem(
key="offline_code", key="offline_code",
label="Offline-Aktivierung", label="Offline-Aktivierung",
icon="transfer", icon="transfer",
endpoint="licenses.manual_code", endpoint="licenses.manual_code",
permissions=("licenses.execute",), permissions=("license_activation.execute",),
), ),
NavItem( NavItem(
key="reports", key="reports",
label="Sub-Lizenzserver", label="Sub-Lizenzserver",
icon="share", icon="share",
endpoint="licenses.reports", endpoint="licenses.reports",
permissions=("reports.view",), permissions=("reports.read",),
condition=_is_master, condition=_is_master,
), ),
NavItem( NavItem(
@@ -72,24 +72,24 @@ NAVIGATION = NavTree(
children=( children=(
NavItem( NavItem(
key="logs_live", key="logs_live",
label="Laufend", label="Live",
icon="terminal", icon="terminal",
endpoint="logs.live", endpoint="logs.live",
permissions=("logs_live.view",), permissions=("logs_live.read",),
), ),
NavItem( NavItem(
key="logs_history", key="logs_history",
label="Verlauf", label="Verlauf",
icon="clock", icon="clock",
endpoint="logs.history", endpoint="logs.history",
permissions=("logs_history.view", "logs_history.export"), permissions=("logs_history.read", "logs_history.export"),
), ),
NavItem( NavItem(
key="logs_audit", key="logs_audit",
label="Audit", label="Audit",
icon="history", icon="history",
endpoint="logs.audit_view", endpoint="logs.audit_view",
permissions=("logs_audit.view", "logs_audit.export"), permissions=("logs_audit.read", "logs_audit.export"),
), ),
), ),
), ),
@@ -103,84 +103,84 @@ NAVIGATION = NavTree(
label="Benutzer", label="Benutzer",
icon="users", icon="users",
endpoint="admin.users", endpoint="admin.users",
permissions=("users.view",), permissions=("users.read",),
), ),
NavItem( NavItem(
key="groups", key="groups",
label="Gruppen und Rechte", label="Gruppen und Rechte",
icon="shield-users", icon="shield-users",
endpoint="admin.groups", endpoint="admin.groups",
permissions=("groups.view",), permissions=("groups.read",),
), ),
NavItem( NavItem(
key="settings_vendor", key="settings_vendor",
label="Anbieterdaten", label="Anbieterdaten",
icon="building", icon="building",
endpoint="settings.vendor", endpoint="settings.vendor",
permissions=("settings_vendor.view",), permissions=("settings_vendor.read",),
), ),
NavItem( NavItem(
key="settings_mail", key="settings_mail",
label="E-Mail-Versand", label="E-Mail-Versand",
icon="mail", icon="mail",
endpoint="settings.mail_settings", endpoint="settings.mail_settings",
permissions=("settings_mail.view",), permissions=("settings_mail.read",),
), ),
NavItem( NavItem(
key="settings_system", key="settings_system",
label="System", label="System",
icon="sliders", icon="sliders",
endpoint="settings.system", endpoint="settings.system",
permissions=("settings_system.view",), permissions=("settings_system.read",),
), ),
NavItem( NavItem(
key="settings_network", key="settings_network",
label="Netzwerk", label="Netzwerk",
icon="network", icon="network",
endpoint="hostsettings.network", endpoint="hostsettings.network",
permissions=("settings_network.view",), permissions=("settings_network.read",),
), ),
NavItem( NavItem(
key="settings_web", key="settings_web",
label="Webserver und TLS", label="Webserver und TLS",
icon="server", icon="server",
endpoint="hostsettings.web", endpoint="hostsettings.web",
permissions=("settings_web.view",), permissions=("settings_web.read",),
), ),
NavItem( NavItem(
key="settings_directory", key="settings_directory",
label="Verzeichnisdienst", label="Verzeichnisdienst",
icon="users", icon="users",
endpoint="hostsettings.directory_settings", endpoint="hostsettings.directory_settings",
permissions=("settings_directory.view",), permissions=("settings_directory.read",),
), ),
NavItem( NavItem(
key="settings_license", key="settings_license",
label="Eigene Lizenz", label="Eigene Lizenz",
icon="key", icon="key",
endpoint="own_license.overview", endpoint="own_license.overview",
permissions=("settings_license.view",), permissions=("settings_license.read",),
), ),
NavItem( NavItem(
key="backup", key="backup",
label="Sicherung", label="Sicherung",
icon="transfer", icon="transfer",
endpoint="backup.overview", endpoint="backup.overview",
permissions=("backup.view", "backup.export", "backup.execute"), permissions=("backup_export.read", "backup_import.read"),
), ),
NavItem( NavItem(
key="trash", key="trash",
label="Papierkorb", label="Papierkorb",
icon="trash", icon="trash",
endpoint="trash.overview", endpoint="trash.overview",
permissions=("trash.view",), permissions=("trash.read",),
), ),
NavItem( NavItem(
key="diagnostics", key="diagnostics",
label="Diagnose", label="Diagnose",
icon="info", icon="info",
endpoint="diagnostics.overview", endpoint="diagnostics.overview",
permissions=("diagnostics.view",), permissions=("diagnostics.read",),
), ),
), ),
), ),
+112 -69
View File
@@ -28,58 +28,97 @@ A = Action
CUSTOMERS = Resource( CUSTOMERS = Resource(
key="customers", key="customers",
label="Kunden", label="Kunden",
actions=(A.VIEW, A.CREATE, A.EDIT, A.DELETE), actions=(A.READ, A.CREATE, A.WRITE, A.DELETE),
hint="Stammdaten der Kundeninstanzen", hint="Stammdaten der Kundeninstanzen",
action_hints={A.DELETE: "Nur moeglich, solange kein Ticket am Kunden haengt."}, action_hints={A.DELETE: "Nur möglich, solange kein Ticket am Kunden hängt."},
) )
TICKETS = Resource( TICKETS = Resource(
key="tickets", key="tickets",
label="Tickets", label="Tickets",
actions=(A.VIEW, A.CREATE, A.EDIT, A.DELETE), actions=(A.READ, A.CREATE, A.WRITE, A.DELETE),
hint="Dauerhafte Anspruechte auf eine Lizenz", hint="Dauerhafte Ansprüche auf eine Lizenz",
action_hints={ action_hints={
A.EDIT: "Parameter aendern und Ticket sperren/entsperren. Wirkt erst auf die " A.WRITE: "Parameter ändern und Ticket sperren/entsperren. Wirkt erst auf die "
"naechste Ausstellung -- bereits ausgestellte Lizenzen bleiben unveraendert.", "nächste Ausstellung – bereits ausgestellte Lizenzen bleiben unverändert.",
A.DELETE: "Nur moeglich, wenn keine offene Lizenz mehr existiert.", A.DELETE: "Nur möglich, wenn keine offene Lizenz mehr existiert.",
}, },
) )
#: Aus einer Zeile wurden fuenf. "Aendern" hiess hier **widerrufen oder neu
#: signieren** -- zwei Eingriffe mit entgegengesetzter Wirkung unter einem
#: Haken: der eine verlaengert einem Kunden die Lizenz, der andere nimmt sie
#: ihm. Wer eine Laufzeit verlaengern durfte, konnte damit jede Lizenz
#: abschalten. Genau das soll die Matrix trennen koennen.
LICENSES = Resource( LICENSES = Resource(
key="licenses", key="licenses",
label="Lizenzen", label="Lizenzen",
actions=(A.VIEW, A.CREATE, A.EDIT, A.DELETE, A.EXECUTE, A.EXPORT), actions=(A.READ, A.DELETE, A.EXPORT),
hint="Konkrete, signierte Lizenzdateien", hint="Ausgestellte Lizenzen: Laufzeit, Zustand, Datei",
action_hints={ action_hints={
A.CREATE: "Aus einem Ticket eine Lizenz ausstellen.", A.DELETE: "Eintrag entfernen (nur ohne offene Aktivierung).",
A.EDIT: "Lizenz widerrufen oder eine neue Revision signieren.",
A.EXECUTE: "Offline-Aktivierungscodes verarbeiten.",
A.EXPORT: "Lizenzdatei herunterladen oder per E-Mail versenden.", A.EXPORT: "Lizenzdatei herunterladen oder per E-Mail versenden.",
}, },
) )
LICENSE_ISSUE = Resource(
key="license_issue",
parent="licenses",
label="Lizenz ausstellen",
actions=(A.READ, A.EXECUTE),
hint="Aus einem Ticket eine neue Lizenz signieren",
action_hints={A.EXECUTE: "Ausstellen und signieren."},
)
LICENSE_RENEW = Resource(
key="license_renew",
parent="licenses",
label="Lizenz erneuern",
actions=(A.READ, A.EXECUTE),
hint="Neue Revision einer bestehenden Lizenz signieren",
action_hints={A.EXECUTE: "Erneuern – die alte Revision verliert ihre Gültigkeit."},
)
LICENSE_REVOKE = Resource(
key="license_revoke",
parent="licenses",
label="Lizenz widerrufen",
actions=(A.READ, A.EXECUTE),
hint="Eine ausgestellte Lizenz ungültig machen",
action_hints={A.EXECUTE: "Widerrufen. Der Kunde verliert den Zugang."},
)
LICENSE_ACTIVATION = Resource(
key="license_activation",
parent="licenses",
label="Aktivierung von Hand",
actions=(A.READ, A.EXECUTE),
hint="Offline-Codes für Kunden ohne Internetzugang",
action_hints={A.EXECUTE: "Code prüfen und Antwortcode erzeugen."},
)
PORTAL = Resource( PORTAL = Resource(
key="portal", key="portal",
label="Kundenportal", label="Kundenportal",
actions=(A.VIEW, A.EXECUTE), actions=(A.READ, A.EXECUTE),
hint="Zugangslinks fuer Kunden", hint="Zugangslinks für Kunden",
action_hints={A.EXECUTE: "Zugangslink erzeugen und versenden."}, action_hints={A.EXECUTE: "Zugangslink erzeugen und versenden."},
) )
REPORTS = Resource( REPORTS = Resource(
key="reports", key="reports",
label="Berichte der Sub-Lizenzserver", label="Berichte der Sub-Lizenzserver",
actions=(A.VIEW,), actions=(A.READ,),
hint="Von untergeordneten Lizenzservern gemeldete Kunden, Tickets und Lizenzen", hint="Von untergeordneten Lizenzservern gemeldete Kunden, Tickets und Lizenzen",
) )
MAIL = Resource( MAIL = Resource(
key="settings_mail", key="settings_mail",
label="E-Mail-Versand", label="E-Mail-Versand",
actions=(A.VIEW, A.EDIT, A.EXECUTE, A.SECRETS), actions=(A.READ, A.WRITE, A.EXECUTE, A.SECRETS),
hint="Microsoft Graph (App-only)", hint="Microsoft Graph (App-only)",
action_hints={ action_hints={
A.EXECUTE: "Verbindungstest ausfuehren.", A.EXECUTE: "Verbindungstest ausführen.",
A.SECRETS: "Client-Secret setzen.", A.SECRETS: "Client-Secret setzen.",
}, },
) )
@@ -87,8 +126,8 @@ MAIL = Resource(
VENDOR = Resource( VENDOR = Resource(
key="settings_vendor", key="settings_vendor",
label="Anbieterdaten", label="Anbieterdaten",
actions=(A.VIEW, A.EDIT), actions=(A.READ, A.WRITE),
hint="Name, Kontakt, Logo und oeffentlicher Endpunkt -- reisen in jeder Lizenz mit", hint="Name, Kontakt, Logo und öffentlicher Endpunkt – reisen in jeder Lizenz mit",
) )
LICENSING_AREA = Area( LICENSING_AREA = Area(
@@ -96,7 +135,17 @@ LICENSING_AREA = Area(
label="Lizenzierung", label="Lizenzierung",
icon="key", icon="key",
hint="Kunden, Tickets, Lizenzen. Ohne diesen Zugang bleibt der gesamte Bereich verborgen.", hint="Kunden, Tickets, Lizenzen. Ohne diesen Zugang bleibt der gesamte Bereich verborgen.",
resources=(CUSTOMERS, TICKETS, LICENSES, PORTAL, REPORTS), resources=(
CUSTOMERS,
TICKETS,
LICENSES,
LICENSE_ISSUE,
LICENSE_RENEW,
LICENSE_REVOKE,
LICENSE_ACTIVATION,
PORTAL,
REPORTS,
),
) )
PERMISSIONS = PermissionTree( PERMISSIONS = PermissionTree(
@@ -108,76 +157,70 @@ PERMISSIONS = PermissionTree(
) )
#: Alle Funktionsrechte ausserhalb der Verwaltung, in einer Gruppe.
#:
#: Seit 2.8.0 heisst das Administratorkennzeichen "Verwaltung" und nicht
#: "alles": Geraete, Neustarts, DHCP und Freigaben kommen ueber Gruppen. Diese
#: Vorlage ist die Gruppe, die man sich dafuer zuweist -- und die Migration
#: gibt sie bestehenden Administratoren, damit nach dem Update nichts fehlt,
#: was vorher ging. Entfernen kann man sie mit einem Klick.
#:
#: Die Liste entsteht aus dem Baum und nicht aus Tippen: eine neue Zeile in der
#: Matrix ist sonst genau die, die hier vergessen wird.
FUNKTIONEN_VOLLZUGRIFF = RolePreset(
key="functions",
name="Funktionen (Vollzugriff)",
description=(
"Alle Rechte ausserhalb der Verwaltung. Gedacht fuer Administratoren, "
"die nicht nur einstellen, sondern auch bedienen."
),
grants=tuple(
(resource.key, "*")
for area in PERMISSIONS.areas
if area.key != PermissionTree.VERWALTUNGSBEREICH
for resource in area.resources
),
)
#: Wie in TESM: drei Gruppen ab Werk, nicht sechs.
#:
#: Auch "Vertrieb" ist hier entfallen, obwohl es die Rolle ist, die den
#: Lizenzserver taeglich benutzt -- gerade deshalb: wer Lizenzen ausstellt,
#: schneidet die Gruppe ohnehin auf sein Haus zu (darf Vertrieb widerrufen?
#: Tickets loeschen? in die Protokolle sehen?). Eine mitgelieferte Antwort auf
#: diese Frage wird entweder uebernommen, ohne sie zu pruefen, oder sie wird
#: umgebaut. "Funktionen (Vollzugriff)" ist der ehrlichere Startpunkt.
ROLE_PRESETS: tuple[RolePreset, ...] = ( ROLE_PRESETS: tuple[RolePreset, ...] = (
RolePreset( RolePreset(
key="viewer", key="viewer",
name="Benutzer", name="Benutzer",
description="Standardgruppe: nur das laufende Protokoll lesen.", description="Standardgruppe: nur das laufende Protokoll lesen.",
grants=(("logs_live", (A.VIEW,)),), grants=(("logs_live", (A.READ,)),),
is_default=True, 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( RolePreset(
key="administration", key="administration",
name="Administration", name="Administration",
description="Benutzer, Gruppen und Einstellungen -- ohne Admin-Kennzeichen.", description="Benutzer, Gruppen und Einstellungen – ohne Admin-Kennzeichen.",
grants=( grants=(
("users", "*"), ("users", "*"),
("groups", "*"), ("groups", "*"),
("settings_system", "*"), ("settings_system", "*"),
("settings_nav", "*"),
("settings_license", "*"), ("settings_license", "*"),
("settings_directory", (A.VIEW, A.EDIT, A.EXECUTE)), ("settings_directory", (A.READ, A.WRITE, A.EXECUTE)),
("settings_web", "*"), ("settings_web", "*"),
("settings_network", "*"), ("settings_network", "*"),
("settings_mail", (A.VIEW, A.EDIT, A.EXECUTE)), ("settings_mail", (A.READ, A.WRITE, A.EXECUTE)),
("settings_vendor", "*"), ("settings_vendor", "*"),
("backup", (A.VIEW, A.EXECUTE, A.EXPORT)), ("backup", (A.READ, A.EXECUTE, A.EXPORT)),
("trash", "*"), ("trash", "*"),
("diagnostics", (A.VIEW,)), ("diagnostics", (A.READ,)),
("logs_audit", (A.VIEW,)), ("logs_audit", (A.READ,)),
), ),
), ),
FUNKTIONEN_VOLLZUGRIFF,
) )
__all__ = ["PERMISSIONS", "ROLE_PRESETS"] __all__ = ["PERMISSIONS", "ROLE_PRESETS"]
+125 -7
View File
@@ -27,6 +27,8 @@ Aenderungen gegenueber dem Vorgaenger:
from __future__ import annotations from __future__ import annotations
from typing import Any
from tesm_core.migrations import Migration from tesm_core.migrations import Migration
V1 = """ V1 = """
@@ -103,12 +105,17 @@ CREATE INDEX IF NOT EXISTS idx_licenses_customer ON licenses(customer_id);
CREATE INDEX IF NOT EXISTS idx_licenses_status ON licenses(status); CREATE INDEX IF NOT EXISTS idx_licenses_status ON licenses(status);
CREATE INDEX IF NOT EXISTS idx_licenses_fingerprint ON licenses(fingerprint); CREATE INDEX IF NOT EXISTS idx_licenses_fingerprint ON licenses(fingerprint);
-- Hoechstens eine offene Lizenz pro Ticket -- auf Datenbankebene, nicht nur im -- Hoechstens eine offene Lizenz **je Art** pro Ticket -- auf Datenbankebene,
-- Python-Code. Damit ist der Doppelklick-Fall (zwei Tabs, zwei Anfragen) sauber -- nicht nur im Python-Code. Damit ist der Doppelklick-Fall (zwei Tabs, zwei
-- abgefangen: der verlierende INSERT scheitert und der Aufrufer gibt die -- Anfragen) sauber abgefangen: der verlierende INSERT scheitert und der
-- bereits erzeugte Lizenz zurueck. -- 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'); -- "Je Art", weil ein Ticket beides tragen darf: eine Testlizenz zum Ansehen
-- und die richtige Lizenz. Vorher war es ein Index ueber ticket_id allein --
-- und damit schloss ein dreissigtaegiger Test die Ausstellung aus.
CREATE UNIQUE INDEX IF NOT EXISTS idx_one_open_license_per_ticket_art
ON licenses(ticket_id, CASE WHEN type = 'trial' THEN 1 ELSE 0 END)
WHERE status IN ('issued','active');
-- Verbrauchte Nonces des Aktivierungsprotokolls. Verhindert Replay. -- Verbrauchte Nonces des Aktivierungsprotokolls. Verhindert Replay.
CREATE TABLE IF NOT EXISTS license_nonces ( CREATE TABLE IF NOT EXISTS license_nonces (
@@ -210,7 +217,118 @@ CREATE INDEX IF NOT EXISTS idx_licenses_revision ON licenses(revision_at);
""" """
def uebergang_funktionsgruppe(conn) -> None: # type: ignore[no-untyped-def]
"""Wie in TESM: das Kennzeichen heisst ab 2.8.0 "Verwaltung".
Ohne diesen Schritt koennte ein bestehender Administrator nach dem Update
keine Lizenz mehr ausstellen -- und wuerde das fuer einen Fehler halten.
"""
from tesm_core.rbac import service as rbac
from .permissions import FUNKTIONEN_VOLLZUGRIFF, PERMISSIONS
rbac.funktionsgruppe_fuer_administratoren(conn, PERMISSIONS, FUNKTIONEN_VOLLZUGRIFF)
#: Die drei Vorlagen, die es bis 2.8.2 gab -- eingefrorene Kopie, siehe TESM.
ABGELEGTE_VORLAGEN: tuple[tuple[str, str, tuple[tuple[str, Any], ...]], ...] = (
(
"sales",
"Vertrieb",
(
("customers", "*"),
("tickets", "*"),
("licenses", ("read", "export")),
("license_issue", "*"),
("license_renew", "*"),
("license_revoke", "*"),
("portal", "*"),
("logs_live", ("read",)),
("logs_audit", ("read",)),
),
),
(
"support",
"Support",
(
("customers", ("read",)),
("tickets", ("read",)),
("licenses", ("read",)),
("license_activation", "*"),
("reports", ("read",)),
("logs_live", ("read",)),
),
),
(
"auditor",
"Revision",
(
("customers", ("read",)),
("tickets", ("read",)),
("licenses", ("read",)),
("reports", ("read",)),
("users", ("read",)),
("groups", ("read",)),
("logs_live", ("read",)),
("logs_history", ("read", "export")),
("logs_audit", ("read", "export")),
("diagnostics", ("read",)),
),
),
)
def uebergang_standardgruppen(conn) -> None: # type: ignore[no-untyped-def]
"""Wie in TESM: unberuehrte Vorlagen in den Papierkorb, benutzte bleiben."""
from tesm_core.rbac import service as rbac
from tesm_core.rbac.model import Action, RolePreset
from .permissions import PERMISSIONS
vorlagen = [
RolePreset(
key=key,
name=name,
description="",
grants=tuple(
(ressource, a if a == "*" else tuple(Action(x) for x in a))
for ressource, a in grants
),
)
for key, name, grants in ABGELEGTE_VORLAGEN
]
rbac.unberuehrte_vorlagen_entfernen(conn, PERMISSIONS, vorlagen)
#: Ein Ticket darf eine Testlizenz **und** eine Volllizenz tragen.
#:
#: Der alte Index stand ueber ``ticket_id`` allein: sobald irgendeine Lizenz
#: dieses Tickets lief, ging keine zweite mehr. Damit schloss ein
#: dreissigtaegiger Blick ins Produkt die richtige Ausstellung aus -- und wer
#: zuerst ausstellte, konnte nichts mehr testen lassen. Die Regel bleibt, sie
#: zaehlt nur die Art mit.
V5 = """
DROP INDEX IF EXISTS idx_one_open_license_per_ticket;
CREATE UNIQUE INDEX IF NOT EXISTS idx_one_open_license_per_ticket_art
ON licenses(ticket_id, CASE WHEN type = 'trial' THEN 1 ELSE 0 END)
WHERE status IN ('issued','active');
"""
LICENSE_MIGRATIONS: tuple[Migration, ...] = ( LICENSE_MIGRATIONS: tuple[Migration, ...] = (
Migration(version=1, name="baseline", sql=V1, allow_integrity_error=True), Migration(
version=1,
name="baseline",
sql=V1,
allow_integrity_error=True,
geaendert_weil=(
"Der eindeutige Index ueber offene Lizenzen zaehlt seit 2.8.12 die Art mit "
"(Testlizenz neben Volllizenz). Eine frische Installation soll ihn gleich "
"richtig anlegen und nicht erst ueber tesm_license/5 umbauen; auf "
"bestehenden Installationen erledigt das die Migration 5."
),
),
Migration(version=2, name="revision-timestamp", sql=V2), Migration(version=2, name="revision-timestamp", sql=V2),
Migration(version=3, name="funktionsgruppe-fuer-admins", fn=uebergang_funktionsgruppe),
Migration(version=4, name="standardgruppen-aufraeumen", fn=uebergang_standardgruppen),
Migration(version=5, name="testlizenz-neben-volllizenz", sql=V5),
) )
@@ -132,7 +132,7 @@ def _verify(
seen_nonce=seen, seen_nonce=seen,
) )
except lic.SignatureInvalid: except lic.SignatureInvalid:
return None, _error(400, "invalid_signature", "Signatur der Anfrage ist ungueltig.") return None, _error(400, "invalid_signature", "Signatur der Anfrage ist ungültig.")
except lic.ReplayDetected as exc: except lic.ReplayDetected as exc:
return None, _error(409, "replay", str(exc)) return None, _error(409, "replay", str(exc))
except lic.ProtocolViolation as exc: except lic.ProtocolViolation as exc:
@@ -150,7 +150,7 @@ def process_activate(
) -> Outcome: ) -> Outcome:
row, error = _verify(conn, request, expected_action="activate") row, error = _verify(conn, request, expected_action="activate")
if error is not None or row is None: if error is not None or row is None:
return error or _error(400, "bad_request", "Ungueltige Anfrage.") return error or _error(400, "bad_request", "Ungültige Anfrage.")
fingerprint = str(request["fingerprint"]) fingerprint = str(request["fingerprint"])
hostname = str(request.get("hostname") or "") hostname = str(request.get("hostname") or "")
@@ -202,7 +202,7 @@ def process_activate(
target=str(row["license_id"]), target=str(row["license_id"]),
detail=( detail=(
f"Fingerprint {fingerprint[:16]}..., Host {hostname or 'unbekannt'}" f"Fingerprint {fingerprint[:16]}..., Host {hostname or 'unbekannt'}"
+ (f", verdraengt {len(superseded)} Lizenz(en)" if superseded else "") + (f", verdrängt {len(superseded)} Lizenz(en)" if superseded else "")
), ),
severity="notice", severity="notice",
) )
@@ -211,7 +211,7 @@ def process_activate(
request, request,
master_private_key=master_private_key, master_private_key=master_private_key,
status="ok", status="ok",
detail="Aktivierung bestaetigt.", detail="Aktivierung bestätigt.",
) )
@@ -220,7 +220,7 @@ def process_deactivate(
) -> Outcome: ) -> Outcome:
row, error = _verify(conn, request, expected_action="deactivate") row, error = _verify(conn, request, expected_action="deactivate")
if error is not None or row is None: if error is not None or row is None:
return error or _error(400, "bad_request", "Ungueltige Anfrage.") return error or _error(400, "bad_request", "Ungültige Anfrage.")
fingerprint = str(request["fingerprint"]) fingerprint = str(request["fingerprint"])
if str(row["status"]) != "active" or str(row["fingerprint"] or "") != fingerprint: if str(row["status"]) != "active" or str(row["fingerprint"] or "") != fingerprint:
@@ -249,7 +249,7 @@ def process_deactivate(
request, request,
master_private_key=master_private_key, master_private_key=master_private_key,
status="ok", status="ok",
detail="Deaktivierung bestaetigt. Die Lizenz kann auf einer anderen Maschine aktiviert werden.", detail="Deaktivierung bestätigt. Die Lizenz kann auf einer anderen Maschine aktiviert werden.",
) )
@@ -258,7 +258,7 @@ def process_heartbeat(
) -> Outcome: ) -> Outcome:
row, error = _verify(conn, request, expected_action="heartbeat") row, error = _verify(conn, request, expected_action="heartbeat")
if error is not None or row is None: if error is not None or row is None:
return error or _error(400, "bad_request", "Ungueltige Anfrage.") return error or _error(400, "bad_request", "Ungültige Anfrage.")
fingerprint = str(request["fingerprint"]) fingerprint = str(request["fingerprint"])
hostname = str(request.get("hostname") or "") hostname = str(request.get("hostname") or "")
@@ -306,7 +306,7 @@ def process_heartbeat(
) )
else: else:
logger.warning( logger.warning(
"Bericht von Lizenz %s verworfen -- Rolle ist %s, nicht license_server", "Bericht von Lizenz %s verworfen | Rolle ist %s, nicht license_server",
row["license_id"], row["license_id"],
row["role"], row["role"],
) )
@@ -322,7 +322,7 @@ def process_heartbeat(
request, request,
master_private_key=master_private_key, master_private_key=master_private_key,
status="ok", status="ok",
detail="Verbindung bestaetigt.", detail="Verbindung bestätigt.",
license_update=update, license_update=update,
) )
@@ -7,7 +7,7 @@ import re
import secrets import secrets
import sqlite3 import sqlite3
from dataclasses import dataclass from dataclasses import dataclass
from datetime import datetime, timezone from datetime import datetime, timedelta, timezone
from typing import Any, Iterable, Sequence from typing import Any, Iterable, Sequence
import tesm_licensing as lic import tesm_licensing as lic
@@ -16,6 +16,28 @@ from tesm_core.db import Database
TICKET_TYPES = lic.LICENSE_TYPES TICKET_TYPES = lic.LICENSE_TYPES
TICKET_STATES = ("open", "blocked") TICKET_STATES = ("open", "blocked")
#: Wann ist ein Ticket wirklich offen?
#:
#: Ein Ticket ist ein **Anspruch** auf eine Lizenz. Ist sie ausgestellt und
#: gilt sie, ist der Anspruch eingeloest -- offen ist dann nichts mehr. Die
#: Kachel auf der Startseite zaehlte trotzdem jedes Ticket mit ``status='open'``
#: und meldete "3 offene Tickets", waehrend zu allen dreien laengst eine aktive
#: Lizenz gehoerte. Wer danach sucht, sucht umsonst.
#:
#: Abgeleitet statt gespeichert, und das ist Absicht: wird eine Lizenz spaeter
#: widerrufen, ist der Anspruch von selbst wieder offen -- genau dann soll neu
#: ausgestellt werden. Ein dritter Ticketzustand muesste an derselben Stelle
#: von Hand zurueckgedreht werden, und wer das einmal vergisst, hat ein Ticket,
#: das erledigt aussieht und keines ist.
#: Eine **Testlizenz zaehlt nicht**: sie ist ein Blick ins Produkt, kein
#: eingeloester Anspruch. Jedes Ticket darf eine laufen lassen und bleibt
#: trotzdem offen -- sonst muesste man sich zwischen "ausprobieren" und
#: "bekommen" entscheiden.
OHNE_GUELTIGE_LIZENZ = (
"NOT EXISTS (SELECT 1 FROM licenses l "
"WHERE l.ticket_id = t.id AND l.status IN ('issued','active') AND l.type <> 'trial')"
)
LICENSE_STATES = ("issued", "active", "deactivated", "revoked") LICENSE_STATES = ("issued", "active", "deactivated", "revoked")
#: Ampel-Schwellen der Anzeige. Bewusst getrennt von der Kulanzlogik in #: Ampel-Schwellen der Anzeige. Bewusst getrennt von der Kulanzlogik in
@@ -48,7 +70,7 @@ def validate_email(value: str, *, required: bool) -> str:
# sollen weiter bearbeitbar bleiben, statt ausgesperrt zu werden. # sollen weiter bearbeitbar bleiben, statt ausgesperrt zu werden.
return "" return ""
if not EMAIL_RE.match(text): if not EMAIL_RE.match(text):
raise IssuanceError(f"{text!r} ist keine gueltige E-Mail-Adresse.") raise IssuanceError(f"{text!r} ist keine gültige E-Mail-Adresse.")
return text return text
@@ -137,7 +159,7 @@ def delete_customer(conn: sqlite3.Connection, customer_id: int) -> None:
) )
if int(tickets or 0): if int(tickets or 0):
raise IssuanceError( raise IssuanceError(
f"An diesem Kunden haengen noch {tickets} Ticket(s). Bitte zuerst entfernen." f"An diesem Kunden hängen noch {tickets} Ticket(s). Bitte zuerst entfernen."
) )
conn.execute("DELETE FROM license_customers WHERE id=?", (customer_id,)) conn.execute("DELETE FROM license_customers WHERE id=?", (customer_id,))
@@ -224,7 +246,7 @@ def create_ticket(
raise IssuanceError("Unbekannter Lizenztyp.") raise IssuanceError("Unbekannter Lizenztyp.")
if role not in lic.LICENSE_ROLES or role == "master": if role not in lic.LICENSE_ROLES or role == "master":
# "master" entsteht ausschliesslich lokal per Bootstrap-Skript. # "master" entsteht ausschliesslich lokal per Bootstrap-Skript.
raise IssuanceError("Unzulaessige Rolle.") raise IssuanceError("Unzulässige Rolle.")
# Trial + unbefristet ist ein sinnloser Zustand: eine permanente Lizenz ohne # Trial + unbefristet ist ein sinnloser Zustand: eine permanente Lizenz ohne
# Module. Diese Regel wird an drei Stellen geprueft (hier, beim Bearbeiten # Module. Diese Regel wird an drei Stellen geprueft (hier, beim Bearbeiten
@@ -290,7 +312,7 @@ def update_ticket(
if license_type != str(ticket["type"]): if license_type != str(ticket["type"]):
changes.append(f"Typ: {ticket['type']} -> {license_type}") changes.append(f"Typ: {ticket['type']} -> {license_type}")
if new_modules != str(ticket["modules"]): if new_modules != str(ticket["modules"]):
changes.append("Module geaendert") changes.append("Module geändert")
if int(valid_days) != int(ticket["valid_days"]): if int(valid_days) != int(ticket["valid_days"]):
changes.append(f"Laufzeit: {ticket['valid_days']} -> {valid_days} Tage") changes.append(f"Laufzeit: {ticket['valid_days']} -> {valid_days} Tage")
if bool(lifetime) != bool(ticket["lifetime"]): if bool(lifetime) != bool(ticket["lifetime"]):
@@ -321,11 +343,22 @@ def set_ticket_status(conn: sqlite3.Connection, ticket_id: str, status: str) ->
) )
def ticket_has_open_license(conn: sqlite3.Connection, ticket_id: str) -> bool: def ticket_has_open_license(
conn: sqlite3.Connection, ticket_id: str, *, trial: bool = False
) -> bool:
"""Laeuft zu diesem Ticket schon eine Lizenz -- und zwar dieser Art?
Zwei Spuren, absichtlich getrennt: **eine** Testlizenz und **eine**
Volllizenz je Ticket. Vorher war es eine gemeinsame Sperre, und damit
schloss ein dreissigtaegiger Blick ins Produkt die richtige Ausstellung
aus -- oder umgekehrt. Wer testen laesst, will danach ausstellen.
"""
vergleich = "=" if trial else "<>"
return ( return (
Database.value( Database.value(
conn, conn,
"SELECT COUNT(*) FROM licenses WHERE ticket_id=? AND status IN ('issued','active')", "SELECT COUNT(*) FROM licenses WHERE ticket_id=? AND status IN ('issued','active') "
f"AND type {vergleich} 'trial'",
(ticket_id,), (ticket_id,),
default=0, default=0,
) )
@@ -333,8 +366,15 @@ def ticket_has_open_license(conn: sqlite3.Connection, ticket_id: str) -> bool:
) > 0 ) > 0
def ticket_has_any_open_license(conn: sqlite3.Connection, ticket_id: str) -> bool:
"""Irgendeine laufende Lizenz -- Test oder Voll. Fuers Loeschen zaehlt beides."""
return ticket_has_open_license(conn, ticket_id) or ticket_has_open_license(
conn, ticket_id, trial=True
)
def delete_ticket(conn: sqlite3.Connection, ticket_id: str) -> None: def delete_ticket(conn: sqlite3.Connection, ticket_id: str) -> None:
if ticket_has_open_license(conn, ticket_id): if ticket_has_any_open_license(conn, ticket_id):
raise IssuanceError( raise IssuanceError(
"Zu diesem Ticket existiert noch eine offene Lizenz. Bitte zuerst widerrufen." "Zu diesem Ticket existiert noch eine offene Lizenz. Bitte zuerst widerrufen."
) )
@@ -428,7 +468,18 @@ def issue_from_ticket(
role = str(ticket["role"]) role = str(ticket["role"])
if as_trial and role != "customer": if as_trial and role != "customer":
raise IssuanceError( raise IssuanceError(
"Eine Testlizenz gibt es nur fuer Kundeninstanzen, nicht fuer Lizenzserver." "Eine Testlizenz gibt es nur für Kundeninstanzen, nicht für Lizenzserver."
)
# Zwei Spuren je Ticket: eine Testlizenz **und** eine Volllizenz. Vorher
# sperrten sie sich gegenseitig -- ein dreissigtaegiger Blick ins Produkt
# schloss die richtige Ausstellung aus, und wer zuerst ausstellte, konnte
# nichts mehr testen lassen.
if ticket_has_open_license(conn, ticket_id, trial=as_trial):
raise IssuanceError(
"Für dieses Ticket läuft bereits eine Testlizenz."
if as_trial
else "Für dieses Ticket läuft bereits eine Lizenz."
) )
if as_trial: if as_trial:
@@ -452,9 +503,9 @@ def issue_from_ticket(
# hier, an der einzigen Stelle, durch die jede Ausstellung laeuft. # hier, an der einzigen Stelle, durch die jede Ausstellung laeuft.
if not str(master_endpoint or "").strip(): if not str(master_endpoint or "").strip():
raise IssuanceError( raise IssuanceError(
"Es ist kein oeffentlicher Endpunkt hinterlegt (Einstellungen -> Anbieter). " "Es ist kein öffentlicher Endpunkt hinterlegt (Einstellungen -> Anbieter). "
"Ohne ihn koennte sich diese Lizenz nie online aktivieren -- der Endpunkt " "Ohne ihn könnte sich diese Lizenz nie online aktivieren – der Endpunkt "
"wird in die Lizenz signiert und laesst sich spaeter nicht nachtragen." "wird in die Lizenz signiert und lässt sich später nicht nachtragen."
) )
customer = get_customer(conn, int(ticket["customer_id"])) customer = get_customer(conn, int(ticket["customer_id"]))
@@ -507,10 +558,11 @@ def issue_from_ticket(
# Der partielle Unique-Index hat zugeschlagen: ein zweiter Klick oder ein # Der partielle Unique-Index hat zugeschlagen: ein zweiter Klick oder ein
# zweiter Tab war schneller. Wir geben die tatsaechlich erzeugte Lizenz # zweiter Tab war schneller. Wir geben die tatsaechlich erzeugte Lizenz
# zurueck statt einen Fehler zu zeigen. # zurueck statt einen Fehler zu zeigen.
vergleich = "=" if as_trial else "<>"
existing = Database.one( existing = Database.one(
conn, conn,
"SELECT license_id, license_document FROM licenses " "SELECT license_id, license_document FROM licenses "
"WHERE ticket_id=? AND status IN ('issued','active')", f"WHERE ticket_id=? AND status IN ('issued','active') AND type {vergleich} 'trial'",
(ticket_id,), (ticket_id,),
) )
if existing is None: if existing is None:
@@ -538,6 +590,7 @@ def delivery_bundle(conn: sqlite3.Connection, license_id: str, *, keystore: Any)
row = get_license(conn, license_id) row = get_license(conn, license_id)
if row is None: if row is None:
raise IssuanceError("Diese Lizenz existiert nicht.") raise IssuanceError("Diese Lizenz existiert nicht.")
document = json.loads(str(row["license_document"])) document = json.loads(str(row["license_document"]))
bundle: dict[str, Any] = { bundle: dict[str, Any] = {
"kind": "tesm-license-bundle", "kind": "tesm-license-bundle",
@@ -550,7 +603,7 @@ def delivery_bundle(conn: sqlite3.Connection, license_id: str, *, keystore: Any)
bundle["client_key"] = keystore.decrypt(encrypted, aad=client_key_aad(license_id)) bundle["client_key"] = keystore.decrypt(encrypted, aad=client_key_aad(license_id))
except Exception as exc: # noqa: BLE001 except Exception as exc: # noqa: BLE001
raise IssuanceError( raise IssuanceError(
"Der Client-Schluessel dieser Lizenz konnte nicht entschluesselt werden. " "Der Client-Schlüssel dieser Lizenz konnte nicht entschlüsselt werden. "
"Bitte eine neue Lizenz ausstellen." "Bitte eine neue Lizenz ausstellen."
) from exc ) from exc
return bundle return bundle
@@ -580,17 +633,56 @@ def delete_license(conn: sqlite3.Connection, license_id: str) -> None:
raise IssuanceError("Diese Lizenz existiert nicht.") raise IssuanceError("Diese Lizenz existiert nicht.")
if str(row["status"]) not in {"revoked", "issued"}: if str(row["status"]) not in {"revoked", "issued"}:
raise IssuanceError( raise IssuanceError(
"Eine aktive Lizenz kann nicht geloescht werden. Bitte zuerst widerrufen." "Eine aktive Lizenz kann nicht gelöscht werden. Bitte zuerst widerrufen."
) )
conn.execute("DELETE FROM licenses WHERE license_id=?", (license_id,)) conn.execute("DELETE FROM licenses WHERE license_id=?", (license_id,))
def ticket_grenze(conn: sqlite3.Connection, license_id: str) -> str | None:
"""Bis wann darf diese Lizenz laufen? ``None`` heisst: unbegrenzt.
Das Ticket ist der Anspruch, die Lizenz die Urkunde daraus -- und eine
Urkunde darf nicht mehr zusagen als der Anspruch hergibt. Vorher liess
sich eine Lizenz aus einem 25-Tage-Ticket beliebig weit verlaengern, ohne
dass am Ticket etwas davon zu sehen war: die Vereinbarung stand dann an
einer Stelle und galt an einer anderen.
Gerechnet wird ab der **Ausstellung** der Lizenz, nicht ab heute: wer ein
Ticket ueber 90 Tage hat, bekommt 90 Tage ab Ausstellung, nicht 90 weitere.
``None`` bei unbefristetem Ticket -- und auch dann, wenn zur Lizenz gar
kein Ticket gehoert (Altbestand, Wurzelinstanz): wo es keinen Anspruch
gibt, gibt es nichts zu begrenzen.
"""
row = get_license(conn, license_id)
if row is None or not row["ticket_id"]:
return None
ticket = get_ticket(conn, str(row["ticket_id"]))
if ticket is None or bool(ticket["lifetime"]):
return None
# Eine Testlizenz lebt nach ihren eigenen Regeln und nicht nach dem Ticket.
if str(row["type"]) == "trial":
return None
# Die Spalte, nicht das Dokument. ``resign_license`` setzt im Dokument bei
# **jeder** Revision ein neues ``issued_at`` -- eine Grenze, die darauf
# rechnet, wandert also mit jeder Neusignatur nach vorn. Wer oft genug neu
# signiert, verlaengert damit den Anspruch, ohne das Ticket anzufassen:
# genau die Luecke, die diese Funktion schliessen soll. Live gemessen --
# Zeile 12.09. 17:45, Dokument nach zwei Revisionen 16.09. 20:20.
#
# Die Spalte ``issued_at`` bleibt bei der ersten Ausstellung stehen.
return lic.iso(
lic.parse_iso(str(row["issued_at"])) + timedelta(days=int(ticket["valid_days"]))
)
def resign_license( def resign_license(
conn: sqlite3.Connection, conn: sqlite3.Connection,
license_id: str, license_id: str,
*, *,
master_private_key: str, master_private_key: str,
changes: dict[str, Any], changes: dict[str, Any],
ueber_ticket: bool = False,
) -> dict[str, Any]: ) -> dict[str, Any]:
"""Signiert eine neue Revision -- z. B. nach einer Modulerweiterung. """Signiert eine neue Revision -- z. B. nach einer Modulerweiterung.
@@ -600,6 +692,18 @@ def resign_license(
row = get_license(conn, license_id) row = get_license(conn, license_id)
if row is None: if row is None:
raise IssuanceError("Diese Lizenz existiert nicht.") raise IssuanceError("Diese Lizenz existiert nicht.")
# Geprueft wird hier und nicht in der Route: es ist die eine Stelle, durch
# die jede Neusignatur laeuft -- auch die aus einer Ticketverlaengerung,
# die deshalb ausdruecklich vorbeidarf (``ueber_ticket``).
grenze = ticket_grenze(conn, license_id)
ziel = str(changes.get("expires_at") or "")
if grenze and ziel and not ueber_ticket and ziel > grenze:
raise IssuanceError(
"So lange deckt das Ticket diese Lizenz nicht: es reicht bis "
f"{lic.parse_iso(grenze).strftime('%d.%m.%Y')}. Verlängern Sie zuerst das "
"Ticket -- die Verlängerung wirkt dann auf diese Lizenz."
)
document = json.loads(str(row["license_document"])) document = json.loads(str(row["license_document"]))
updated = licsrv.resign_license( updated = licsrv.resign_license(
document, master_private_key_b64=master_private_key, changes=changes document, master_private_key_b64=master_private_key, changes=changes
@@ -675,6 +779,11 @@ def display_state(row: dict[str, Any], *, now: datetime | None = None) -> dict[s
Grenzen: mehr als 90 Tage = gruen, 30 bis 90 Tage = orange, unter 30 Tage Grenzen: mehr als 90 Tage = gruen, 30 bis 90 Tage = orange, unter 30 Tage
(auch abgelaufen) = rot. Unbefristete Lizenzen sind immer gruen. (auch abgelaufen) = rot. Unbefristete Lizenzen sind immer gruen.
Gezaehlt wird in **angefangenen** Tagen (``lic.tage_bis``) -- dieselbe
Rechnung, die auch der Client meldet. Die Vergleiche sind darauf
abgestimmt: 90 volle Tage und eine Stunde sind "mehr als 90" und damit
gruen, 29 Tage und eine Stunde sind "unter 30" und damit rot.
""" """
moment = now or datetime.now(timezone.utc) moment = now or datetime.now(timezone.utc)
result = { result = {
@@ -698,11 +807,18 @@ def display_state(row: dict[str, Any], *, now: datetime | None = None) -> dict[s
return {**result, "color": "red"} return {**result, "color": "red"}
delta = expires - moment delta = expires - moment
days_left = int(delta.total_seconds() // 86400) # Dieselbe Rechnung wie im Client -- nicht eine zweite daneben. Hier stand
# eine abgerundete, und damit zeigte dieselbe Lizenz auf dieser Seite
# "21 Tage", waehrend der Abruf beim Kunden "22" meldete.
days_left = lic.tage_bis(expires, now=moment)
expired = delta.total_seconds() <= 0 expired = delta.total_seconds() <= 0
if expired: if expired:
color = "red" color = "red"
elif days_left < RED_BELOW_DAYS: elif days_left <= RED_BELOW_DAYS:
# "<=", weil ``days_left`` **angefangene** Tage zaehlt: eine Lizenz mit
# 29 Tagen und einer Stunde steht als 30 da und liegt trotzdem unter
# der Schwelle von 30 vollen Tagen. Mit "<" waere sie orange -- die
# Warnung kaeme einen Tag zu spaet, und zwar genau am kritischen Rand.
color = "red" color = "red"
elif days_left <= GREEN_ABOVE_DAYS: elif days_left <= GREEN_ABOVE_DAYS:
color = "orange" color = "orange"
@@ -717,6 +833,189 @@ def display_state(row: dict[str, Any], *, now: datetime | None = None) -> dict[s
} }
def ticket_verlaengern(conn: sqlite3.Connection, ticket_id: str, tage: int) -> int:
"""Schlaegt Tage auf die Laufzeit des Tickets auf. Rueckgabe: die neue Zahl.
Dasselbe Bedienmuster wie bei der Lizenz ("+ X Tage"), und aus demselben
Grund: wer verlaengert, rechnet in Tagen ab heute und nicht in einer neuen
Gesamtzahl, die er sich erst aus dem alten Wert zusammensuchen muss.
"""
ticket = get_ticket(conn, ticket_id)
if ticket is None:
raise IssuanceError("Dieses Ticket existiert nicht.")
if bool(ticket["lifetime"]):
raise IssuanceError("Dieses Ticket ist unbefristet -- da gibt es nichts zu verlängern.")
if int(tage) <= 0:
raise IssuanceError("Die Verlängerung muss eine Zahl größer als null sein.")
neu = int(ticket["valid_days"]) + int(tage)
conn.execute(
"UPDATE license_tickets SET valid_days=?, updated_at=? WHERE id=?",
(neu, _now(), ticket_id),
)
return neu
def lizenz_dem_ticket_nachziehen(
conn: sqlite3.Connection, ticket_id: str, *, master_private_key: str
) -> dict[str, Any] | None:
"""Zieht die laufende Lizenz eines Tickets auf dessen neue Laufzeit nach.
Gibt die neue Revision zurueck, oder ``None``, wenn es nichts zu tun gab.
**Nur verlaengern, nie verkuerzen.** Eine ausgestellte und signierte
Urkunde nachtraeglich zu beschneiden waere eine Zusage, die man einseitig
zurueckzieht -- und der Kunde erfuehre es erst, wenn seine Installation
stehenbleibt. Wird ein Ticket verkuerzt, gilt das fuer die naechste
Ausstellung.
"""
offene = Database.one(
conn,
"SELECT license_id FROM licenses WHERE ticket_id=? AND status IN ('issued','active') "
"AND type <> 'trial' LIMIT 1",
(ticket_id,),
)
if offene is None:
return None
license_id = str(offene["license_id"])
grenze = ticket_grenze(conn, license_id)
if grenze is None:
# Das Ticket ist unbefristet geworden -- dann auch die Lizenz.
row = get_license(conn, license_id)
if row is None or bool(row["lifetime"]):
return None
ticket = get_ticket(conn, ticket_id)
if ticket is None or not bool(ticket["lifetime"]):
return None
return resign_license(
conn,
license_id,
master_private_key=master_private_key,
changes={"lifetime": True},
ueber_ticket=True,
)
row = get_license(conn, license_id)
if row is None or bool(row["lifetime"]) or str(row["expires_at"]) >= grenze:
return None
return resign_license(
conn,
license_id,
master_private_key=master_private_key,
changes={"expires_at": grenze},
ueber_ticket=True,
)
def ticket_abweichung(conn: sqlite3.Connection, license_id: str) -> dict[str, Any] | None:
"""Weicht die Laufzeit dieser Lizenz von ihrem Ticket ab? Sonst ``None``.
Vier Faelle, und sie sind nicht symmetrisch:
=================== =================== ====================================
Ticket Lizenz Abgleich
=================== =================== ====================================
unbefristet unbefristet nichts zu tun
unbefristet befristet wird unbefristet -- ein Gewinn
befristet unbefristet wird befristet -- eine **Ruecknahme**
befristet befristet auf den Tag des Tickets
=================== =================== ====================================
``verkuerzt`` sagt, ob der Abgleich etwas wegnimmt. Die Oberflaeche faerbt
danach ihre Rueckfrage, und das Aenderungsprotokoll seine Stufe: eine
unbefristete Zusage zu befristen ist die schaerfste Form davon.
Testlizenzen bleiben aussen vor -- sie leben nach eigenen Regeln.
"""
row = get_license(conn, license_id)
if row is None or not row["ticket_id"] or str(row["type"]) == "trial":
return None
ticket = get_ticket(conn, str(row["ticket_id"]))
if ticket is None:
return None
ticket_unbefristet = bool(ticket["lifetime"])
lizenz_unbefristet = bool(row["lifetime"])
if ticket_unbefristet and lizenz_unbefristet:
return None
if ticket_unbefristet:
return {"grenze": None, "unbefristet": True, "verkuerzt": False}
grenze = ticket_grenze(conn, license_id)
if grenze is None:
return None
if not lizenz_unbefristet and str(row["expires_at"]) == grenze:
return None
return {
"grenze": grenze,
"unbefristet": False,
# Unbefristet zu befristen nimmt immer etwas weg; sonst entscheidet
# das Datum.
"verkuerzt": lizenz_unbefristet or grenze < str(row["expires_at"]),
}
def lizenz_auf_ticket_setzen(
conn: sqlite3.Connection, license_id: str, *, master_private_key: str
) -> dict[str, Any] | None:
"""Setzt die Laufzeit einer Lizenz auf das, was ihr Ticket hergibt.
In beide Richtungen -- das ist der Unterschied zum Nachziehen bei einer
Ticketverlaengerung, das nur verlaengert. Hier greift jemand ausdruecklich
zu, um Anspruch und Urkunde wieder zur Deckung zu bringen: eine Lizenz, die
laenger laeuft als ihr Ticket, ist eine Zusage ohne Grundlage.
**Das verkuerzt eine bereits ausgestellte Zusage.** Der Client uebernimmt
die neue Revision beim naechsten Heartbeat, und danach laeuft die
Installation frueher ab. Deshalb passiert es nur auf ausdruecklichen
Zugriff und nie nebenbei.
Rueckgabe: die neue Revision, oder ``None``, wenn nichts zu tun war.
"""
row = get_license(conn, license_id)
if row is None:
raise IssuanceError("Diese Lizenz existiert nicht.")
abweichung = ticket_abweichung(conn, license_id)
if abweichung is None:
return None
if abweichung["unbefristet"]:
# Das Ticket gewaehrt unbefristet, die Lizenz laeuft ab. Der Abgleich
# gibt etwas -- hier ist nichts zurueckzunehmen.
changes: dict[str, Any] = {"lifetime": True}
else:
# Ein Abgleich, der in die Vergangenheit zeigt, ist kein Abgleich: er
# laesst die Lizenz beim naechsten Heartbeat sofort ablaufen. Das mag
# gewollt sein -- dann heisst es Widerruf und hat einen eigenen Knopf,
# eine eigene Rueckfrage und einen eigenen Eintrag. Hier wird es
# abgewiesen, denn wer "auf die Ticketlaufzeit setzen" drueckt, will
# zwei Angaben zur Deckung bringen und nicht eine Installation
# stilllegen.
# Verglichen wird auf Zeitpunkten, nicht auf Zeichenketten: die
# Lizenzdaten enden auf "Z", ``_now()`` schreibt "+00:00" -- als Text
# sortiert sich das falsch herum.
if lic.parse_iso(str(abweichung["grenze"])) <= datetime.now(timezone.utc):
raise IssuanceError(
"Die Ticketlaufzeit ist bereits abgelaufen "
f"({lic.parse_iso(str(abweichung['grenze'])).strftime('%d.%m.%Y')}) -- ein "
"Abgleich würde diese Lizenz sofort ungültig machen. Verlängern Sie zuerst "
"das Ticket, oder widerrufen Sie die Lizenz bewusst."
)
# Auch aus einer unbefristeten Lizenz wird hier wieder eine befristete.
# Das ist die schaerfste Ruecknahme, die dieses System kennt, und
# genau deshalb hat sie einen eigenen Knopf mit eigener Rueckfrage --
# statt unmoeglich zu sein. Unmoeglich hiesse: eine unbefristete
# Zusage, die versehentlich entstanden ist, bleibt fuer immer stehen.
changes = {"lifetime": False, "expires_at": abweichung["grenze"]}
return resign_license(
conn,
license_id,
master_private_key=master_private_key,
changes=changes,
ueber_ticket=True,
)
def dashboard_summary(conn: sqlite3.Connection) -> dict[str, Any]: def dashboard_summary(conn: sqlite3.Connection) -> dict[str, Any]:
rows = list_licenses(conn) rows = list_licenses(conn)
buckets: dict[str, list[dict[str, Any]]] = {"red": [], "orange": [], "green": []} buckets: dict[str, list[dict[str, Any]]] = {"red": [], "orange": [], "green": []}
@@ -734,7 +1033,21 @@ def dashboard_summary(conn: sqlite3.Connection) -> dict[str, Any]:
), ),
"tickets_open": int( "tickets_open": int(
Database.value( Database.value(
conn, "SELECT COUNT(*) FROM license_tickets WHERE status='open'", default=0 conn,
"SELECT COUNT(*) FROM license_tickets t WHERE t.status='open' "
f"AND {OHNE_GUELTIGE_LIZENZ}",
default=0,
)
or 0
),
# Zur Einordnung unter der Kachel: was schon erledigt ist, soll man
# sehen, ohne die Liste zu oeffnen.
"tickets_redeemed": int(
Database.value(
conn,
"SELECT COUNT(*) FROM license_tickets t WHERE t.status='open' "
f"AND NOT {OHNE_GUELTIGE_LIZENZ}",
default=0,
) )
or 0 or 0
), ),
@@ -61,7 +61,7 @@ class Report:
def as_text(self) -> str: def as_text(self) -> str:
lines = [ lines = [
f"Signaturschluessel uebernommen: {'ja' if self.key_adopted else 'nein'}" f"Signaturschlüssel übernommen: {'ja' if self.key_adopted else 'nein'}"
+ (f" ({self.key_id})" if self.key_id else ""), + (f" ({self.key_id})" if self.key_id else ""),
f"Kunden : {self.customers}", f"Kunden : {self.customers}",
f"Tickets : {self.tickets}", f"Tickets : {self.tickets}",
@@ -69,7 +69,7 @@ class Report:
] ]
if self.reissued: if self.reissued:
lines.append("") lines.append("")
lines.append("Neu signiert -- diese Kunden brauchen eine neue Lizenzdatei:") lines.append("Neu signiert – diese Kunden brauchen eine neue Lizenzdatei:")
lines.extend(f" * {entry}" for entry in self.reissued) lines.extend(f" * {entry}" for entry in self.reissued)
if self.skipped: if self.skipped:
lines.append("") lines.append("")
@@ -95,7 +95,7 @@ def open_legacy(root: Path) -> sqlite3.Connection:
"""Oeffnet die alte Datenbank **nur lesend**.""" """Oeffnet die alte Datenbank **nur lesend**."""
path = Path(root) / "sqlite.db" path = Path(root) / "sqlite.db"
if not path.is_file(): if not path.is_file():
raise LegacyImportError(f"Unter {path} liegt keine Datenbank der Vorgaengerfassung.") raise LegacyImportError(f"Unter {path} liegt keine Datenbank der Vorgängerfassung.")
try: try:
conn = sqlite3.connect(f"file:{path}?mode=ro", uri=True) conn = sqlite3.connect(f"file:{path}?mode=ro", uri=True)
except sqlite3.Error as exc: except sqlite3.Error as exc:
@@ -107,7 +107,7 @@ def open_legacy(root: Path) -> sqlite3.Connection:
if missing: if missing:
conn.close() conn.close()
raise LegacyImportError( raise LegacyImportError(
f"Das sieht nicht nach einem Lizenzserver aus -- es fehlen: {', '.join(sorted(missing))}" f"Das sieht nicht nach einem Lizenzserver aus – es fehlen: {', '.join(sorted(missing))}"
) )
return conn return conn
@@ -123,7 +123,7 @@ def _standard_b64(wert: str, feld: str) -> str:
""" """
roh = wert.strip() roh = wert.strip()
if not roh: if not roh:
raise LegacyImportError(f"Im alten Signaturschluessel fehlt {feld!r}.") raise LegacyImportError(f"Im alten Signaturschlüssel fehlt {feld!r}.")
# Fehlende Polsterung ergaenzen -- der Vorgaenger schrieb sie mit, andere # Fehlende Polsterung ergaenzen -- der Vorgaenger schrieb sie mit, andere
# Werkzeuge lassen sie weg. # Werkzeuge lassen sie weg.
gepolstert = roh + "=" * (-len(roh) % 4) gepolstert = roh + "=" * (-len(roh) % 4)
@@ -138,29 +138,29 @@ def _standard_b64(wert: str, feld: str) -> str:
continue continue
if len(rohbytes) != 32: if len(rohbytes) != 32:
raise LegacyImportError( raise LegacyImportError(
f"{feld} im alten Signaturschluessel ist {len(rohbytes)} Byte lang, " f"{feld} im alten Signaturschlüssel ist {len(rohbytes)} Byte lang, "
"erwartet werden 32 (Ed25519)." "erwartet werden 32 (Ed25519)."
) )
return base64.b64encode(rohbytes).decode("ascii") return base64.b64encode(rohbytes).decode("ascii")
raise LegacyImportError(f"{feld} im alten Signaturschluessel ist kein Base64.") raise LegacyImportError(f"{feld} im alten Signaturschlüssel ist kein Base64.")
def read_legacy_key(root: Path) -> tuple[str, str]: def read_legacy_key(root: Path) -> tuple[str, str]:
"""Liest den alten Signaturschluessel. Rueckgabe: ``(privat, oeffentlich)``.""" """Liest den alten Signaturschluessel. Rueckgabe: ``(privat, oeffentlich)``."""
path = Path(root) / "master_signing_key.json" path = Path(root) / "master_signing_key.json"
if not path.is_file(): if not path.is_file():
raise LegacyImportError(f"Der alte Signaturschluessel {path} fehlt.") raise LegacyImportError(f"Der alte Signaturschlüssel {path} fehlt.")
try: try:
data = json.loads(path.read_text(encoding="utf-8")) data = json.loads(path.read_text(encoding="utf-8"))
except (OSError, ValueError) as exc: except (OSError, ValueError) as exc:
raise LegacyImportError(f"Der alte Signaturschluessel ist unlesbar: {exc}") from exc raise LegacyImportError(f"Der alte Signaturschlüssel ist unlesbar: {exc}") from exc
# Die Vorgaengerfassung nannte die Felder "private"/"public" und kodierte # Die Vorgaengerfassung nannte die Felder "private"/"public" und kodierte
# sie URL-sicher. # sie URL-sicher.
private = str(data.get("private") or data.get("private_key") or "") private = str(data.get("private") or data.get("private_key") or "")
public = str(data.get("public") or data.get("public_key") or "") public = str(data.get("public") or data.get("public_key") or "")
if not private or not public: if not private or not public:
raise LegacyImportError("Der alte Signaturschluessel ist unvollstaendig.") raise LegacyImportError("Der alte Signaturschlüssel ist unvollständig.")
private = _standard_b64(private, "private") private = _standard_b64(private, "private")
public = _standard_b64(public, "public") public = _standard_b64(public, "public")
# Alles, was aus der Protokollschicht kommt, wird hier zu einer Meldung im # Alles, was aus der Protokollschicht kommt, wird hier zu einer Meldung im
@@ -170,11 +170,11 @@ def read_legacy_key(root: Path) -> tuple[str, str]:
try: try:
passt = licsrv.public_from_private(private) == public passt = licsrv.public_from_private(private) == public
except (ValueError, TypeError) as exc: except (ValueError, TypeError) as exc:
raise LegacyImportError(f"Der alte Signaturschluessel ist unbrauchbar: {exc}") from exc raise LegacyImportError(f"Der alte Signaturschlüssel ist unbrauchbar: {exc}") from exc
if not passt: if not passt:
raise LegacyImportError( raise LegacyImportError(
"Im alten Signaturschluessel passen privater und oeffentlicher Teil nicht " "Im alten Signaturschlüssel passen privater und öffentlicher Teil nicht "
"zueinander. Die Datei ist beschaedigt." "zueinander. Die Datei ist beschädigt."
) )
return private, public return private, public
@@ -190,16 +190,16 @@ def adopt_key(private: str, public: str, target: Path) -> str:
if Path(target).is_file(): if Path(target).is_file():
raise LegacyImportError( raise LegacyImportError(
f"Unter {target} liegt bereits ein Signaturschluessel. Er wird nicht " f"Unter {target} liegt bereits ein Signaturschlüssel. Er wird nicht "
"ueberschrieben -- das wuerde jede damit ausgestellte Lizenz unbrauchbar " "überschrieben – das würde jede damit ausgestellte Lizenz unbrauchbar "
"machen. Fuer eine Uebernahme bitte eine frische Installation verwenden." "machen. Für eine Übernahme bitte eine frische Installation verwenden."
) )
payload = { payload = {
"private_key": private, "private_key": private,
"public_key": public, "public_key": public,
"key_id": lic.key_id(public), "key_id": lic.key_id(public),
"created_at": _now(), "created_at": _now(),
"origin": "Uebernommen aus der Vorgaengerfassung.", "origin": "Übernommen aus der Vorgängerfassung.",
} }
write_private_file(Path(target), json.dumps(payload, indent=2, sort_keys=True) + "\n") write_private_file(Path(target), json.dumps(payload, indent=2, sort_keys=True) + "\n")
return lic.key_id(public) return lic.key_id(public)
@@ -309,7 +309,7 @@ def import_all(
) )
if existing is not None: if existing is not None:
customer_map[int(row["id"])] = int(existing["id"]) customer_map[int(row["id"])] = int(existing["id"])
report.warnings.append(f"Kunde {name!r} war schon vorhanden -- nicht doppelt angelegt.") report.warnings.append(f"Kunde {name!r} war schon vorhanden – nicht doppelt angelegt.")
continue continue
new_id = issuance.create_customer( new_id = issuance.create_customer(
conn, conn,
@@ -331,7 +331,7 @@ def import_all(
for row in legacy.execute("SELECT * FROM license_tickets ORDER BY created_at"): for row in legacy.execute("SELECT * FROM license_tickets ORDER BY created_at"):
customer_id = customer_map.get(int(row["customer_id"] or 0)) customer_id = customer_map.get(int(row["customer_id"] or 0))
if customer_id is None: if customer_id is None:
report.skipped.append(f"Ticket {str(row['id'])[:12]} -- Kunde nicht uebernommen") report.skipped.append(f"Ticket {str(row['id'])[:12]} -- Kunde nicht übernommen")
continue continue
if Database.one(conn, "SELECT id FROM license_tickets WHERE id=?", (row["id"],)): if Database.one(conn, "SELECT id FROM license_tickets WHERE id=?", (row["id"],)):
continue continue
@@ -348,7 +348,7 @@ def import_all(
int(row["lifetime"] or 0), int(row["lifetime"] or 0),
str(row["status"] or "open"), str(row["status"] or "open"),
str(row["role"] or "customer"), str(row["role"] or "customer"),
"Aus der Vorgaengerfassung uebernommen.", "Aus der Vorgängerfassung übernommen.",
str(row["created_by"] or actor), str(row["created_by"] or actor),
str(row["created_at"] or _now()), str(row["created_at"] or _now()),
_now(), _now(),
@@ -361,7 +361,7 @@ def import_all(
license_id = str(row["license_id"] or "") license_id = str(row["license_id"] or "")
customer_id = customer_map.get(int(row["customer_id"] or 0)) customer_id = customer_map.get(int(row["customer_id"] or 0))
if not license_id or customer_id is None: if not license_id or customer_id is None:
report.skipped.append(f"Lizenz {license_id[:12] or '?'} -- Kunde nicht uebernommen") report.skipped.append(f"Lizenz {license_id[:12] or '?'} -- Kunde nicht übernommen")
continue continue
if Database.one(conn, "SELECT id FROM licenses WHERE license_id=?", (license_id,)): if Database.one(conn, "SELECT id FROM licenses WHERE license_id=?", (license_id,)):
continue continue
@@ -371,7 +371,7 @@ def import_all(
modules = _modules(row["modules"]) modules = _modules(row["modules"])
if not reissue: if not reissue:
report.skipped.append(f"{customer['name']} / {license_id[:12]} -- nur gezaehlt") report.skipped.append(f"{customer['name']} / {license_id[:12]} -- nur gezählt")
report.licenses += 1 report.licenses += 1
continue continue
@@ -383,7 +383,7 @@ def import_all(
if not lifetime and valid_days <= 0: if not lifetime and valid_days <= 0:
report.skipped.append( report.skipped.append(
f"{customer['name']} / {license_id[:12]} -- abgelaufen am " f"{customer['name']} / {license_id[:12]} -- abgelaufen am "
f"{str(row['expires_at'])[:10]}, nicht uebernommen" f"{str(row['expires_at'])[:10]}, nicht übernommen"
) )
continue continue
@@ -444,7 +444,7 @@ def import_all(
if report.reissued: if report.reissued:
report.warnings.append( report.warnings.append(
"Die neu signierten Lizenzen sind noch nicht aktiviert: die Bindung an eine " "Die neu signierten Lizenzen sind noch nicht aktiviert: die Bindung an eine "
"Maschine gilt nur fuer die alte Datei. Nach dem Einspielen aktiviert der " "Maschine gilt nur für die alte Datei. Nach dem Einspielen aktiviert der "
"Kunde erneut." "Kunde erneut."
) )
return report return report
@@ -92,7 +92,7 @@ def _secret(conn: sqlite3.Connection) -> str:
return core().keystore.decrypt(str(value), aad=AAD_GRAPH_SECRET) return core().keystore.decrypt(str(value), aad=AAD_GRAPH_SECRET)
except Exception as exc: # noqa: BLE001 except Exception as exc: # noqa: BLE001
raise MailError( raise MailError(
"Das Client-Secret konnte nicht entschluesselt werden -- bitte neu eintragen." "Das Client-Secret konnte nicht entschlüsselt werden – bitte neu eintragen."
) from exc ) from exc
@@ -162,7 +162,7 @@ def fetch_token(conn: sqlite3.Connection) -> str:
def test_connection(conn: sqlite3.Connection) -> dict[str, Any]: def test_connection(conn: sqlite3.Connection) -> dict[str, Any]:
"""Holt nur ein Token -- verschickt bewusst keine echte Nachricht.""" """Holt nur ein Token -- verschickt bewusst keine echte Nachricht."""
if not is_enabled(conn): if not is_enabled(conn):
return {"ok": False, "message": "Der E-Mail-Versand ist nicht vollstaendig konfiguriert."} return {"ok": False, "message": "Der E-Mail-Versand ist nicht vollständig konfiguriert."}
try: try:
fetch_token(conn) fetch_token(conn)
except MailError as exc: except MailError as exc:
@@ -48,22 +48,22 @@ class MasterKey:
def load(path: Path) -> MasterKey: def load(path: Path) -> MasterKey:
if not path.is_file(): if not path.is_file():
raise MasterKeyError( raise MasterKeyError(
f"Der Signaturschluessel {path} fehlt. Ohne ihn kann keine Lizenz ausgestellt " f"Der Signaturschlüssel {path} fehlt. Ohne ihn kann keine Lizenz ausgestellt "
"werden. Erzeugen Sie ihn mit 'tesm-license-admin init-key'." "werden. Erzeugen Sie ihn mit 'tesm-license-admin init-key'."
) )
try: try:
data = json.loads(path.read_text(encoding="utf-8")) data = json.loads(path.read_text(encoding="utf-8"))
except (OSError, ValueError) as exc: except (OSError, ValueError) as exc:
raise MasterKeyError(f"Der Signaturschluessel ist unlesbar: {exc}") from exc raise MasterKeyError(f"Der Signaturschlüssel ist unlesbar: {exc}") from exc
private = str(data.get("private_key") or "") private = str(data.get("private_key") or "")
public = str(data.get("public_key") or "") public = str(data.get("public_key") or "")
if not private or not public: if not private or not public:
raise MasterKeyError("Der Signaturschluessel ist unvollstaendig.") raise MasterKeyError("Der Signaturschlüssel ist unvollständig.")
if licsrv.public_from_private(private) != public: if licsrv.public_from_private(private) != public:
raise MasterKeyError( raise MasterKeyError(
"Der oeffentliche Teil des Signaturschluessels passt nicht zum privaten. " "Der öffentliche Teil des Signaturschlüssels passt nicht zum privaten. "
"Die Datei ist beschaedigt oder wurde manipuliert." "Die Datei ist beschädigt oder wurde manipuliert."
) )
return MasterKey( return MasterKey(
private_key=private, public_key=public, created_at=str(data.get("created_at") or "") private_key=private, public_key=public, created_at=str(data.get("created_at") or "")
@@ -87,7 +87,7 @@ def ensure(path: Path) -> MasterKey:
} }
write_private_file(path, json.dumps(payload, indent=2, sort_keys=True) + "\n") write_private_file(path, json.dumps(payload, indent=2, sort_keys=True) + "\n")
logger.warning( logger.warning(
"Neuer Signaturschluessel erzeugt (%s). Diese Datei ist unwiederbringlich -- " "Neuer Signaturschlüssel erzeugt (%s). Diese Datei ist unwiederbringlich – "
"bitte sofort gesichert ablegen: %s", "bitte sofort gesichert ablegen: %s",
lic.key_id(public), lic.key_id(public),
path, path,
@@ -98,7 +98,7 @@ def ensure(path: Path) -> MasterKey:
def health(path: Path) -> dict[str, object]: def health(path: Path) -> dict[str, object]:
"""Zustand fuer die Diagnoseseite.""" """Zustand fuer die Diagnoseseite."""
if not path.is_file(): if not path.is_file():
return {"present": False, "problems": ["Signaturschluessel fehlt"]} return {"present": False, "problems": ["Signaturschlüssel fehlt"]}
problems = check_private_file(path) problems = check_private_file(path)
try: try:
key = load(path) key = load(path)
@@ -40,6 +40,16 @@ class LiveLog:
def info(self, scope: str, message: str) -> None: def info(self, scope: str, message: str) -> None:
self.write("INFO", scope, message) self.write("INFO", scope, message)
def notice(self, scope: str, message: str) -> None:
"""Etwas ist passiert, und es ist in Ordnung -- aber es gehoert gesehen.
Zwischen "laeuft" und "stimmt etwas nicht" fehlte eine Stufe. Ein
Geraet, das neu gestartet wird, ist kein Fehler; es einfach als INFO
zwischen zweihundert Erreichbarkeitsmeldungen zu stellen heisst aber,
dass es niemand findet.
"""
self.write("HINWEIS", scope, message)
def warning(self, scope: str, message: str) -> None: def warning(self, scope: str, message: str) -> None:
self.write("WARNUNG", scope, message) self.write("WARNUNG", scope, message)
@@ -24,6 +24,7 @@ import sqlite3
from typing import Any, Callable, Iterable from typing import Any, Callable, Iterable
from tesm_core.db import Database from tesm_core.db import Database
from tesm_core.rbac.legacy_permissions import umgeschrieben
from tesm_core.transfer import ( from tesm_core.transfer import (
MIN_PASSPHRASE_LENGTH, MIN_PASSPHRASE_LENGTH,
PREVIEW_TTL_MINUTES, PREVIEW_TTL_MINUTES,
@@ -41,11 +42,11 @@ APP_TAG = "tesm-license"
CATEGORIES: tuple[Category, ...] = ( CATEGORIES: tuple[Category, ...] = (
Category("customers", "Kunden", "Stammdaten der Kundeninstanzen"), Category("customers", "Kunden", "Stammdaten der Kundeninstanzen"),
Category("tickets", "Tickets", "Anspruechte inklusive Self-Service-Schluessel", sensitive=True), Category("tickets", "Tickets", "Ansprüche inklusive Self-Service-Schlüssel", sensitive=True),
Category( Category(
"licenses", "licenses",
"Lizenzen", "Lizenzen",
"Ausgestellte Lizenzen mit signiertem Dokument und verschluesseltem Client-Schluessel", "Ausgestellte Lizenzen mit signiertem Dokument und verschlüsseltem Client-Schlüssel",
sensitive=True, sensitive=True,
), ),
Category("users", "Benutzer", "Lokale Konten mit Passwort-Hash", admin_only=True), Category("users", "Benutzer", "Lokale Konten mit Passwort-Hash", admin_only=True),
@@ -153,6 +154,10 @@ def apply_import(
zusammengefuehrt; Tickets und Lizenzen ueber ihre ID, weil die zugleich zusammengefuehrt; Tickets und Lizenzen ueber ihre ID, weil die zugleich
kryptographisch relevant ist (Self-Service-Link bzw. signierte Lizenz-ID). kryptographisch relevant ist (Self-Service-Link bzw. signierte Lizenz-ID).
""" """
#: Merkt, ob das Paket alte Rechteschluessel mitbringt -- dann stammt es
#: von vor 2.8.0, und ein Administrator konnte dort noch alles.
alte_fassung: set[bool] = set()
wanted = {key for key in categories if key in CATEGORY_BY_KEY} wanted = {key for key in categories if key in CATEGORY_BY_KEY}
report: dict[str, dict[str, int]] = {} report: dict[str, dict[str, int]] = {}
stamp = _stamp() stamp = _stamp()
@@ -332,9 +337,16 @@ def apply_import(
group_id = int(existing["id"]) group_id = int(existing["id"])
bump("groups", "updated") bump("groups", "updated")
conn.execute("DELETE FROM group_permissions WHERE group_id=?", (group_id,)) conn.execute("DELETE FROM group_permissions WHERE group_id=?", (group_id,))
# Wie in TESM: eine Sicherung aus 2.7.x nennt customers.view und
# tickets.edit. Unbekannte Schluessel wirft sanitize() still weg --
# die wiederhergestellte Gruppe waere leer, ohne Meldung.
mitgebracht = [str(recht) for recht in entry.get("permissions", []) or []]
neue = umgeschrieben(mitgebracht)
if sorted(set(mitgebracht)) != neue:
alte_fassung.add(True)
conn.executemany( conn.executemany(
"INSERT OR IGNORE INTO group_permissions (group_id, permission) VALUES (?,?)", "INSERT OR IGNORE INTO group_permissions (group_id, permission) VALUES (?,?)",
[(group_id, str(permission)) for permission in entry.get("permissions", [])], [(group_id, permission) for permission in neue],
) )
if "users" in wanted: if "users" in wanted:
@@ -393,6 +405,18 @@ def apply_import(
set_setting(str(key), value) set_setting(str(key), value)
bump("settings", "updated") bump("settings", "updated")
if alte_fassung and "users" in wanted:
# Wie die Migration: aus einer Sicherung von vor 2.8.0 kommen
# Administratoren, die alles konnten. Ohne die Gruppe koennten sie
# nach dem Einlesen keine Lizenz mehr ausstellen.
from tesm_core.rbac import service as rbac
from ..permissions import FUNKTIONEN_VOLLZUGRIFF, PERMISSIONS
rbac.funktionsgruppe_fuer_administratoren(
conn, PERMISSIONS, FUNKTIONEN_VOLLZUGRIFF
)
return report return report
@@ -25,8 +25,8 @@ VENDOR_SETTINGS: tuple[SettingSpec, ...] = (
"master_endpoint", "master_endpoint",
"", "",
"str", "str",
"Oeffentlicher Endpunkt dieses Lizenzservers", "Öffentlicher Endpunkt dieses Lizenzservers",
hint="Basis-URL, unter der Kundeninstanzen diesen Server erreichen -- z. B. " hint="Basis-URL, unter der Kundeninstanzen diesen Server erreichen – z. B. "
"https://lizenz.firma.de. Wird in jede Lizenz eingebettet und fuer alle " "https://lizenz.firma.de. Wird in jede Lizenz eingebettet und fuer alle "
"kundengerichteten Links verwendet.", "kundengerichteten Links verwendet.",
section="vendor", section="vendor",
@@ -38,8 +38,8 @@ PORTAL_SETTINGS: tuple[SettingSpec, ...] = (
"portal_link_minutes", "portal_link_minutes",
30, 30,
"int", "int",
"Gueltigkeit des Zugangslinks (Minuten)", "Gültigkeit des Zugangslinks (Minuten)",
hint="Der Link ist zusaetzlich nur einmal verwendbar.", hint="Der Link ist zusätzlich nur einmal verwendbar.",
section="portal", section="portal",
minimum=5, minimum=5,
maximum=1440, maximum=1440,
@@ -48,9 +48,9 @@ PORTAL_SETTINGS: tuple[SettingSpec, ...] = (
"portal_session_minutes", "portal_session_minutes",
60, 60,
"int", "int",
"Gueltigkeit der Portalsitzung (Minuten)", "Gültigkeit der Portalsitzung (Minuten)",
hint="Zweites, unabhaengiges Zeitfenster: eines fuer das Anklicken des Links, " hint="Zweites, unabhängiges Zeitfenster: eines für das Anklicken des Links, "
"eines fuer die daraus entstehende Sitzung.", "eines für die daraus entstehende Sitzung.",
section="portal", section="portal",
minimum=5, minimum=5,
maximum=1440, maximum=1440,
@@ -64,7 +64,7 @@ PORTAL_SETTINGS: tuple[SettingSpec, ...] = (
"bool", "bool",
"Self-Service-Link je Ticket aktiv", "Self-Service-Link je Ticket aktiv",
hint="Erlaubt dem Kunden, seine Lizenz selbst herunterzuladen, zu widerrufen und " hint="Erlaubt dem Kunden, seine Lizenz selbst herunterzuladen, zu widerrufen und "
"neu auszustellen -- ohne Anmeldung, allein ueber den unratbaren Ticket-Link.", "neu auszustellen – ohne Anmeldung, allein über den unratbaren Ticket-Link.",
section="portal", section="portal",
), ),
) )
@@ -83,8 +83,8 @@ DEFAULT_TICKET_SETTINGS: tuple[SettingSpec, ...] = (
"allow_customer_revoke", "allow_customer_revoke",
True, True,
"bool", "bool",
"Kunden duerfen ihre Lizenz selbst widerrufen", "Kunden dürfen ihre Lizenz selbst widerrufen",
hint="Notwendig fuer einen Systemwechsel ohne Support-Anruf.", hint="Notwendig für einen Systemwechsel ohne Support-Anruf.",
section="licensing", section="licensing",
), ),
) )
@@ -14,7 +14,7 @@
{% block topbar_extra %} {% block topbar_extra %}
{% if current_user.has('tickets.create') %} {% if current_user.has('tickets.create') %}
<a class="btn btn--primary btn--sm" href="{{ url_for('tickets.new', kunde=customer.id) }}"> <a class="btn btn--primary btn--sm" href="{{ url_for('tickets.new', customer=customer.id) }}">
{{ icon('plus', size=15) }} Ticket anlegen {{ icon('plus', size=15) }} Ticket anlegen
</a> </a>
{% endif %} {% endif %}
@@ -122,14 +122,13 @@
informativ und werden bei jedem Bericht vollständig ersetzt.', 'info') }} informativ und werden bei jedem Bericht vollständig ersetzt.', 'info') }}
{% for entry in reported.customers %} {% for entry in reported.customers %}
<details class="card"> <details class="card">
<summary class="card__header" style="cursor:pointer"> <summary class="card__header">
<div class="flex-1"> <div class="flex-1">
<div class="card__title">{{ entry.name }}</div> <div class="card__title">{{ entry.name }}</div>
<div class="card__sub"> <div class="card__sub">
{{ entry.city or 'ohne Ort' }} &middot; {{ entry.tickets | length }} Ticket(s) {{ entry.city or 'ohne Ort' }} &middot; {{ entry.tickets | length }} Ticket(s)
</div> </div>
</div> </div>
{{ icon('chevron-down', size=15) }}
</summary> </summary>
<div class="card__body"> <div class="card__body">
{% for ticket in entry.tickets %} {% for ticket in entry.tickets %}
@@ -171,22 +170,22 @@
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}"> <input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
<div class="card__body stack"> <div class="card__body stack">
{{ field('name', 'Firmenname', value=customer.name, required=True, {{ field('name', 'Firmenname', value=customer.name, required=True,
disabled=not current_user.has('customers.edit')) }} disabled=not current_user.has('customers.write')) }}
{{ field('contact_email', 'E-Mail', value=customer.contact_email, type='email', {{ field('contact_email', 'E-Mail', value=customer.contact_email, type='email',
disabled=not current_user.has('customers.edit')) }} disabled=not current_user.has('customers.write')) }}
{{ field('contact_person', 'Ansprechpartner', value=customer.contact_person, {{ field('contact_person', 'Ansprechpartner', value=customer.contact_person,
disabled=not current_user.has('customers.edit')) }} disabled=not current_user.has('customers.write')) }}
{{ field('contact_phone', 'Telefon', value=customer.contact_phone, {{ field('contact_phone', 'Telefon', value=customer.contact_phone,
disabled=not current_user.has('customers.edit')) }} disabled=not current_user.has('customers.write')) }}
<div class="form-grid"> <div class="form-grid">
{{ field('street', 'Strasse', value=customer.street, {{ field('street', 'Strasse', value=customer.street,
disabled=not current_user.has('customers.edit')) }} disabled=not current_user.has('customers.write')) }}
{{ field('house_number', 'Nr.', value=customer.house_number, {{ field('house_number', 'Nr.', value=customer.house_number,
disabled=not current_user.has('customers.edit')) }} disabled=not current_user.has('customers.write')) }}
{{ field('postal_code', 'PLZ', value=customer.postal_code, {{ field('postal_code', 'PLZ', value=customer.postal_code,
disabled=not current_user.has('customers.edit')) }} disabled=not current_user.has('customers.write')) }}
{{ field('city', 'Ort', value=customer.city, {{ field('city', 'Ort', value=customer.city,
disabled=not current_user.has('customers.edit')) }} disabled=not current_user.has('customers.write')) }}
</div> </div>
{{ textarea_field('notes', 'Notizen', value=customer.notes, rows=3, {{ textarea_field('notes', 'Notizen', value=customer.notes, rows=3,
extra_class='field--full') }} extra_class='field--full') }}
@@ -200,7 +199,7 @@
{{ icon('copy', size=13) }}</button></dd> {{ icon('copy', size=13) }}</button></dd>
</dl> </dl>
</div> </div>
{% if current_user.has('customers.edit') %} {% if current_user.has('customers.write') %}
<div class="card__footer modal__footer--split"> <div class="card__footer modal__footer--split">
{% if current_user.has('customers.delete') %} {% if current_user.has('customers.delete') %}
<button class="btn btn--sm btn--ghost danger-text" type="submit" <button class="btn btn--sm btn--ghost danger-text" type="submit"
@@ -16,7 +16,13 @@
{% else %} {% else %}
<div class="grid grid--4"> <div class="grid grid--4">
{{ stat('Kunden', summary.counts.customers, href=url_for('customers.overview')) }} {{ stat('Kunden', summary.counts.customers, href=url_for('customers.overview')) }}
{{ stat('Offene Tickets', summary.counts.tickets_open, href=url_for('tickets.overview')) }} {# "Offen" heisst: noch nicht eingeloest. Ein Ticket, zu dem eine gueltige
Lizenz gehoert, ist erledigt -- es hier mitzuzaehlen schickte jemanden
auf die Suche nach Arbeit, die es nicht gibt. #}
{{ stat('Offene Tickets', summary.counts.tickets_open,
hint_text=(summary.counts.tickets_redeemed ~ ' eingelöst')
if summary.counts.tickets_redeemed else '',
href=url_for('tickets.overview')) }}
{{ stat('Aktive Lizenzen', summary.counts.licenses_active, kind='ok', {{ stat('Aktive Lizenzen', summary.counts.licenses_active, kind='ok',
href=url_for('licenses.overview', status='active')) }} href=url_for('licenses.overview', status='active')) }}
{{ stat('Widerrufen', summary.counts.licenses_revoked, kind='muted', {{ stat('Widerrufen', summary.counts.licenses_revoked, kind='muted',
@@ -35,14 +41,13 @@
{% set entries = summary.buckets[key] %} {% set entries = summary.buckets[key] %}
{% if entries %} {% if entries %}
<details class="card" data-persist="dash-{{ key }}" {% if key != 'green' %}open{% endif %}> <details class="card" data-persist="dash-{{ key }}" {% if key != 'green' %}open{% endif %}>
<summary class="card__header" style="cursor:pointer"> <summary class="card__header">
<div class="flex-1"> <div class="flex-1">
<div class="card__title"> <div class="card__title">
{{ pill(entries | length, kind if kind != 'ok' else 'online') }} {{ label }} {{ pill(entries | length, kind if kind != 'ok' else 'online') }} {{ label }}
</div> </div>
<div class="card__sub">{{ hint }}</div> <div class="card__sub">{{ hint }}</div>
</div> </div>
{{ icon('chevron-down', size=16) }}
</summary> </summary>
<div class="card__body"> <div class="card__body">
<div class="grid grid--tiles"> <div class="grid grid--tiles">
@@ -101,7 +101,9 @@
</div> </div>
</section> </section>
{% if current_user.has('licenses.edit') and license.status != 'revoked' %} {# Die Laufzeit haengt am Ticket: weiter als dessen Anspruch reicht, geht
sie nicht. Das steht hier, damit es nicht erst die Fehlermeldung sagt. #}
{% if current_user.has('license_renew.execute') and license.status != 'revoked' %}
<section class="card"> <section class="card">
<div class="card__header"> <div class="card__header">
<div class="flex-1"> <div class="flex-1">
@@ -128,13 +130,47 @@
{% endif %} {% endif %}
{{ field('extend_days', 'Laufzeit verlängern (Tage)', type='number', {{ field('extend_days', 'Laufzeit verlängern (Tage)', type='number',
placeholder='0', placeholder='0',
hint_text='Wird auf das aktülle Ablaufdatum aufgeschlagen.') }} hint_text=('Höchstens bis zum Ende der Ticketlaufzeit (' ~ (ticket_grenze | dt) ~ ') -- weiter geht es nur über das Ticket.') if ticket_grenze else 'Wird auf das aktuelle Ablaufdatum aufgeschlagen.') }}
</div> </div>
<div class="card__footer"> <div class="card__footer">
<button class="btn btn--primary" type="submit">Neue Revision signieren</button> <button class="btn btn--primary" type="submit">Neue Revision signieren</button>
</div> </div>
</form> </form>
</section> </section>
{# Anspruch und Urkunde wieder zur Deckung bringen. In beide Richtungen --
und wenn es verkürzt, ist das eine Rücknahme von Laufzeit: deshalb mit
Rückfrage und eigener Zeile im Änderungsprotokoll. #}
{% if abweichung %}
<section class="card">
<div class="card__header">
<div class="flex-1">
<div class="card__title">{{ icon('shield', size=17) }} Laufzeit weicht vom Ticket ab</div>
<div class="card__sub">
Diese Lizenz läuft
{% if license.lifetime %}<strong>unbefristet</strong>
{% else %}bis <strong>{{ license.expires_at | dt }}</strong>{% endif %},
das Ticket gewährt
{% if abweichung.unbefristet %}<strong>unbefristet</strong>
{% else %}bis <strong>{{ abweichung.grenze | dt }}</strong>{% endif %}.
</div>
</div>
</div>
<form method="post"
action="{{ url_for('licenses.align_ticket', license_id=license.license_id) }}">
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
<div class="card__footer">
<button class="btn" type="submit"
data-confirm="Die Laufzeit dieser Lizenz auf {% if abweichung.unbefristet %}unbefristet{% else %}{{ abweichung.grenze | dt }}{% endif %} setzen?{% if abweichung.verkuerzt %} Das nimmt bereits zugesagte Laufzeit zurück{% if license.lifetime %} -- aus einer unbefristeten Lizenz wird eine befristete{% endif %}. Der Client übernimmt es beim nächsten Heartbeat.{% endif %}"
data-confirm-title="Auf die Ticketlaufzeit setzen"
data-confirm-ok="Setzen"
data-confirm-style="{{ 'danger' if abweichung.verkuerzt else 'primary' }}">
Auf die Ticketlaufzeit setzen
</button>
</div>
</form>
</section>
{% endif %}
{% endif %} {% endif %}
<section class="card"> <section class="card">
@@ -167,12 +203,11 @@
</section> </section>
<details class="card" data-persist="license-document"> <details class="card" data-persist="license-document">
<summary class="card__header" style="cursor:pointer"> <summary class="card__header">
<div class="flex-1"> <div class="flex-1">
<div class="card__title">{{ icon('file-text', size=17) }} Signiertes Dokument</div> <div class="card__title">{{ icon('file-text', size=17) }} Signiertes Dokument</div>
<div class="card__sub">Genau so, wie es signiert wurde -- jede Änderung bricht die Signatur</div> <div class="card__sub">Genau so, wie es signiert wurde – jede Änderung bricht die Signatur</div>
</div> </div>
{{ icon('chevron-down', size=16) }}
</summary> </summary>
<pre class="log-view">{{ document_json }}</pre> <pre class="log-view">{{ document_json }}</pre>
</details> </details>
@@ -204,17 +239,17 @@
</section> </section>
{% endif %} {% endif %}
{% if current_user.has('licenses.edit') or current_user.has('licenses.delete') %} {% if current_user.has('license_revoke.execute') or current_user.has('licenses.delete') %}
<section class="card"> <section class="card">
<div class="card__header"> <div class="card__header">
<div class="flex-1"><div class="card__title">{{ icon('alert', size=17) }} Lebenszyklus</div></div> <div class="flex-1"><div class="card__title">{{ icon('alert', size=17) }} Lebenszyklus</div></div>
</div> </div>
<div class="card__body stack stack--tight"> <div class="card__body stack stack--tight">
{% if current_user.has('licenses.edit') and license.status != 'revoked' %} {% if current_user.has('license_revoke.execute') and license.status != 'revoked' %}
<button class="btn btn--sm btn--danger btn--block" type="button" <button class="btn btn--sm btn--danger btn--block" type="button"
data-dialog-open="revoke-dialog">Widerrufen</button> data-dialog-open="revoke-dialog">Widerrufen</button>
<p class="fs-xs faint"> <p class="fs-xs faint">
Das Ticket bleibt offen -- eine neue Lizenz kann sofort ausgestellt werden. Genau so Das Ticket bleibt offen – eine neue Lizenz kann sofort ausgestellt werden. Genau so
läuft ein Systemwechsel. läuft ein Systemwechsel.
</p> </p>
{% endif %} {% endif %}
@@ -239,7 +274,7 @@
{% endblock %} {% endblock %}
{% block dialogs %} {% block dialogs %}
{% if current_user.has('licenses.edit') and license.status != 'revoked' %} {% if current_user.has('license_revoke.execute') and license.status != 'revoked' %}
{% call modal('revoke-dialog', 'Lizenz widerrufen', license.customer_name, 'narrow') %} {% call modal('revoke-dialog', 'Lizenz widerrufen', license.customer_name, 'narrow') %}
<form method="post" action="{{ url_for('licenses.revoke', license_id=license.license_id) }}"> <form method="post" action="{{ url_for('licenses.revoke', license_id=license.license_id) }}">
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}"> <input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
@@ -6,7 +6,7 @@
{% block topbar_extra %} {% block topbar_extra %}
{{ search_box('licenses-table', 'Kunde, Lizenz-ID, Host ...') }} {{ search_box('licenses-table', 'Kunde, Lizenz-ID, Host ...') }}
{% if current_user.has('licenses.execute') %} {% if current_user.has('license_activation.execute') %}
<a class="btn btn--sm" href="{{ url_for('licenses.manual_code') }}"> <a class="btn btn--sm" href="{{ url_for('licenses.manual_code') }}">
{{ icon('transfer', size=15) }} Offline-Code {{ icon('transfer', size=15) }} Offline-Code
</a> </a>
@@ -89,7 +89,7 @@
</dd> </dd>
</dl> </dl>
</div> </div>
{% if current_user.has('settings_license.edit') %} {% if current_user.has('settings_license.write') %}
<div class="card__footer"> <div class="card__footer">
{% if not state.activated %} {% if not state.activated %}
<form method="post" action="{{ url_for('own_license.activate') }}" data-no-guard> <form method="post" action="{{ url_for('own_license.activate') }}" data-no-guard>
@@ -151,7 +151,7 @@
</section> </section>
{% endif %} {% endif %}
{% if current_user.has('settings_license.edit') %} {% if current_user.has('settings_license.write') %}
<section class="card"> <section class="card">
<div class="card__header"> <div class="card__header">
<div class="flex-1"> <div class="flex-1">
@@ -22,7 +22,7 @@
</form> </form>
<p class="auth-footnote"> <p class="auth-footnote">
Aus Datenschutzgründen bekommen Sie immer dieselbe Rückmeldung -- unabhängig davon, ob die Aus Datenschutzgründen bekommen Sie immer dieselbe Rückmeldung – unabhängig davon, ob die
Adresse bei uns bekannt ist. Adresse bei uns bekannt ist.
</p> </p>
{% endblock %} {% endblock %}
@@ -19,7 +19,7 @@ werden bei jedem Bericht vollständig ersetzt.', 'info') }}
<div class="stack"> <div class="stack">
{% for entry in reported.customers %} {% for entry in reported.customers %}
<details class="card" open> <details class="card" open>
<summary class="card__header" style="cursor:pointer"> <summary class="card__header">
<div class="flex-1"> <div class="flex-1">
<div class="card__title">{{ entry.name }}</div> <div class="card__title">{{ entry.name }}</div>
<div class="card__sub"> <div class="card__sub">
@@ -28,7 +28,6 @@ werden bei jedem Bericht vollständig ersetzt.', 'info') }}
&middot; {{ entry.tickets | length }} Ticket(s) &middot; {{ entry.tickets | length }} Ticket(s)
</div> </div>
</div> </div>
{{ icon('chevron-down', size=16) }}
</summary> </summary>
<div class="table-wrap"> <div class="table-wrap">
<table class="table table--compact"> <table class="table table--compact">
@@ -99,12 +99,11 @@
{% if history %} {% if history %}
<details class="card"> <details class="card">
<summary class="card__header" style="cursor:pointer"> <summary class="card__header">
<div class="flex-1"> <div class="flex-1">
<div class="card__title">{{ icon('history', size=17) }} Früher ausgestellt</div> <div class="card__title">{{ icon('history', size=17) }} Früher ausgestellt</div>
<div class="card__sub">{{ history | length }} Einträge</div> <div class="card__sub">{{ history | length }} Einträge</div>
</div> </div>
{{ icon('chevron-down', size=16) }}
</summary> </summary>
<div class="table-wrap"> <div class="table-wrap">
<table class="table table--compact"> <table class="table table--compact">
@@ -15,7 +15,7 @@
{% if response_code %} {% if response_code %}
<div class="field"> <div class="field">
<span class="field__label">Antwortcode -- in TESM eintragen</span> <span class="field__label">Antwortcode – in TESM eintragen</span>
<pre class="log-view" id="offline-answer" style="max-height:16dvh">{{ response_code }}</pre> <pre class="log-view" id="offline-answer" style="max-height:16dvh">{{ response_code }}</pre>
<div class="row row--end mt-2"> <div class="row row--end mt-2">
<button class="btn btn--sm" type="button" data-copy="#offline-answer"> <button class="btn btn--sm" type="button" data-copy="#offline-answer">
@@ -23,7 +23,7 @@
</button> </button>
</div> </div>
<p class="fs-sm muted mt-2"> <p class="fs-sm muted mt-2">
Tragen Sie ihn in TESM unter Verwaltung -- Lizenz bei Tragen Sie ihn in TESM unter Verwaltung → Lizenz bei
&bdquo;Antwortcode des Lizenzservers&ldquo; ein. &bdquo;Antwortcode des Lizenzservers&ldquo; ein.
{% if summary.hostname %} {% if summary.hostname %}
Die Anfrage kam von <span class="mono">{{ summary.hostname }}</span>. Die Anfrage kam von <span class="mono">{{ summary.hostname }}</span>.
@@ -33,16 +33,16 @@ bewusst keine echte Nachricht.', 'info') }}
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}"> <input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
<div class="card__body stack"> <div class="card__body stack">
{{ checkbox('graph_enabled', 'E-Mail-Versand aktiv', checked=values.graph_enabled, {{ checkbox('graph_enabled', 'E-Mail-Versand aktiv', checked=values.graph_enabled,
disabled=not current_user.has('settings_mail.edit')) }} disabled=not current_user.has('settings_mail.write')) }}
<div class="form-grid"> <div class="form-grid">
{{ field('graph_tenant_id', 'Verzeichnis-ID (Tenant)', value=values.graph_tenant_id, {{ field('graph_tenant_id', 'Verzeichnis-ID (Tenant)', value=values.graph_tenant_id,
disabled=not current_user.has('settings_mail.edit')) }} disabled=not current_user.has('settings_mail.write')) }}
{{ field('graph_client_id', 'Anwendungs-ID (Client)', value=values.graph_client_id, {{ field('graph_client_id', 'Anwendungs-ID (Client)', value=values.graph_client_id,
disabled=not current_user.has('settings_mail.edit')) }} disabled=not current_user.has('settings_mail.write')) }}
{{ field('graph_sender', 'Absender-Postfach', value=values.graph_sender, type='email', {{ field('graph_sender', 'Absender-Postfach', value=values.graph_sender, type='email',
disabled=not current_user.has('settings_mail.edit')) }} disabled=not current_user.has('settings_mail.write')) }}
{{ field('graph_reply_to', 'Antwortadresse', value=values.graph_reply_to, type='email', {{ field('graph_reply_to', 'Antwortadresse', value=values.graph_reply_to, type='email',
disabled=not current_user.has('settings_mail.edit')) }} disabled=not current_user.has('settings_mail.write')) }}
</div> </div>
<fieldset class="fieldset"> <fieldset class="fieldset">
<legend>Client-Secret {% if has_secret %}{{ pill('gesetzt', 'online') }}{% else %}{{ pill('fehlt', 'warning') }}{% endif %}</legend> <legend>Client-Secret {% if has_secret %}{{ pill('gesetzt', 'online') }}{% else %}{{ pill('fehlt', 'warning') }}{% endif %}</legend>
@@ -55,7 +55,7 @@ bewusst keine echte Nachricht.', 'info') }}
{% endif %} {% endif %}
</fieldset> </fieldset>
</div> </div>
{% if current_user.has('settings_mail.edit') %} {% if current_user.has('settings_mail.write') %}
<div class="card__footer"><button class="btn btn--primary" type="submit">Speichern</button></div> <div class="card__footer"><button class="btn btn--primary" type="submit">Speichern</button></div>
{% endif %} {% endif %}
</form> </form>
@@ -1,5 +1,5 @@
{% extends "tesm_core/base.html" %} {% extends "tesm_core/base.html" %}
{% from "tesm_core/macros.html" import field, select_field, checkbox, alert %} {% from "tesm_core/macros.html" import field, select_field, checkbox, alert, nav_order_card %}
{% block page_title %}Systemeinstellungen{% endblock %} {% block page_title %}Systemeinstellungen{% endblock %}
{% block page_sub %}Portal, Self-Service, Protokolle, Host{% endblock %} {% block page_sub %}Portal, Self-Service, Protokolle, Host{% endblock %}
@@ -60,9 +60,6 @@
selected=values.log_rotate_interval) }} selected=values.log_rotate_interval) }}
{{ field('log_rotate_keep', 'Aufbewahrte Rotationen', value=values.log_rotate_keep, {{ field('log_rotate_keep', 'Aufbewahrte Rotationen', value=values.log_rotate_keep,
type='number') }} type='number') }}
{{ field('session_idle_minutes', 'Automatische Abmeldung (Minuten)',
value=values.session_idle_minutes, type='number',
hint_text=specs['session_idle_minutes'].hint) }}
{{ field('trash_retention_days', 'Papierkorb-Aufbewahrung (Tage)', {{ field('trash_retention_days', 'Papierkorb-Aufbewahrung (Tage)',
value=values.trash_retention_days, type='number') }} value=values.trash_retention_days, type='number') }}
</div> </div>
@@ -73,6 +70,34 @@
</div> </div>
<div class="stack"> <div class="stack">
{# Eigene Karte: die Abmeldung stand zwischen Protokollrotation und
Papierkorb -- drei Dinge ohne Zusammenhang. Wer die Sitzungsdauer sucht,
sucht sie nicht unter "Protokolle". #}
<section class="card">
<div class="card__header">
<div class="flex-1">
<div class="card__title">{{ icon('clock', size=17) }} Sitzungen</div>
<div class="card__sub">Wie lange eine Anmeldung gilt</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('session_idle_minutes', 'Automatische Abmeldung nach Leerlauf (Minuten)',
value=values.session_idle_minutes, type='number',
attrs='min="5" max="1440" step="1"',
hint_text=specs['session_idle_minutes'].hint) }}
{{ field('session_absolute_hours', 'Längstmögliche Sitzungsdauer (Stunden)',
value=values.session_absolute_hours, type='number',
attrs='min="1" max="168" step="1"',
hint_text=specs['session_absolute_hours'].hint) }}
</div>
</div>
<div class="card__footer"><button class="btn btn--primary" type="submit">Speichern</button></div>
</form>
</section>
<section class="card"> <section class="card">
<div class="card__header"> <div class="card__header">
<div class="flex-1"><div class="card__title">{{ icon('server', size=17) }} Host</div></div> <div class="flex-1"><div class="card__title">{{ icon('server', size=17) }} Host</div></div>
@@ -104,33 +129,9 @@
</section> </section>
{% if current_user.is_admin %} {% if current_user.is_admin %}
<section class="card"> {{ nav_order_card(nav_baum, nav_order, nav_child_order,
<div class="card__header"> url_for('admin.nav_order'), csrf_field_name, csrf_token(),
<div class="flex-1"> sub='Gilt für alle Benutzer') }}
<div class="card__title">{{ icon('sliders', size=17) }} Navigationsreihenfolge</div>
<div class="card__sub">Gilt für 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 %} {% endif %}
</div> </div>
</div> </div>
@@ -19,16 +19,16 @@ welche Basis-URL in kundengerichteten Links steht.', 'info') }}
<div class="card__body stack"> <div class="card__body stack">
<div class="form-grid"> <div class="form-grid">
{{ field('vendor_name', 'Anbietername', value=values.vendor_name, {{ field('vendor_name', 'Anbietername', value=values.vendor_name,
disabled=not current_user.has('settings_vendor.edit')) }} disabled=not current_user.has('settings_vendor.write')) }}
{{ field('vendor_email', 'E-Mail', value=values.vendor_email, type='email', {{ field('vendor_email', 'E-Mail', value=values.vendor_email, type='email',
disabled=not current_user.has('settings_vendor.edit')) }} disabled=not current_user.has('settings_vendor.write')) }}
{{ field('vendor_phone', 'Telefon', value=values.vendor_phone, {{ field('vendor_phone', 'Telefon', value=values.vendor_phone,
disabled=not current_user.has('settings_vendor.edit')) }} disabled=not current_user.has('settings_vendor.write')) }}
</div> </div>
{{ textarea_field('vendor_address', 'Adresse', value=values.vendor_address, rows=3) }} {{ textarea_field('vendor_address', 'Adresse', value=values.vendor_address, rows=3) }}
{{ field('master_endpoint', 'Öffentlicher Endpunkt', value=values.master_endpoint, {{ field('master_endpoint', 'Öffentlicher Endpunkt', value=values.master_endpoint,
placeholder='https://lizenz.firma.de', placeholder='https://lizenz.firma.de',
disabled=not current_user.has('settings_vendor.edit'), disabled=not current_user.has('settings_vendor.write'),
hint_text='Basis-URL, unter der Kundeninstanzen diesen Server erreichen. Wird in hint_text='Basis-URL, unter der Kundeninstanzen diesen Server erreichen. Wird in
jede Lizenz eingebettet und für alle kundengerichteten Links verwendet -- statt jede Lizenz eingebettet und für alle kundengerichteten Links verwendet -- statt
des Host-Headers der aktüllen Anfrage, der bei internen Aufrufen falsch wäre.', des Host-Headers der aktüllen Anfrage, der bei internen Aufrufen falsch wäre.',
@@ -43,14 +43,14 @@ welche Basis-URL in kundengerichteten Links steht.', 'info') }}
<p class="faint fs-sm">Kein Logo hinterlegt.</p> <p class="faint fs-sm">Kein Logo hinterlegt.</p>
{% endif %} {% endif %}
<input class="input" type="file" name="vendor_logo" accept="image/png,image/jpeg,image/svg+xml" <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 %}> {% if not current_user.has('settings_vendor.write') %}disabled{% endif %}>
<span class="field__hint"> <span class="field__hint">
Ein zu grosses Logo wird abgelehnt, die übrigen Angaben werden trotzdem gespeichert. Ein zu großes Logo wird abgelehnt, die übrigen Angaben werden trotzdem gespeichert.
</span> </span>
</div> </div>
</fieldset> </fieldset>
</div> </div>
{% if current_user.has('settings_vendor.edit') %} {% if current_user.has('settings_vendor.write') %}
<div class="card__footer modal__footer--split"> <div class="card__footer modal__footer--split">
{% if values.vendor_logo_base64 %} {% if values.vendor_logo_base64 %}
<button class="btn btn--sm btn--ghost" type="submit" <button class="btn btn--sm btn--ghost" type="submit"
@@ -75,7 +75,7 @@ welche Basis-URL in kundengerichteten Links steht.', 'info') }}
<div class="divider"></div> <div class="divider"></div>
<p class="fs-sm muted"> <p class="fs-sm muted">
Die Angaben werden zum Zeitpunkt der Ausstellung in die Lizenz kopiert und signiert. Eine Die Angaben werden zum Zeitpunkt der Ausstellung in die Lizenz kopiert und signiert. Eine
Änderung hier wirkt auf neue Lizenzen -- bestehende behalten ihren Stand, bis eine neue Änderung hier wirkt auf neue Lizenzen – bestehende behalten ihren Stand, bis eine neue
Revision signiert wird. Revision signiert wird.
</p> </p>
</div> </div>
@@ -33,15 +33,17 @@
<div class="card__title">{{ icon('shield', size=17) }} Lizenz ausstellen</div> <div class="card__title">{{ icon('shield', size=17) }} Lizenz ausstellen</div>
<div class="card__sub"> <div class="card__sub">
{% if has_open_license %} {% if has_open_license %}
Es existiert bereits eine offene Lizenz -- widerrufen Sie sie zuerst. Es existiert bereits eine offene Lizenz – widerrufen Sie sie zuerst.
{% else %} {% else %}
Erzeugt eine signierte Lizenzdatei mit den Werten dieses Tickets. Erzeugt eine signierte Lizenzdatei mit den Werten dieses Tickets.
Die Laufzeit kommt aus dem Ticket und kann später nur mit ihm
verlängert werden.
{% endif %} {% endif %}
</div> </div>
</div> </div>
</div> </div>
<div class="card__body row row--tight"> <div class="card__body row row--tight">
{% if current_user.has('licenses.create') %} {% if current_user.has('license_issue.execute') %}
<form method="post" action="{{ url_for('licenses.issue', ticket_id=ticket.id) }}" <form method="post" action="{{ url_for('licenses.issue', ticket_id=ticket.id) }}"
data-no-guard> data-no-guard>
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}"> <input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
@@ -57,8 +59,11 @@
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}"> <input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
<input type="hidden" name="as_trial" value="1"> <input type="hidden" name="as_trial" value="1">
<button class="btn" type="submit" <button class="btn" type="submit"
{% if has_open_license or ticket.status == 'blocked' %}disabled{% endif %} {# Eine laufende **Test**lizenz sperrt diesen Knopf, eine
data-confirm="Testlizenz ausstellen? {{ trial_days }} Tage Laufzeit mit allen Modulen. Das Ticket selbst bleibt unverändert." Volllizenz nicht: ansehen und bekommen sind zweierlei,
und ein Ticket darf beides tragen. #}
{% if has_open_trial or ticket.status == 'blocked' %}disabled{% endif %}
data-confirm="Testlizenz ausstellen? {{ trial_days }} Tage Laufzeit mit allen Modulen. Das Ticket bleibt unverändert und offen -- eine Testlizenz löst es nicht ein."
data-confirm-title="Testlizenz" data-confirm-ok="Ausstellen" data-confirm-title="Testlizenz" data-confirm-ok="Ausstellen"
data-confirm-style="primary"> data-confirm-style="primary">
Als Testlizenz ({{ trial_days }} Tage, alle Module) Als Testlizenz ({{ trial_days }} Tage, alle Module)
@@ -128,7 +133,7 @@
</div> </div>
<p class="fs-xs faint"> <p class="fs-xs faint">
Damit kann der Kunde seine Lizenz herunterladen, bei einem Systemwechsel freigeben und Damit kann der Kunde seine Lizenz herunterladen, bei einem Systemwechsel freigeben und
neu ausstellen. Der Link enthält 192 Bit Zufall und ist nicht ratbar -- behandeln Sie neu ausstellen. Der Link enthält 192 Bit Zufall und ist nicht ratbar – behandeln Sie
ihn wie ein Passwort. ihn wie ein Passwort.
</p> </p>
<div class="divider"></div> <div class="divider"></div>
@@ -163,7 +168,7 @@
<label class="field"> <label class="field">
<span class="field__label">Lizenztyp</span> <span class="field__label">Lizenztyp</span>
<select class="select" name="type" id="ticket-type" <select class="select" name="type" id="ticket-type"
{% if not current_user.has('tickets.edit') %}disabled{% endif %}> {% if not current_user.has('tickets.write') %}disabled{% endif %}>
{% for value in types %} {% for value in types %}
<option value="{{ value }}" {% if value == ticket.type %}selected{% endif %}> <option value="{{ value }}" {% if value == ticket.type %}selected{% endif %}>
{{ type_labels.get(value, value) }} {{ type_labels.get(value, value) }}
@@ -178,15 +183,16 @@
{% for module in modules %} {% for module in modules %}
{{ checkbox('modules', module_labels[module], value=module, {{ checkbox('modules', module_labels[module], value=module,
checked=module in ticket_modules, checked=module in ticket_modules,
disabled=not current_user.has('tickets.edit')) }} disabled=not current_user.has('tickets.write')) }}
{% endfor %} {% endfor %}
</div> </div>
</fieldset> </fieldset>
{% endif %} {% endif %}
{{ field('valid_days', 'Laufzeit (Tage)', value=ticket.valid_days, type='number', {{ field('valid_days', 'Laufzeit (Tage)', value=ticket.valid_days, type='number',
disabled=not current_user.has('tickets.edit')) }} hint_text='Gesamtlaufzeit ab Ausstellung. Aufschlagen geht unten.',
disabled=not current_user.has('tickets.write')) }}
{{ checkbox('lifetime', 'Unbefristet', checked=ticket.lifetime, {{ checkbox('lifetime', 'Unbefristet', checked=ticket.lifetime,
disabled=not current_user.has('tickets.edit')) }} disabled=not current_user.has('tickets.write')) }}
{{ textarea_field('notes', 'Interne Notiz', value=ticket.notes, rows=2) }} {{ textarea_field('notes', 'Interne Notiz', value=ticket.notes, rows=2) }}
<div class="divider"></div> <div class="divider"></div>
<dl class="kv fs-xs"> <dl class="kv fs-xs">
@@ -195,7 +201,7 @@
<dt>Angelegt</dt><dd>{{ ticket.created_at | dt }} von {{ ticket.created_by or '-' }}</dd> <dt>Angelegt</dt><dd>{{ ticket.created_at | dt }} von {{ ticket.created_by or '-' }}</dd>
</dl> </dl>
</div> </div>
{% if current_user.has('tickets.edit') %} {% if current_user.has('tickets.write') %}
<div class="card__footer"> <div class="card__footer">
<button class="btn btn--primary" type="submit">Speichern</button> <button class="btn btn--primary" type="submit">Speichern</button>
</div> </div>
@@ -203,13 +209,41 @@
</form> </form>
</section> </section>
{% if current_user.has('tickets.edit') or current_user.has('tickets.delete') %} {# Dasselbe Bedienmuster wie bei der Lizenz: wer verlängert, rechnet in
Tagen ab jetzt und nicht in einer neuen Gesamtzahl, die er sich erst aus
dem alten Wert zusammensuchen muss. Und weil das Ticket der Anspruch
ist, zieht die laufende Lizenz gleich mit. #}
{% if current_user.has('tickets.write') and not ticket.lifetime %}
<section class="card">
<div class="card__header">
<div class="flex-1">
<div class="card__title">{{ icon('clock', size=17) }} Laufzeit verlängern</div>
<div class="card__sub">
Schlägt Tage auf die {{ ticket.valid_days }} Tage dieses Tickets auf.
Eine laufende Lizenz wird dabei mitverlängert – sie erhält eine neue
Revision und übernimmt sie beim nächsten Heartbeat.
</div>
</div>
</div>
<form method="post" action="{{ url_for('tickets.extend', ticket_id=ticket.id) }}">
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
<div class="card__body">
{{ field('days', 'Verlängern um (Tage)', type='number', placeholder='30') }}
</div>
<div class="card__footer">
<button class="btn btn--primary" type="submit">Verlängern</button>
</div>
</form>
</section>
{% endif %}
{% if current_user.has('tickets.write') or current_user.has('tickets.delete') %}
<section class="card"> <section class="card">
<div class="card__header"> <div class="card__header">
<div class="flex-1"><div class="card__title">{{ icon('lock', size=17) }} Sperre und Löschung</div></div> <div class="flex-1"><div class="card__title">{{ icon('lock', size=17) }} Sperre und Löschung</div></div>
</div> </div>
<div class="card__body stack stack--tight"> <div class="card__body stack stack--tight">
{% if current_user.has('tickets.edit') %} {% if current_user.has('tickets.write') %}
<form method="post" action="{{ url_for('tickets.toggle_block', ticket_id=ticket.id) }}" <form method="post" action="{{ url_for('tickets.toggle_block', ticket_id=ticket.id) }}"
data-no-guard> data-no-guard>
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}"> <input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
@@ -32,7 +32,7 @@
{% endfor %} {% endfor %}
</select> </select>
<span class="field__hint"> <span class="field__hint">
Der Kunde ist nach dem Anlegen unveränderlich -- der Self-Service-Link ist an ihn Der Kunde ist nach dem Anlegen unveränderlich – der Self-Service-Link ist an ihn
gebunden. gebunden.
</span> </span>
</label> </label>
@@ -77,7 +77,7 @@
<div class="flex-1"> <div class="flex-1">
<div class="card__title">{{ icon('share', size=17) }} Lizenzserver-Lizenz</div> <div class="card__title">{{ icon('share', size=17) }} Lizenzserver-Lizenz</div>
<div class="card__sub"> <div class="card__sub">
Erzeugt eine neue Vertrauensgrenze -- der Empfänger verwaltet danach eigene Kunden Erzeugt eine neue Vertrauensgrenze – der Empfänger verwaltet danach eigene Kunden
</div> </div>
</div> </div>
</div> </div>
@@ -103,7 +103,7 @@
<div class="card__body"> <div class="card__body">
<ol class="stack stack--tight fs-sm muted" style="list-style:decimal;padding-left:1.2em"> <ol class="stack stack--tight fs-sm muted" style="list-style:decimal;padding-left:1.2em">
<li>Ticket anlegen (dieser Schritt).</li> <li>Ticket anlegen (dieser Schritt).</li>
<li>Auf der Ticketseite eine Lizenz ausstellen -- optional als 30-Tage-Testlizenz mit <li>Auf der Ticketseite eine Lizenz ausstellen – optional als 30-Tage-Testlizenz mit
allen Modulen.</li> allen Modulen.</li>
<li>Lizenzdatei herunterladen oder per E-Mail versenden.</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 <li>Der Kunde spielt sie ein und aktiviert sie; die Aktivierung bindet sie an seine
@@ -112,7 +112,7 @@
<div class="divider"></div> <div class="divider"></div>
<p class="fs-sm muted"> <p class="fs-sm muted">
Der Self-Service-Link des Tickets erlaubt dem Kunden, seine Lizenz selbst Der Self-Service-Link des Tickets erlaubt dem Kunden, seine Lizenz selbst
herunterzuladen und bei einem Systemwechsel freizugeben -- ohne Support-Anruf. herunterzuladen und bei einem Systemwechsel freizugeben – ohne Support-Anruf.
</p> </p>
</div> </div>
<div class="card__footer"> <div class="card__footer">
@@ -62,7 +62,10 @@ Ausstellung -- bereits ausgestellte Lizenzen bleiben unverändert.', 'info') }}
{% else %}{{ ticket.valid_days }} Tage{% endif %} {% else %}{{ ticket.valid_days }} Tage{% endif %}
</td> </td>
<td> <td>
{# Dieselbe Rechnung wie auf der Startseite, sonst widersprechen
sich die beiden Seiten: dort "0 offen", hier dreimal "offen". #}
{% if ticket.status == 'blocked' %}{{ pill('gesperrt', 'danger') }} {% if ticket.status == 'blocked' %}{{ pill('gesperrt', 'danger') }}
{% elif ticket.open_license_id %}{{ pill('eingelöst', '') }}
{% else %}{{ pill('offen', 'online') }}{% endif %} {% else %}{{ pill('offen', 'online') }}{% endif %}
</td> </td>
<td class="fs-sm"> <td class="fs-sm">
+128 -33
View File
@@ -26,6 +26,23 @@
# ============================================================================= # =============================================================================
set -euo pipefail set -euo pipefail
# Ein Lauf dieses Skripts gehoert in eine definierte Sprachumgebung -- nicht in
# die, die das Terminal des Administrators zufaellig ankuendigt. Per SSH kommt
# LC_ALL mit (sshd: "AcceptEnv LANG LC_*"), und ist diese Locale auf dem Ziel
# nicht erzeugt, warnt **jede neu gestartete Shell**:
#
# bash: warning: setlocale: LC_ALL: cannot change locale (en_US.UTF-8)
#
# Mitten in einer Aktualisierung sieht das nach einem Fehler aus und ist
# keiner. Nebenbei sind damit auch die Meldungen der Werkzeuge, die hier
# laufen, in einer Sprache -- ein Protokoll, das je nach Arbeitsplatz anders
# klingt, liest sich schlecht.
if locale -a 2>/dev/null | grep -qix 'c\.utf-\?8'; then
export LC_ALL=C.UTF-8 LANG=C.UTF-8
else
export LC_ALL=C LANG=C
fi
APP="" APP=""
INSTANCE="" INSTANCE=""
#: Verzeichnis der Umgebungsdateien. Ueberschreibbar, damit sich die #: Verzeichnis der Umgebungsdateien. Ueberschreibbar, damit sich die
@@ -39,6 +56,7 @@ HTTPS=0
HTTPS_PORT="" HTTPS_PORT=""
DOMAIN="" DOMAIN=""
ACME_EMAIL="" ACME_EMAIL=""
ERZWINGEN=0
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
REPO_DIR="$(cd "$SCRIPT_DIR/.." && pwd)" REPO_DIR="$(cd "$SCRIPT_DIR/.." && pwd)"
@@ -49,6 +67,39 @@ warn() { printf '%s !!%s %s\n' "$COLOR_WARN" "$COLOR_OFF" "$*"; }
fail() { printf '%s !!%s %s\n' "$COLOR_ERR" "$COLOR_OFF" "$*" >&2; exit 1; } fail() { printf '%s !!%s %s\n' "$COLOR_ERR" "$COLOR_OFF" "$*" >&2; exit 1; }
info() { printf ' %s\n' "$*"; } info() { printf ' %s\n' "$*"; }
# -- Welche Fassung liegt hier, und welche laeuft schon? ---------------------
#
# Ein Installationslauf auf dieselbe Fassung macht nichts besser: er baut die
# Python-Umgebung neu, kopiert dieselben Dateien, laedt nginx neu und startet
# den Dienst -- mit der einzigen Wirkung, dass die Anwendung ein paar Sekunden
# weg ist. Wer "update.sh" in einen Zeitplan haengt, bekaeme das taeglich.
#
# Verglichen wird die Anwendung **und** der Kern: die beiden Nummern laufen
# absichtlich auseinander (siehe pyproject.toml), und eine Auslieferung kann
# denselben Anwendungsstand mit neuem Kern enthalten.
#: Die Nummer aus einer pyproject.toml. Die erste Zeile "version = ..." gilt --
#: weitere Vorkommen stehen in Abhaengigkeiten und gehen uns nichts an.
paket_version() {
sed -n 's/^version = "\([^"]*\)"/\1/p' "$1" 2>/dev/null | head -1
}
#: Was in dieser Installation laeuft: "<anwendung> <kern>", sonst leer.
laufende_version() {
local wurzel="$1" modul="$2"
[[ -x "$wurzel/venv/bin/python" ]] || return 0
"$wurzel/venv/bin/python" -c \
"import $modul, tesm_core; print($modul.__version__, tesm_core.__version__)" \
2>/dev/null || true
}
#: Ist das, was hier liegt, schon installiert? Eine eigene Funktion, weil genau
#: diese Frage sich pruefen lassen muss -- ohne einen Server aufzusetzen.
gleiche_version() {
local laeuft="$1" liegt="$2"
[[ -n "$laeuft" && "$laeuft" == "$liegt" ]]
}
#: Merker fuer die EXIT-Falle. Zurueckgerollt wird nur, was auch gesichert ist. #: Merker fuer die EXIT-Falle. Zurueckgerollt wird nur, was auch gesichert ist.
SICHERUNG_STEHT=0 SICHERUNG_STEHT=0
INSTALL_FERTIG=0 INSTALL_FERTIG=0
@@ -94,11 +145,11 @@ bei_fehler() {
else else
warn "Abgebrochen (Code $code). Es gab keinen vorherigen Stand dieser Fassung," warn "Abgebrochen (Code $code). Es gab keinen vorherigen Stand dieser Fassung,"
warn "also wurde nichts wiederhergestellt und nichts geloescht." warn "also wurde nichts wiederhergestellt und nichts geloescht."
fi # Nur sagen, was es wirklich gibt: bei einem Abbruch an der Rueckfrage
# Nur sagen, was es wirklich gibt: bei einem Abbruch an der Rueckfrage # existiert das Verzeichnis noch nicht.
# existiert das Verzeichnis noch nicht, und die Zeile log dann. if [[ -n "${ROOT:-}" && -d "${ROOT:-}" ]]; then
if [[ -n "${ROOT:-}" && -d "${ROOT:-}" ]]; then warn "Angelegt wurde bis hierhin: $ROOT (Daten unter data/ unberuehrt)."
warn "Angelegt wurde bis hierhin: $ROOT (Daten unter data/ unberuehrt)." fi
fi fi
fi fi
return "$code" return "$code"
@@ -129,6 +180,7 @@ Verwendung: install.sh --app <tesm|tesm-license> [Optionen]
--acme-email <a> Kontaktadresse fuer Let's Encrypt. --acme-email <a> Kontaktadresse fuer Let's Encrypt.
--skip-packages Keine Systempakete installieren --skip-packages Keine Systempakete installieren
--yes Rueckfragen ueberspringen --yes Rueckfragen ueberspringen
--force Auch dann einrichten, wenn dieselbe Fassung schon laeuft
USAGE USAGE
} }
@@ -144,6 +196,7 @@ while [[ $# -gt 0 ]]; do
--acme-email) ACME_EMAIL="${2:?}"; shift 2 ;; --acme-email) ACME_EMAIL="${2:?}"; shift 2 ;;
--skip-packages) SKIP_PACKAGES=1; shift ;; --skip-packages) SKIP_PACKAGES=1; shift ;;
--yes|-y) ASSUME_YES=1; shift ;; --yes|-y) ASSUME_YES=1; shift ;;
--force) ERZWINGEN=1; shift ;;
-h|--help) usage; exit 0 ;; -h|--help) usage; exit 0 ;;
*) fail "unbekannte Option: $1" ;; *) fail "unbekannte Option: $1" ;;
esac esac
@@ -168,6 +221,8 @@ LOG_DIR="/var/log/$NAME"
SERVICE_USER="$APP" SERVICE_USER="$APP"
UNIT="$NAME.service" UNIT="$NAME.service"
MONITOR_UNIT="$NAME-monitor.service" MONITOR_UNIT="$NAME-monitor.service"
CLEANUP_UNIT="$NAME-cleanup.service"
CLEANUP_TIMER="$NAME-cleanup.timer"
NGINX_SITE="/etc/nginx/sites-available/$NAME" NGINX_SITE="/etc/nginx/sites-available/$NAME"
ENV_PREFIX="TESM_" ENV_PREFIX="TESM_"
[[ "$APP" == "tesm-license" ]] && ENV_PREFIX="TESM_LICENSE_" [[ "$APP" == "tesm-license" ]] && ENV_PREFIX="TESM_LICENSE_"
@@ -189,6 +244,12 @@ fi
# beim Secure-Flag weiter unten: eine Aktualisierung nimmt nichts weg, was # beim Secure-Flag weiter unten: eine Aktualisierung nimmt nichts weg, was
# nicht ausdruecklich neu gesetzt wird. # nicht ausdruecklich neu gesetzt wird.
ENV_FILE="$ETC_TESM/$NAME.env" ENV_FILE="$ETC_TESM/$NAME.env"
# Wo die Daten liegen, steht in der Umgebungsdatei; ohne sie beim Vorgabeort.
# "|| true": bei einer Neuinstallation gibt es die Datei noch nicht, und ein
# scheiterndes sed in einer Zuweisung beendet unter "set -e" das ganze Skript.
DATEN_DIR="$(sed -n "s/^${ENV_PREFIX}DATA_DIR=//p" "$ENV_FILE" 2>/dev/null || true)"
DATEN_DIR="$(printf '%s' "$DATEN_DIR" | tail -1)"
DATEN_DIR="${DATEN_DIR:-$ROOT/data}"
if [[ -z "$PORT" && -f "$ENV_FILE" ]]; then if [[ -z "$PORT" && -f "$ENV_FILE" ]]; then
PORT="$(sed -n "s/^${ENV_PREFIX}BIND=127\.0\.0\.1:\([0-9]\{1,5\}\)$/\1/p" "$ENV_FILE" | head -1)" PORT="$(sed -n "s/^${ENV_PREFIX}BIND=127\.0\.0\.1:\([0-9]\{1,5\}\)$/\1/p" "$ENV_FILE" | head -1)"
fi fi
@@ -253,6 +314,23 @@ FAILED_DIR="/srv/$NAME-failed-$TIMESTAMP"
IS_UPDATE=0 IS_UPDATE=0
[[ -f "$ROOT/data/app.db" ]] && IS_UPDATE=1 [[ -f "$ROOT/data/app.db" ]] && IS_UPDATE=1
# Laeuft hier schon genau das, was hier liegt? Dann ist Schluss, bevor
# irgendetwas angefasst wird -- und zwar mit Ausstiegscode 0: es ist nichts
# schiefgegangen, es war nur nichts zu tun.
MODUL="tesm"
[[ "$APP" == "tesm-license" ]] && MODUL="tesm_license"
LIEGT="$(paket_version "$REPO_DIR/apps/$APP/pyproject.toml") $(
paket_version "$REPO_DIR/packages/tesm-core/pyproject.toml")"
LAEUFT="$(laufende_version "$ROOT" "$MODUL")"
if [[ $IS_UPDATE -eq 1 && $ERZWINGEN -eq 0 ]] && gleiche_version "$LAEUFT" "$LIEGT"; then
step "Nichts zu tun"
info "$APP ${LIEGT%% *} (tesm-core ${LIEGT##* }) laeuft hier bereits."
info "Ein zweiter Durchlauf wuerde nur die Umgebung neu bauen und den"
info "Dienst neu starten -- ohne dass sich etwas aendert."
info "Trotzdem neu einrichten: --force"
exit 0
fi
step "TESM-Installation" step "TESM-Installation"
info "Anwendung : $APP${INSTANCE:+ (Instanz $INSTANCE)}" info "Anwendung : $APP${INSTANCE:+ (Instanz $INSTANCE)}"
info "Ziel : $ROOT" info "Ziel : $ROOT"
@@ -270,6 +348,7 @@ else
info "nginx : Port $HTTP_PORT (nur HTTP)" info "nginx : Port $HTTP_PORT (nur HTTP)"
fi fi
info "Modus : $([[ $IS_UPDATE -eq 1 ]] && echo Aktualisierung || echo Neuinstallation)" info "Modus : $([[ $IS_UPDATE -eq 1 ]] && echo Aktualisierung || echo Neuinstallation)"
info "Fassung : ${LIEGT%% *} (tesm-core ${LIEGT##* })${LAEUFT:+, bisher ${LAEUFT%% *}}"
if [[ $ASSUME_YES -eq 0 ]]; then if [[ $ASSUME_YES -eq 0 ]]; then
read -r -p "Fortfahren? [j/N] " answer read -r -p "Fortfahren? [j/N] " answer
@@ -306,11 +385,28 @@ fi
# Abgleich unten laeuft mit --delete und wuerde das Teilpaket entfernen. Danach # Abgleich unten laeuft mit --delete und wuerde das Teilpaket entfernen. Danach
# gaebe es auf dem Master keinen Weg mehr, eine Master-Lizenz auszustellen -- # gaebe es auf dem Master keinen Weg mehr, eine Master-Lizenz auszustellen --
# und niemand haette es gesagt. # und niemand haette es gesagt.
if [[ "$EDITION" == "sub" && -d "$ROOT/$MASTER_TEILPAKET" ]]; then # Die Rolle steht in der eingespielten Lizenz. Das ist die zuverlaessigere
fail "Hier laeuft die Master-Ausgabe, dieses Paket ist die Ausgabe fuer Sub-Lizenzserver. # Haelfte der Pruefung: eine Master-Installation aus einer aelteren Fassung
# kennt das Teilpaket noch nicht, ist aber trotzdem eine Wurzelinstanz.
ROLLE_ALT=""
if [[ "$EDITION" == "sub" && -s "$DATEN_DIR/license.json" ]]; then
ROLLE_ALT="$(python3 -c '
import json, sys
try:
print(json.load(open(sys.argv[1])).get("role", ""))
except Exception:
pass
' "$DATEN_DIR/license.json" 2>/dev/null || true)"
fi
if [[ "$EDITION" == "sub" && ( -d "$ROOT/$MASTER_TEILPAKET" || "$ROLLE_ALT" == "master" ) ]]; then
fail "Hier laeuft eine Wurzelinstanz${ROLLE_ALT:+ (Lizenz mit Rolle $ROLLE_ALT)}, dieses Paket
ist die Ausgabe fuer Sub-Lizenzserver.
Eine Aktualisierung damit wuerde die Master-Ausgabe entfernen. Eine Aktualisierung damit wuerde die Master-Ausgabe entfernen.
Passendes Paket: Repository tesm-license-master (privat). Passendes Paket: Repository tesm-license-master (privat).
Bewusster Rueckbau: $ROOT/$MASTER_TEILPAKET von Hand entfernen, dann erneut." Bewusster Rueckbau: eine vom Master ausgestellte Lizenz einspielen (Rolle
license_server) und $ROOT/$MASTER_TEILPAKET entfernen -- danach ist dies ein
Sub-Lizenzserver und dieses Paket das richtige."
fi fi
# -- Backup ------------------------------------------------------------------ # -- Backup ------------------------------------------------------------------
@@ -339,26 +435,16 @@ info "Dateien abgeglichen (Instanzdaten unter data/ bleiben unangetastet)"
# -- venv -------------------------------------------------------------------- # -- venv --------------------------------------------------------------------
step "Python-Umgebung" step "Python-Umgebung"
if [[ ! -x "$ROOT/venv/bin/python3" ]]; then # Aufbauen, pruefen und reparieren steht in genau einem Programm -- und
python3 -m venv "$ROOT/venv" # dasselbe laeuft als ExecStartPre vor jedem Dienststart. Zwei getrennte
info "venv erzeugt" # Paketlisten waeren zwei Listen, die auseinanderlaufen: die Reparatur baute
fi # dann eine Umgebung, die nicht mehr der Installation entspricht.
# Immer ueber "python3 -m pip" -- der pip-Shim im venv baeckt einen absoluten # Ueber "bash" und nicht als Programm: Dateien im Baum tragen kein
# Pfad in seinen Shebang, der nach einer Umbenennung des Verzeichnisses bricht. # Ausfuehrungsrecht -- git kennt auf einer Windows-Buildmaschine keines, und
"$ROOT/venv/bin/python3" -m pip install --quiet --upgrade pip setuptools wheel # install.sh, tesm-helper und tesm-motd werden aus demselben Grund ebenso
PAKETE=("$ROOT/packages/tesm-licensing" "$ROOT/packages/tesm-core") # aufgerufen oder mit ausdruecklichem Modus installiert. Ohne das brach die
# Die Serverhaelfte des Lizenzprotokolls -- ausstellen, erneuern, antworten -- # Aktualisierung mit "Permission denied" ab (und rollte zurueck).
# gehoert ausschliesslich zum Lizenzserver. Ein Client verifiziert Lizenzen und bash "$ROOT/deploy/tesm-venv" "$ROOT" "$APP" --immer
# stellt Anfragen; den Code zum Ausstellen soll er nicht einmal mitbringen.
if [[ "$APP" == "tesm-license" ]]; then
[[ -d "$ROOT/packages/tesm-licensing-server" ]] ||
fail "packages/tesm-licensing-server fehlt -- ohne sie kann der Lizenzserver nichts ausstellen"
PAKETE+=("$ROOT/packages/tesm-licensing-server")
fi
"$ROOT/venv/bin/python3" -m pip install --quiet "${PAKETE[@]}" "$ROOT/apps/$APP" gunicorn
if [[ "$APP" == "tesm" ]]; then
"$ROOT/venv/bin/python3" -m pip install --quiet flask-sock
fi
info "Abhaengigkeiten installiert" info "Abhaengigkeiten installiert"
# -- Statische Dateien fuer nginx -------------------------------------------- # -- Statische Dateien fuer nginx --------------------------------------------
@@ -388,6 +474,12 @@ install_if_changed() {
install_if_changed "$ROOT/deploy/tesm-helper" /usr/local/lib/tesm/tesm-helper 0755 && \ install_if_changed "$ROOT/deploy/tesm-helper" /usr/local/lib/tesm/tesm-helper 0755 && \
info "Helfer aktualisiert" || info "Helfer unveraendert" info "Helfer aktualisiert" || info "Helfer unveraendert"
# Derselbe Baustein, der oben die Umgebung gebaut hat: die Dienste rufen ihn
# vor jedem Start auf und reparieren damit, was ein Release-Upgrade des
# Betriebssystems ihnen unter den Fuessen weggezogen hat.
install_if_changed "$ROOT/deploy/tesm-venv" /usr/local/lib/tesm/tesm-venv 0755 >/dev/null \
&& info "Umgebungspfleger aktualisiert" || true
# visudo -c prueft die Datei, bevor sie aktiv wird -- eine kaputte sudoers-Datei # visudo -c prueft die Datei, bevor sie aktiv wird -- eine kaputte sudoers-Datei
# waere sonst ein Totalausfall der Administration. # waere sonst ein Totalausfall der Administration.
TMP_SUDO="$(mktemp)" TMP_SUDO="$(mktemp)"
@@ -516,6 +608,11 @@ render_unit "$ROOT/deploy/systemd/$APP.service" "/etc/systemd/system/$UNIT"
if [[ "$APP" == "tesm" ]]; then if [[ "$APP" == "tesm" ]]; then
render_unit "$ROOT/deploy/systemd/tesm-monitor.service" "/etc/systemd/system/$MONITOR_UNIT" render_unit "$ROOT/deploy/systemd/tesm-monitor.service" "/etc/systemd/system/$MONITOR_UNIT"
fi fi
# Aufraeumen lief bisher nur von Hand -- also praktisch nie. Auf einer
# laufenden Installation lagen deshalb hunderte abgelaufene Sitzungszeilen in
# der Datenbank, und das Aenderungsprotokoll waere nie archiviert worden.
render_unit "$ROOT/deploy/systemd/$APP-cleanup.service" "/etc/systemd/system/$CLEANUP_UNIT"
render_unit "$ROOT/deploy/systemd/$APP-cleanup.timer" "/etc/systemd/system/$CLEANUP_TIMER"
[[ $UNITS_CHANGED -eq 1 ]] && systemctl daemon-reload && info "Units neu geladen" \ [[ $UNITS_CHANGED -eq 1 ]] && systemctl daemon-reload && info "Units neu geladen" \
|| info "Units unveraendert" || info "Units unveraendert"
@@ -588,6 +685,8 @@ systemctl enable --now "$UNIT" >/dev/null
if [[ "$APP" == "tesm" ]]; then if [[ "$APP" == "tesm" ]]; then
systemctl enable --now "$MONITOR_UNIT" >/dev/null systemctl enable --now "$MONITOR_UNIT" >/dev/null
fi fi
# Der Timer, nicht der Dienst: der Dienst laeuft einmal und ist fertig.
systemctl enable --now "$CLEANUP_TIMER" >/dev/null 2>&1 && info "Aufraeumen: taeglich 03:30" || info "Aufraeum-Timer nicht aktiviert -- 'systemctl status $CLEANUP_TIMER' zeigt warum"
# -- Health-Check und Rollback ---------------------------------------------- # -- Health-Check und Rollback ----------------------------------------------
step "Gesundheitspruefung" step "Gesundheitspruefung"
@@ -597,7 +696,7 @@ for attempt in $(seq 1 20); do
# pruefen ging schon einmal schief -- auf einem gemeinsamen Port antwortete # pruefen ging schon einmal schief -- auf einem gemeinsamen Port antwortete
# eine andere Instanz, und ein toter Dienst galt als gesund. # eine andere Instanz, und ein toter Dienst galt als gesund.
if systemctl is-active --quiet "$UNIT" && if systemctl is-active --quiet "$UNIT" &&
curl -fsS --max-time 3 "http://127.0.0.1:$PORT/gesundheit" >/dev/null 2>&1; then curl -fsS --max-time 3 "http://127.0.0.1:$PORT/health" >/dev/null 2>&1; then
HEALTHY=1 HEALTHY=1
info "Antwort nach ${attempt}x" info "Antwort nach ${attempt}x"
break break
@@ -676,10 +775,6 @@ fi
# gleich die eigene Master-Lizenz. Bei einer Aktualisierung nichts davon -- # gleich die eigene Master-Lizenz. Bei einer Aktualisierung nichts davon --
# ein bestehender Schluessel darf nie angetastet werden. # ein bestehender Schluessel darf nie angetastet werden.
if [[ "$APP" == "tesm-license" && $IS_UPDATE -eq 0 ]]; then if [[ "$APP" == "tesm-license" && $IS_UPDATE -eq 0 ]]; then
# Das Datenverzeichnis steht in der Umgebungsdatei -- es muss nicht
# $ROOT/data sein, wenn jemand es dort umgestellt hat.
DATEN_DIR="$(sed -n "s/^${ENV_PREFIX}DATA_DIR=//p" "$ENV_FILE" | tail -1)"
DATEN_DIR="${DATEN_DIR:-$ROOT/data}"
step "Signaturschluessel" step "Signaturschluessel"
if sudo -u "$SERVICE_USER" "/usr/local/bin/$NAME-admin" init-key; then if sudo -u "$SERVICE_USER" "/usr/local/bin/$NAME-admin" init-key; then
info "Bitte die Datei $DATEN_DIR/master_signing_key.json SOFORT sichern." info "Bitte die Datei $DATEN_DIR/master_signing_key.json SOFORT sichern."
+34
View File
@@ -0,0 +1,34 @@
[Unit]
Description=TESM -- Aufraeumen: Sitzungen, Ratenereignisse, Protokollarchiv
After=tesm.service
# Ohne die laufende Anwendung ist nichts kaputt -- der Befehl arbeitet direkt
# auf der Datenbank. "After" sorgt nur fuer eine sinnvolle Reihenfolge beim
# Hochfahren.
[Service]
Type=oneshot
User=tesm
Group=tesm
WorkingDirectory=/srv/tesm
Environment=TESM_BASE_DIR=/srv/tesm
Environment=TESM_DATA_DIR=/srv/tesm/data
Environment=TESM_LOG_DIR=/var/log/tesm
Environment=PYTHONUNBUFFERED=1
ExecStart=/srv/tesm/venv/bin/tesm-admin housekeeping
# Hier darf voll gehaertet werden: dieser Aufruf fasst nur die Datenbank und
# das Protokollverzeichnis an, keine Systemdateien und keinen Helfer per sudo.
NoNewPrivileges=yes
PrivateTmp=yes
ProtectSystem=strict
ProtectHome=yes
ProtectKernelTunables=yes
ProtectKernelModules=yes
ProtectControlGroups=yes
RestrictSUIDSGID=yes
RestrictNamespaces=yes
RestrictAddressFamilies=AF_UNIX
LockPersonality=yes
SystemCallArchitectures=native
UMask=0027
ReadWritePaths=/srv/tesm/data /var/log/tesm
+18
View File
@@ -0,0 +1,18 @@
[Unit]
Description=TESM -- taegliches Aufraeumen
[Timer]
# Nachts, wenn niemand arbeitet: abgelaufene Sitzungen, alte Ratenereignisse
# und die Archivierung des Aenderungsprotokolls.
#
# Vorher lief das nur, wenn jemand "tesm-admin housekeeping" aufrief oder in
# der Diagnose darauf klickte -- also praktisch nie. Gemessen auf einer
# Installation nach vier Wochen: 747 Sitzungszeilen, 682 davon abgelaufen.
OnCalendar=*-*-* 03:30:00
# Streuung, damit nicht jede Installation im Netz zur selben Sekunde loslegt.
RandomizedDelaySec=15m
# Nachgeholt, wenn die Maschine nachts aus war.
Persistent=true
[Install]
WantedBy=timers.target
@@ -0,0 +1,34 @@
[Unit]
Description=TESM-Lizenzserver -- Aufraeumen: Sitzungen, Ratenereignisse, Protokollarchiv
After=tesm-license.service
# Ohne die laufende Anwendung ist nichts kaputt -- der Befehl arbeitet direkt
# auf der Datenbank. "After" sorgt nur fuer eine sinnvolle Reihenfolge beim
# Hochfahren.
[Service]
Type=oneshot
User=tesm-license
Group=tesm-license
WorkingDirectory=/srv/tesm-license
Environment=TESM_LICENSE_BASE_DIR=/srv/tesm-license
Environment=TESM_LICENSE_DATA_DIR=/srv/tesm-license/data
Environment=TESM_LICENSE_LOG_DIR=/var/log/tesm-license
Environment=PYTHONUNBUFFERED=1
ExecStart=/srv/tesm-license/venv/bin/tesm-license-admin housekeeping
# Hier darf voll gehaertet werden: dieser Aufruf fasst nur die Datenbank und
# das Protokollverzeichnis an, keine Systemdateien und keinen Helfer per sudo.
NoNewPrivileges=yes
PrivateTmp=yes
ProtectSystem=strict
ProtectHome=yes
ProtectKernelTunables=yes
ProtectKernelModules=yes
ProtectControlGroups=yes
RestrictSUIDSGID=yes
RestrictNamespaces=yes
RestrictAddressFamilies=AF_UNIX
LockPersonality=yes
SystemCallArchitectures=native
UMask=0027
ReadWritePaths=/srv/tesm-license/data /var/log/tesm-license
+18
View File
@@ -0,0 +1,18 @@
[Unit]
Description=TESM-Lizenzserver -- taegliches Aufraeumen
[Timer]
# Nachts, wenn niemand arbeitet: abgelaufene Sitzungen, alte Ratenereignisse
# und die Archivierung des Aenderungsprotokolls.
#
# Vorher lief das nur, wenn jemand "tesm-license-admin housekeeping" aufrief oder in
# der Diagnose darauf klickte -- also praktisch nie. Gemessen auf einer
# Installation nach vier Wochen: 747 Sitzungszeilen, 682 davon abgelaufen.
OnCalendar=*-*-* 03:30:00
# Streuung, damit nicht jede Installation im Netz zur selben Sekunde loslegt.
RandomizedDelaySec=15m
# Nachgeholt, wenn die Maschine nachts aus war.
Persistent=true
[Install]
WantedBy=timers.target
+12
View File
@@ -15,11 +15,23 @@ Environment=TESM_LICENSE_LOG_DIR=/var/log/tesm-license
Environment=TESM_LICENSE_WEB_PROCESS=1 Environment=TESM_LICENSE_WEB_PROCESS=1
Environment=TESM_LICENSE_COOKIE_SECURE=1 Environment=TESM_LICENSE_COOKIE_SECURE=1
Environment=PYTHONUNBUFFERED=1 Environment=PYTHONUNBUFFERED=1
# Vor jedem Start: traegt die Python-Umgebung noch? Ein Release-Upgrade des
# Betriebssystems tauscht die Minor-Fassung von Python aus, und der venv haengt
# daran -- danach findet der Dienst kein einziges Paket mehr und scheitert mit
# "No module named gunicorn". Ist alles heil, kostet diese Zeile Millisekunden;
# sonst baut sie die Umgebung neu, einmal je Systemstart.
#
# Das "+" laesst den Schritt als root laufen. Noetig, weil der venv root
# gehoert, der Dienst aber als unprivilegierter Benutzer laeuft.
ExecStartPre=+/usr/local/lib/tesm/tesm-venv /srv/tesm-license tesm-license
ExecStart=/srv/tesm-license/venv/bin/gunicorn \ ExecStart=/srv/tesm-license/venv/bin/gunicorn \
--workers 1 --worker-class gthread --threads 8 \ --workers 1 --worker-class gthread --threads 8 \
--bind 127.0.0.1:5001 \ --bind 127.0.0.1:5001 \
--access-logfile - --error-logfile - \ --access-logfile - --error-logfile - \
tesm_license.wsgi:app tesm_license.wsgi:app
# Ein Neuaufbau der Python-Umgebung laedt Pakete und dauert Minuten --
# die Vorgabe von 90 Sekunden wuerde ihn mittendrin abschneiden.
TimeoutStartSec=900
Restart=always Restart=always
RestartSec=5 RestartSec=5
+12
View File
@@ -12,7 +12,19 @@ Environment=TESM_BASE_DIR=/srv/tesm
Environment=TESM_DATA_DIR=/srv/tesm/data Environment=TESM_DATA_DIR=/srv/tesm/data
Environment=TESM_LOG_DIR=/var/log/tesm Environment=TESM_LOG_DIR=/var/log/tesm
Environment=PYTHONUNBUFFERED=1 Environment=PYTHONUNBUFFERED=1
# Vor jedem Start: traegt die Python-Umgebung noch? Ein Release-Upgrade des
# Betriebssystems tauscht die Minor-Fassung von Python aus, und der venv haengt
# daran -- danach findet der Dienst kein einziges Paket mehr und scheitert mit
# "No module named gunicorn". Ist alles heil, kostet diese Zeile Millisekunden;
# sonst baut sie die Umgebung neu, einmal je Systemstart.
#
# Das "+" laesst den Schritt als root laufen. Noetig, weil der venv root
# gehoert, der Dienst aber als unprivilegierter Benutzer laeuft.
ExecStartPre=+/usr/local/lib/tesm/tesm-venv /srv/tesm tesm
ExecStart=/srv/tesm/venv/bin/tesm-monitor ExecStart=/srv/tesm/venv/bin/tesm-monitor
# Ein Neuaufbau der Python-Umgebung laedt Pakete und dauert Minuten --
# die Vorgabe von 90 Sekunden wuerde ihn mittendrin abschneiden.
TimeoutStartSec=900
Restart=always Restart=always
RestartSec=10 RestartSec=10
+12
View File
@@ -15,11 +15,23 @@ Environment=TESM_LOG_DIR=/var/log/tesm
Environment=TESM_WEB_PROCESS=1 Environment=TESM_WEB_PROCESS=1
Environment=TESM_COOKIE_SECURE=1 Environment=TESM_COOKIE_SECURE=1
Environment=PYTHONUNBUFFERED=1 Environment=PYTHONUNBUFFERED=1
# Vor jedem Start: traegt die Python-Umgebung noch? Ein Release-Upgrade des
# Betriebssystems tauscht die Minor-Fassung von Python aus, und der venv haengt
# daran -- danach findet der Dienst kein einziges Paket mehr und scheitert mit
# "No module named gunicorn". Ist alles heil, kostet diese Zeile Millisekunden;
# sonst baut sie die Umgebung neu, einmal je Systemstart.
#
# Das "+" laesst den Schritt als root laufen. Noetig, weil der venv root
# gehoert, der Dienst aber als unprivilegierter Benutzer laeuft.
ExecStartPre=+/usr/local/lib/tesm/tesm-venv /srv/tesm tesm
ExecStart=/srv/tesm/venv/bin/gunicorn \ ExecStart=/srv/tesm/venv/bin/gunicorn \
--workers 1 --worker-class gthread --threads 16 --timeout 0 \ --workers 1 --worker-class gthread --threads 16 --timeout 0 \
--bind 127.0.0.1:5000 \ --bind 127.0.0.1:5000 \
--access-logfile - --error-logfile - \ --access-logfile - --error-logfile - \
tesm.wsgi:app tesm.wsgi:app
# Ein Neuaufbau der Python-Umgebung laedt Pakete und dauert Minuten --
# die Vorgabe von 90 Sekunden wuerde ihn mittendrin abschneiden.
TimeoutStartSec=900
Restart=always Restart=always
RestartSec=5 RestartSec=5
+4
View File
@@ -100,6 +100,10 @@ check_readable_path() {
case "$1" in case "$1" in
/var/log/kea/kea-dhcp4.log) ;; /var/log/kea/kea-dhcp4.log) ;;
/var/lib/kea/kea-leases4.csv) ;; /var/lib/kea/kea-leases4.csv) ;;
# Die eigene Kea-Konfiguration: 0640 root:_kea, also fuer den
# Dienstbenutzer nicht lesbar. Die Anwendung braucht sie, um zu sehen, ob
# der Dienst ueberhaupt mit dem laeuft, was sie geschrieben hat.
/etc/kea/kea-dhcp4.conf) ;;
/etc/netplan/90-*.yaml) /etc/netplan/90-*.yaml)
check_app_key "$(basename "$1" .yaml | sed 's/^90-//')" ;; check_app_key "$(basename "$1" .yaml | sed 's/^90-//')" ;;
/etc/nginx/sites-available/*) /etc/nginx/sites-available/*)
+16
View File
@@ -150,6 +150,22 @@ def main(argv: list[str] | None = None) -> int:
globals().update(AMBER="", DIM="", BOLD="", RESET="") globals().update(AMBER="", DIM="", BOLD="", RESET="")
root = Path(args.root) root = Path(args.root)
# Gibt es diese Installation ueberhaupt noch? Wenn nicht, ist hier nichts
# zu melden -- auch kein Fehler.
#
# Der Hook unter /etc/update-motd.d bleibt liegen, wenn jemand eine
# Installation von Hand entfernt; einen Rueckbau bringt TESM nicht mit.
# Vorher druckte jeder dieser Hooks trotzdem seinen vollen Banner und
# darunter "Datenbank nicht lesbar". Auf dem Testserver standen so
# vierzehn Bloecke fuer vierzehn laengst geloeschte Probeinstallationen
# vor jeder Anmeldung -- und der echte Banner ging darin unter.
#
# Eine entfernte Installation ist keine kaputte. Eine Datenbank, die da
# ist und sich nicht lesen laesst, dagegen schon: die meldet sich weiter.
if not (root / "data" / "app.db").is_file():
return 0
# Kein isatty()-Test: dieses Skript schreibt beim Login nicht auf ein # Kein isatty()-Test: dieses Skript schreibt beim Login nicht auf ein
# Terminal, sondern ueber run-parts in eine Datei. Der Betrachter ist # Terminal, sondern ueber run-parts in eine Datei. Der Betrachter ist
# trotzdem ein echtes Terminal -- deshalb immer Farbe. # trotzdem ein echtes Terminal -- deshalb immer Farbe.
+173
View File
@@ -0,0 +1,173 @@
#!/usr/bin/env bash
# =============================================================================
# Die Python-Umgebung einer Installation -- aufbauen, pruefen, reparieren.
#
# tesm-venv /srv/tesm tesm # reparieren, falls noetig (Dienststart)
# tesm-venv /srv/tesm tesm --immer # neu bestuecken (Installation)
# tesm-venv /srv/tesm tesm --pruefen # nur melden: 0 heil, 1 kaputt
#
# **Warum es das gibt.** Ein venv haengt an der Minor-Fassung von Python: die
# Pakete liegen unter lib/python3.X. Ein Release-Upgrade des Betriebssystems
# tauscht diese Fassung aus (Ubuntu 24.04 bringt 3.12, 26.04 bringt 3.14) --
# und danach zeigt venv/bin/python3 weiter auf /usr/bin/python3, also auf den
# **neuen** Interpreter. Der venv ist ausfuehrbar und trotzdem tot: er findet
# kein einziges Paket mehr, nicht einmal pip. Jeder Dienst scheitert mit
# "ModuleNotFoundError: No module named 'gunicorn'", nginx meldet 502.
#
# Real passiert, auf beiden Testservern, am 16.09.2026.
#
# Dieses Programm laeuft deshalb als ExecStartPre vor jedem Dienststart. Ist
# alles heil, endet es nach Millisekunden. Sonst baut es die Umgebung neu --
# aus denselben Paketen, die auch die Installation einspielt, denn es ist
# dasselbe Programm: install.sh ruft es mit --immer auf. Zwei getrennte Listen
# waeren zwei Listen, die auseinanderlaufen.
#
# **Was es nicht tut:** die alten Pakete unter lib/python3.X uebernehmen.
# cryptography, argon2-cffi und bcrypt sind kompiliert und fuer die alte
# Fassung gebaut; ein halb uebernommener venv waere schlimmer als ein neuer.
# =============================================================================
set -euo pipefail
# **Der venv muss fuer den Dienstbenutzer lesbar sein.**
#
# Dieses Programm laeuft als ExecStartPre der Dienst-Unit -- und erbt damit
# deren UMask=0027. Alles, was es anlegt, gehoerte root und waere 0750: der
# Dienst selbst laeuft unprivilegiert und koennte sein eigenes site-packages
# nicht einmal betreten. Gemessen: nach der Reparatur ging "import gunicorn"
# als root, als Benutzer tesm nicht -- die Reparatur baute etwas, das genau
# der Prozess nicht benutzen konnte, fuer den sie gedacht war.
#
# Im venv liegt nichts Geheimes; Schluessel und Datenbank stehen woanders.
umask 022
ROOT="${1:-}"
APP="${2:-}"
MODUS="reparieren"
[[ "${3:-}" == "--immer" ]] && MODUS="immer"
[[ "${3:-}" == "--pruefen" ]] && MODUS="pruefen"
[[ -n "$ROOT" && -n "$APP" ]] || {
echo "Aufruf: tesm-venv <verzeichnis> <tesm|tesm-license> [--immer|--pruefen]" >&2
exit 2
}
[[ "$APP" == "tesm" || "$APP" == "tesm-license" ]] || {
echo "tesm-venv: unbekannte Anwendung '$APP'" >&2
exit 2
}
[[ -d "$ROOT" ]] || { echo "tesm-venv: $ROOT gibt es nicht" >&2; exit 2; }
NAME="$(basename "$ROOT")"
VENV="$ROOT/venv"
PY="$VENV/bin/python3"
MODUL="tesm"; [[ "$APP" == "tesm-license" ]] && MODUL="tesm_license"
LOG_DIR="${TESM_LOG_DIR:-/var/log/$NAME}"
#: Ein Versuch je Systemstart. /run ist ein tmpfs, der Merker verschwindet
#: also beim naechsten Start von selbst. Ohne ihn wuerde "Restart=always" nach
#: einem misslungenen Neuaufbau (kein Netz, kein PyPI) alle fuenf Sekunden
#: einen neuen pip-Lauf starten -- gegen einen Server, der ohnehin gerade
#: nicht erreichbar ist.
MERKER="/run/tesm-venv-$NAME.versucht"
melden() {
local stufe="$1" text="$2"
# Ins Journal: ExecStartPre-Ausgabe landet dort ohnehin.
printf 'tesm-venv: %s\n' "$text" >&2
# Und ins laufende Protokoll, damit es in der Oberflaeche sichtbar ist.
# Fehlschlaege hier duerfen den Start nicht kosten -- ein Protokoll ist
# kein Grund, einen Dienst nicht zu starten.
if [[ -d "$LOG_DIR" ]]; then
printf '%s %-7s %-10s %s\n' "$(date '+%Y-%m-%d %H:%M:%S')" "$stufe" "venv" "$text" \
>> "$LOG_DIR/live.log" 2>/dev/null || true
chown "$APP:$APP" "$LOG_DIR/live.log" 2>/dev/null || true
fi
}
#: Traegt diese Umgebung? Gefragt wird nicht nach der Versionsnummer, sondern
#: nach dem, was gebraucht wird -- so faellt auch ein venv auf, dem jemand ein
#: Paket herausgeloescht hat.
traegt() {
[[ -x "$PY" ]] || return 1
"$PY" -c "import pip" >/dev/null 2>&1 || return 1
# Beim allerersten Lauf ist die Anwendung noch nicht eingespielt -- dann
# zaehlt allein, dass pip benutzbar ist.
[[ "$MODUS" == "immer" ]] && return 0
"$PY" -c "import $MODUL" >/dev/null 2>&1
}
bestuecken() {
# **Jeder pip-Lauf wird einzeln geprueft.**
#
# "set -e" hilft hier nicht: diese Funktion wird in einer Bedingung
# aufgerufen ("if ! bestuecken"), und darin ist errexit fuer den **ganzen**
# Rumpf abgeschaltet. Ein gescheitertes pip lief deshalb einfach weiter bis
# zum "return 0" -- der Lauf meldete "Fertig", und im venv lagen weiter die
# alten Pakete.
#
# Real passiert: der Lizenzserver blieb ueber drei Auslieferungen hinweg auf
# 2.8.16 stehen, weil eine Abhaengigkeit nicht aufloesbar war. Gesehen hat es
# niemand, denn die Fehlerzeile von pip ging im Lauf unter und der
# Ausstiegscode war 0.
"$PY" -m pip install --quiet --upgrade pip setuptools wheel || return 1
local pakete=("$ROOT/packages/tesm-licensing" "$ROOT/packages/tesm-core")
if [[ "$APP" == "tesm-license" ]]; then
[[ -d "$ROOT/packages/tesm-licensing-server" ]] || {
echo "tesm-venv: packages/tesm-licensing-server fehlt" >&2
return 1
}
pakete+=("$ROOT/packages/tesm-licensing-server")
fi
"$PY" -m pip install --quiet "${pakete[@]}" "$ROOT/apps/$APP" gunicorn || return 1
# Die WebSocket-Bruecke braucht nur TESM (Terminal und Bildschirm).
if [[ "$APP" == "tesm" ]]; then
"$PY" -m pip install --quiet flask-sock || return 1
fi
return 0
}
# --------------------------------------------------------------------------- #
if [[ "$MODUS" == "pruefen" ]]; then
traegt && exit 0 || exit 1
fi
if traegt && [[ "$MODUS" != "immer" ]]; then
exit 0
fi
if [[ "$MODUS" != "immer" && -e "$MERKER" ]]; then
melden "WARNUNG" \
"Die Python-Umgebung von $NAME ist unbrauchbar, und der Neuaufbau ist seit dem letzten Systemstart schon einmal gescheitert. Erneuter Versuch: 'bash $ROOT/deploy/install.sh --app $APP --yes'."
exit 1
fi
touch "$MERKER" 2>/dev/null || true
if ! [[ -x "$PY" ]] || ! "$PY" -c "import pip" >/dev/null 2>&1; then
# Ohne --clear: ein erneutes "venv" ergaenzt das fehlende lib-Verzeichnis
# und holt pip zurueck. Gemessen, nicht angenommen.
python3 -m venv "$VENV"
[[ "$MODUS" == "immer" ]] ||
melden "HINWEIS" \
"Python-Umgebung von $NAME neu aufgebaut ($(python3 -V 2>&1)) -- das System hat seine Python-Fassung gewechselt."
fi
if ! bestuecken; then
melden "FEHLER" \
"Die Pakete fuer $NAME liessen sich nicht einspielen. Hat dieser Server Zugriff auf das Paketverzeichnis?"
exit 1
fi
# Und noch einmal ausdruecklich, fuer den Fall, dass ein frueherer Lauf mit
# strengerer Maske gebaut hat: was hier liegt, muss lesbar sein. Idempotent
# und in einer Sekunde durch.
chmod -R a+rX "$VENV" 2>/dev/null || true
# Vertrauen ist gut: nach dem Einspielen muss die Anwendung auch importierbar
# sein. Ohne diese Probe hiesse "Fertig" nur, dass pip nicht geschimpft hat.
if ! "$PY" -c "import $MODUL" >/dev/null 2>&1; then
melden "FEHLER" "Die Pakete fuer $NAME wurden eingespielt, aber '$MODUL' laesst sich nicht importieren."
exit 1
fi
[[ "$MODUS" == "immer" ]] || melden "INFO" "Python-Umgebung von $NAME ist wieder vollstaendig."
rm -f "$MERKER" 2>/dev/null || true
exit 0
+320
View File
@@ -0,0 +1,320 @@
#!/usr/bin/env bash
# =============================================================================
# Rueckbau einer Installation von TESM oder TESM-Lizenzserver.
#
# Aufruf:
# sudo bash deploy/uninstall.sh --app tesm
# sudo bash deploy/uninstall.sh --app tesm-license --instance test
# sudo bash deploy/uninstall.sh --app tesm --dry-run
#
# Das genaue Gegenstueck zu install.sh -- und es fehlte. Wer eine Installation
# von Hand loeschte ("rm -rf /srv/tesm-probe"), liess zurueck: die systemd-Unit
# (die systemd danach in einer Dauerschleife neu zu starten versuchte), den
# Begruessungshaken (der bei jeder Anmeldung seinen Banner druckte), den
# Shim unter /usr/local/bin, die Umgebungsdatei, die nginx-Site und den
# Dienstbenutzer. Auf dem Testserver standen so neun tote Dienste und vierzehn
# Banner. Genau das raeumt dieses Skript ab.
#
# Zwei Regeln, die den Unterschied machen:
#
# * **Daten bleiben.** Datenbank, Schluessel und Lizenz werden vorher in ein
# Archiv gelegt, nicht geloescht. Wer sie wirklich los sein will, sagt es
# mit --purge. Ein Rueckbau, der beilaeufig die Datenbank mitnimmt, ist
# keiner, sondern ein Unfall.
# * **Geteiltes bleibt, solange es jemand braucht.** sudoers-Datei und
# Dienstbenutzer gehoeren der *Anwendung* (nicht der Instanz), der Helfer
# unter /usr/local/lib/tesm allen Installationen beider Anwendungen. Sie
# verschwinden erst, wenn die letzte geht -- sonst legt der Rueckbau einer
# Testinstanz die Produktion daneben lahm.
# =============================================================================
set -euo pipefail
# Ein Lauf dieses Skripts gehoert in eine definierte Sprachumgebung -- nicht in
# die, die das Terminal des Administrators zufaellig ankuendigt. Per SSH kommt
# LC_ALL mit (sshd: "AcceptEnv LANG LC_*"), und ist diese Locale auf dem Ziel
# nicht erzeugt, warnt **jede neu gestartete Shell**:
#
# bash: warning: setlocale: LC_ALL: cannot change locale (en_US.UTF-8)
#
# Mitten in einer Aktualisierung sieht das nach einem Fehler aus und ist
# keiner. Nebenbei sind damit auch die Meldungen der Werkzeuge, die hier
# laufen, in einer Sprache -- ein Protokoll, das je nach Arbeitsplatz anders
# klingt, liest sich schlecht.
if locale -a 2>/dev/null | grep -qix 'c\.utf-\?8'; then
export LC_ALL=C.UTF-8 LANG=C.UTF-8
else
export LC_ALL=C LANG=C
fi
APP=""
INSTANCE=""
PURGE=0
ASSUME_YES=0
DRY_RUN=0
#: Ueberschreibbar, damit sich die Entscheidungen dieses Skripts in einem Test
#: wirklich ausfuehren lassen -- und nicht nur als Textmuster geprueft werden.
ETC_TESM="${TESM_ETC_DIR:-/etc/tesm}"
SRV_DIR="${TESM_SRV_DIR:-/srv}"
UNIT_DIR="${TESM_SYSTEMD_DIR:-/etc/systemd/system}"
MOTD_DIR="${TESM_MOTD_DIR:-/etc/update-motd.d}"
BIN_DIR="${TESM_BIN_DIR:-/usr/local/bin}"
LIB_DIR="${TESM_LIB_DIR:-/usr/local/lib/tesm}"
SUDOERS_DIR="${TESM_SUDOERS_DIR:-/etc/sudoers.d}"
LOGROTATE_DIR="${TESM_LOGROTATE_DIR:-/etc/logrotate.d}"
NGINX_DIR="${TESM_NGINX_DIR:-/etc/nginx}"
LOG_BASE="${TESM_LOG_BASE:-/var/log}"
COLOR_OK=$'\033[32m'; COLOR_WARN=$'\033[33m'; COLOR_ERR=$'\033[31m'; COLOR_OFF=$'\033[0m'
step() { printf '\n%s==>%s %s\n' "$COLOR_OK" "$COLOR_OFF" "$*"; }
warn() { printf '%s !!%s %s\n' "$COLOR_WARN" "$COLOR_OFF" "$*" ; }
fail() { printf '%s !!%s %s\n' "$COLOR_ERR" "$COLOR_OFF" "$*" >&2; exit 1; }
info() { printf ' %s\n' "$*"; }
usage() {
cat <<'TEXT'
Rueckbau einer Installation.
--app <tesm|tesm-license> welche Anwendung (Pflicht)
--instance <name> benannte Instanz (sonst die Hauptinstallation)
--purge Daten nicht sichern, sondern loeschen
--dry-run nur zeigen, was geschehen wuerde
--yes, -y nicht nachfragen
TEXT
}
while [[ $# -gt 0 ]]; do
case "$1" in
--app) APP="${2:?}"; shift 2 ;;
--instance) INSTANCE="${2:?}"; shift 2 ;;
--purge) PURGE=1; shift ;;
--dry-run) DRY_RUN=1; shift ;;
--yes|-y) ASSUME_YES=1; shift ;;
-h|--help) usage; exit 0 ;;
*) fail "unbekannte Option: $1" ;;
esac
done
[[ -n "$APP" ]] || { usage; fail "--app fehlt"; }
[[ "$APP" == "tesm" || "$APP" == "tesm-license" ]] || fail "--app muss tesm oder tesm-license sein"
[[ -z "$INSTANCE" || "$INSTANCE" =~ ^[a-z0-9-]{1,16}$ ]] || fail "ungueltiger Instanzname"
# Root braucht es wegen /srv, /etc und systemd. Wer die Pfade umbiegt (Test),
# fasst nichts davon an -- dann ist die Huerde keine Sicherheit, sondern nur im
# Weg, und ein Rueckbau, der sich nicht pruefen laesst, ist ein Risiko fuer sich.
if [[ $DRY_RUN -eq 0 && "$SRV_DIR" == "/srv" && "$(id -u)" -ne 0 ]]; then
fail "bitte mit sudo ausfuehren"
fi
NAME="$APP"
[[ -n "$INSTANCE" ]] && NAME="$APP-$INSTANCE"
ROOT="$SRV_DIR/$NAME"
# Das Datenverzeichnis kann verschoben sein -- dann steht es in der
# Umgebungsdatei, und nur dort. Ein Rueckbau, der stattdessen "$ROOT/data"
# annimmt, sichert ein leeres Verzeichnis und laesst die echte Datenbank liegen.
ENV_FILE="$ETC_TESM/$NAME.env"
DATEN_DIR="$(sed -n 's/^TESM_DATA_DIR=//p' "$ENV_FILE" 2>/dev/null | tail -1 || true)"
DATEN_DIR="${DATEN_DIR:-$ROOT/data}"
# --------------------------------------------------------------------------- #
# Wer teilt sich was?
# --------------------------------------------------------------------------- #
#: Gibt es neben dieser noch eine weitere Installation **derselben** Anwendung?
#: Daran haengen sudoers-Datei und Dienstbenutzer -- beide tragen den Namen der
#: Anwendung, nicht den der Instanz.
weitere_dieser_anwendung() {
local eintrag name
for eintrag in "$ETC_TESM/$APP.env" "$ETC_TESM/$APP-"*.env; do
[[ -f "$eintrag" ]] || continue
name="$(basename "$eintrag" .env)"
[[ "$name" == "$NAME" ]] && continue
return 0
done
return 1
}
#: Gibt es ueberhaupt noch irgendeine Installation? Daran haengen der Helfer
#: und das Banner-Programm unter /usr/local/lib/tesm -- die gehoeren allen.
weitere_ueberhaupt() {
local eintrag name
for eintrag in "$ETC_TESM"/*.env; do
[[ -f "$eintrag" ]] || continue
name="$(basename "$eintrag" .env)"
[[ "$name" == "$NAME" ]] && continue
return 0
done
return 1
}
# --------------------------------------------------------------------------- #
# Der Plan -- erst zeigen, dann tun
# --------------------------------------------------------------------------- #
UNITS=("$NAME.service" "$NAME-monitor.service" "$NAME-cleanup.service" "$NAME-cleanup.timer")
DATEIEN=(
"$NGINX_DIR/sites-enabled/$NAME"
"$NGINX_DIR/sites-available/$NAME"
"$MOTD_DIR/50-$NAME"
"$BIN_DIR/$NAME-admin"
"$LOGROTATE_DIR/$NAME"
"$ENV_FILE"
)
VERZEICHNISSE=("$ETC_TESM/certs/$NAME" "$LOG_BASE/$NAME")
gefunden=0
step "Rueckbau von $NAME"
info "Anwendung : $APP${INSTANCE:+ (Instanz $INSTANCE)}"
info "Verzeichnis : $ROOT"
echo
echo " Dienste:"
for unit in "${UNITS[@]}"; do
if [[ -f "$UNIT_DIR/$unit" ]]; then info "- $unit"; gefunden=1; fi
done
[[ $gefunden -eq 1 ]] || info "(keine)"
echo
echo " Dateien und Verzeichnisse:"
for pfad in "${DATEIEN[@]}" "${VERZEICHNISSE[@]}"; do
if [[ -e "$pfad" ]]; then info "- $pfad"; gefunden=1; fi
done
if [[ -d "$ROOT" ]]; then info "- $ROOT"; gefunden=1; fi
if [[ $gefunden -eq 0 ]]; then
echo
info "Von '$NAME' ist nichts mehr da -- nichts zu tun."
exit 0
fi
echo
echo " Daten (Datenbank, Schluessel, Lizenz):"
if [[ -d "$DATEN_DIR" ]]; then
if [[ $PURGE -eq 1 ]]; then
warn "$DATEN_DIR wird GELOESCHT (--purge)"
else
info "$DATEN_DIR wird vorher gesichert"
fi
else
info "(kein Datenverzeichnis gefunden)"
fi
echo
echo " Bleibt stehen:"
if weitere_dieser_anwendung; then
info "sudoers und Dienstbenutzer '$APP' -- eine weitere Installation braucht sie"
else
info "- (sudoers und Dienstbenutzer '$APP' werden entfernt)"
fi
if weitere_ueberhaupt; then
info "Helfer unter $LIB_DIR -- andere Installationen brauchen ihn"
else
info "- (Helfer unter $LIB_DIR wird entfernt)"
fi
if [[ $DRY_RUN -eq 1 ]]; then
echo
info "Probelauf -- es wurde nichts geaendert."
exit 0
fi
if [[ $ASSUME_YES -eq 0 ]]; then
echo
read -r -p "Fortfahren? [j/N] " antwort
[[ "$antwort" =~ ^[jJyY]$ ]] || fail "abgebrochen"
fi
# --------------------------------------------------------------------------- #
# Ausfuehren
# --------------------------------------------------------------------------- #
step "Daten"
if [[ -d "$DATEN_DIR" && $PURGE -eq 0 ]]; then
ARCHIV="$SRV_DIR/$NAME-daten-$(date +%Y%m%d-%H%M%S).tar.gz"
tar -czf "$ARCHIV" -C "$(dirname "$DATEN_DIR")" "$(basename "$DATEN_DIR")"
chmod 600 "$ARCHIV"
info "gesichert: $ARCHIV"
elif [[ -d "$DATEN_DIR" ]]; then
info "wird mit dem Verzeichnis geloescht (--purge)"
else
info "nichts zu sichern"
fi
step "Dienste"
for unit in "${UNITS[@]}"; do
[[ -f "$UNIT_DIR/$unit" ]] || continue
systemctl disable --now "$unit" >/dev/null 2>&1 || true
rm -f "$UNIT_DIR/$unit"
# Ohne das bleibt eine entfernte Unit als "not-found" im Fehlerzustand
# stehen und faerbt "systemctl --failed" rot.
systemctl reset-failed "$unit" >/dev/null 2>&1 || true
info "entfernt: $unit"
done
systemctl daemon-reload >/dev/null 2>&1 || true
step "Webserver"
if [[ -e "$NGINX_DIR/sites-enabled/$NAME" || -e "$NGINX_DIR/sites-available/$NAME" ]]; then
rm -f "$NGINX_DIR/sites-enabled/$NAME" "$NGINX_DIR/sites-available/$NAME"
# Drei Faelle, und sie bedeuten Verschiedenes: kein nginx da (dann gibt es
# nichts neu zu laden), nginx da und zufrieden, nginx da und unzufrieden.
# Sie in einen Zweig zu werfen hiesse, auf einer Maschine ohne nginx eine
# Warnung ueber eine kaputte Konfiguration auszugeben, die es nicht gibt.
if ! command -v nginx >/dev/null 2>&1; then
info "Site entfernt (nginx ist hier nicht installiert)"
elif nginx -t >/dev/null 2>&1; then
systemctl reload nginx >/dev/null 2>&1 || true
info "Site entfernt, nginx neu geladen"
else
warn "Site entfernt -- nginx beanstandet die Konfiguration, bitte 'nginx -t' ansehen"
fi
else
info "keine Site vorhanden"
fi
step "Dateien"
for pfad in "${DATEIEN[@]}"; do
[[ -e "$pfad" ]] && { rm -f "$pfad"; info "entfernt: $pfad"; }
done
for pfad in "${VERZEICHNISSE[@]}"; do
[[ -d "$pfad" ]] && { rm -rf "$pfad"; info "entfernt: $pfad"; }
done
if [[ -d "$ROOT" ]]; then
rm -rf "$ROOT"
info "entfernt: $ROOT"
fi
if [[ $PURGE -eq 1 ]]; then
# Die Rueckwege aelterer Aktualisierungen. Sie enthalten dieselben Daten wie
# die Installation -- deshalb nur hier, nie im Normalfall.
for rest in "$SRV_DIR/$NAME-backup-"* "$SRV_DIR/$NAME-failed-"*; do
[[ -d "$rest" ]] && { rm -rf "$rest"; info "entfernt: $rest"; }
done
fi
step "Geteiltes"
if weitere_dieser_anwendung; then
info "sudoers und Dienstbenutzer '$APP' bleiben -- eine weitere Installation braucht sie"
else
rm -f "$SUDOERS_DIR/$APP"
info "entfernt: $SUDOERS_DIR/$APP"
if getent passwd "$APP" >/dev/null 2>&1; then
# Ein laufender Prozess haelt den Benutzer fest. Das ist kein Grund,
# den Rueckbau scheitern zu lassen -- der Dienst ist oben schon gestoppt,
# und was sonst noch unter diesem Namen laeuft, gehoert angesehen.
if userdel "$APP" >/dev/null 2>&1; then
info "entfernt: Dienstbenutzer $APP"
else
warn "Dienstbenutzer $APP liess sich nicht entfernen -- laeuft noch etwas unter ihm?"
fi
fi
fi
if weitere_ueberhaupt; then
info "Helfer unter $LIB_DIR bleibt -- andere Installationen brauchen ihn"
else
rm -rf "$LIB_DIR"
rmdir "$ETC_TESM/certs" "$ETC_TESM" >/dev/null 2>&1 || true
info "entfernt: $LIB_DIR"
fi
step "Fertig"
info "'$NAME' ist zurueckgebaut."
if [[ $PURGE -eq 0 && -n "${ARCHIV:-}" ]]; then
info "Die Daten liegen unter $ARCHIV -- dort und sonst nirgends."
fi
+45
View File
@@ -18,6 +18,23 @@
# ============================================================================= # =============================================================================
set -euo pipefail set -euo pipefail
# Ein Lauf dieses Skripts gehoert in eine definierte Sprachumgebung -- nicht in
# die, die das Terminal des Administrators zufaellig ankuendigt. Per SSH kommt
# LC_ALL mit (sshd: "AcceptEnv LANG LC_*"), und ist diese Locale auf dem Ziel
# nicht erzeugt, warnt **jede neu gestartete Shell**:
#
# bash: warning: setlocale: LC_ALL: cannot change locale (en_US.UTF-8)
#
# Mitten in einer Aktualisierung sieht das nach einem Fehler aus und ist
# keiner. Nebenbei sind damit auch die Meldungen der Werkzeuge, die hier
# laufen, in einer Sprache -- ein Protokoll, das je nach Arbeitsplatz anders
# klingt, liest sich schlecht.
if locale -a 2>/dev/null | grep -qix 'c\.utf-\?8'; then
export LC_ALL=C.UTF-8 LANG=C.UTF-8
else
export LC_ALL=C LANG=C
fi
APP="${1:-}" APP="${1:-}"
TAG="${2:-latest}" TAG="${2:-latest}"
@@ -33,6 +50,8 @@ EXTRA=("$@")
echo "Verwendung: update.sh <tesm|tesm-license> [tag] [install.sh-Argumente ...]" >&2 echo "Verwendung: update.sh <tesm|tesm-license> [tag] [install.sh-Argumente ...]" >&2
echo "Beispiel : update.sh tesm latest --instance test" >&2 echo "Beispiel : update.sh tesm latest --instance test" >&2
echo "Ohne Rueckfrage: --yes anhaengen (ohne Terminal zwingend)." >&2 echo "Ohne Rueckfrage: --yes anhaengen (ohne Terminal zwingend)." >&2
echo "Laeuft die Fassung schon, endet der Aufruf ohne Arbeit; --force setzt" >&2
echo "sie trotzdem neu auf." >&2
exit 2 exit 2
} }
@@ -173,6 +192,32 @@ release_holen() {
release_holen release_holen
# Schon installiert? Dann gar nicht erst fragen.
#
# Dieselbe Pruefung steht in install.sh und gilt dort endgueltig (sie
# vergleicht auch den Kern). Hier steht sie noch einmal, weil ein Update, das
# ohne Wirkung bleibt, gar nicht erst nach einer Bestaetigung fragen soll --
# und weil "es lief durch" sonst wie eine Aktualisierung aussieht.
ERZWINGEN=0
for ((i = 0; i < ${#EXTRA[@]}; i++)); do
[[ "${EXTRA[i]}" == "--force" ]] && ERZWINGEN=1
done
INSTALLATION="$APP"
for ((i = 0; i < ${#EXTRA[@]}; i++)); do
[[ "${EXTRA[i]}" == "--instance" ]] && INSTALLATION="$APP-${EXTRA[i + 1]:-}"
done
MODUL="tesm"
[[ "$APP" == "tesm-license" ]] && MODUL="tesm_license"
if [[ $ERZWINGEN -eq 0 && -x "/srv/$INSTALLATION/venv/bin/python" ]]; then
LAEUFT="$("/srv/$INSTALLATION/venv/bin/python" -c \
"import $MODUL; print($MODUL.__version__)" 2>/dev/null || true)"
if [[ -n "$LAEUFT" && "v$LAEUFT" == "$TAG" ]]; then
echo "==> $APP $LAEUFT laeuft hier bereits -- nichts zu tun."
echo " Trotzdem neu einrichten: --force"
exit 0
fi
fi
# --yes darf auch hier stehen, nicht nur bei install.sh und upgrade.sh. Es # --yes darf auch hier stehen, nicht nur bei install.sh und upgrade.sh. Es
# bleibt in EXTRA: install.sh bekommt es ohnehin, doppelt schadet nicht. # bleibt in EXTRA: install.sh bekommt es ohnehin, doppelt schadet nicht.
OHNE_RUECKFRAGE=0 OHNE_RUECKFRAGE=0
+90 -1
View File
@@ -120,6 +120,14 @@ sudo bash /srv/tesm/deploy/update.sh tesm
sudo bash /srv/tesm-license/deploy/update.sh tesm-license sudo bash /srv/tesm-license/deploy/update.sh tesm-license
``` ```
**Läuft die Fassung schon, passiert nichts.** `update.sh` vergleicht den
aufgelösten Tag mit dem, was installiert ist, und endet mit `0`, bevor es eine
Rückfrage stellt; `install.sh` prüft es noch einmal genauer (Anwendung **und**
Kern) und bricht vor dem ersten Eingriff ab. Ein zweiter Lauf auf dieselbe
Fassung baut sonst die Python-Umgebung neu, kopiert dieselben Dateien und
startet den Dienst neu -- ein paar Sekunden Ausfall ohne jede Wirkung. Wer das
trotzdem will (eine beschädigte Installation etwa), hängt `--force` an.
Alles nach dem Tag geht unverändert an `install.sh` weiter -- das ist der Weg, Alles nach dem Tag geht unverändert an `install.sh` weiter -- das ist der Weg,
eine **benannte Instanz** zu aktualisieren: eine **benannte Instanz** zu aktualisieren:
@@ -375,6 +383,87 @@ sudo bash /srv/tesm/deploy/update.sh tesm
--- ---
## 4bb. Release-Upgrade des Betriebssystems
Ein `do-release-upgrade` tauscht die Minor-Fassung von Python aus (24.04 bringt
3.12, 26.04 bringt 3.14). Der venv einer Installation hängt daran: seine Pakete
liegen unter `lib/python3.12`, während `venv/bin/python3` auf `/usr/bin/python3`
zeigt — also nach dem Upgrade auf den **neuen** Interpreter. Er ist ausführbar
und trotzdem tot; jeder Dienst scheitert mit `No module named gunicorn`, nginx
meldet 502.
**Das repariert TESM seit 2.8.33 von selbst.** Vor jedem Dienststart läuft
```
ExecStartPre=+/usr/local/lib/tesm/tesm-venv /srv/tesm tesm
```
Ist die Umgebung heil, kostet das Millisekunden. Sonst wird sie neu gebaut und
mit denselben Paketen bestückt, die auch die Installation einspielt — es ist
dasselbe Programm, `install.sh` ruft es mit `--immer` auf. Nach dem Neustart,
der auf jedes Release-Upgrade folgt, kommt der Dienst also von allein zurück;
im Live-Protokoll steht dann eine Zeile als Hinweis.
Von Hand prüfen oder anstoßen:
```bash
/usr/local/lib/tesm/tesm-venv /srv/tesm tesm --pruefen # 0 heil, 1 kaputt
/usr/local/lib/tesm/tesm-venv /srv/tesm tesm # reparieren
```
**Grenzen.** Der Neuaufbau lädt Pakete und braucht Zugriff auf das
Paketverzeichnis. Ohne Netz kann er nicht gelingen: dann wird **ein** Versuch je
Systemstart unternommen (Merker unter `/run`), und es bleibt bei einer klaren
Zeile im Journal und im Live-Protokoll. Ohne diese Bremse startete
`Restart=always` alle fünf Sekunden einen neuen `pip`-Lauf.
Die alten Pakete unter `lib/python3.<alt>` werden nicht übernommen —
`cryptography`, `argon2-cffi` und `bcrypt` sind kompiliert und für die alte
Fassung gebaut.
Empfohlene Reihenfolge bleibt trotzdem: erst TESM aktualisieren, dann
`do-release-upgrade`, dann prüfen.
---
## 4c. Installation entfernen
Das Gegenstück zu `install.sh`:
```bash
sudo bash /srv/tesm/deploy/uninstall.sh --app tesm --dry-run
sudo bash /srv/tesm/deploy/uninstall.sh --app tesm
sudo bash /srv/tesm/deploy/uninstall.sh --app tesm-license --instance test --yes
```
Entfernt werden Dienste (`.service`, `-monitor`, `-cleanup` samt Timer), die
nginx-Site, der Begrüßungshaken, der Shim unter `/usr/local/bin`, die
Umgebungsdatei, das Zertifikatsverzeichnis, das Protokollverzeichnis und die
Installation selbst.
**Die Daten bleiben.** Datenbank, Schlüssel und Lizenz gehen vorher in ein
Archiv (`/srv/<name>-daten-<zeit>.tar.gz`, nur root). Sie sind das einzige an
einer Installation, was sich nicht wiederherstellen lässt. Wer sie wirklich
los sein will, nimmt `--purge` — das löscht zusätzlich die Rückwege älterer
Aktualisierungen (`/srv/<name>-backup-*`).
**Geteiltes bleibt, solange es jemand braucht.** Die sudoers-Datei und der
Dienstbenutzer tragen den Namen der *Anwendung*, nicht den der Instanz; der
Helfer unter `/usr/local/lib/tesm` gehört allen Installationen beider
Anwendungen. Sie verschwinden erst mit der letzten. Sonst legte der Rückbau
einer Testinstanz die Produktion daneben lahm.
`--dry-run` zeigt den Plan, ohne etwas zu ändern. Ein zweiter Lauf auf eine
bereits entfernte Installation sagt das und endet mit 0.
Warum es das gibt: wer eine Installation von Hand löscht (`rm -rf /srv/…`),
lässt Unit, Begrüßungshaken, Shim, sudoers-Datei und nginx-Site zurück.
systemd versucht die Unit danach in einer Dauerschleife zu starten, und der
Haken druckt bei jeder Anmeldung seinen Banner. Auf dem Testserver standen so
neun tote Dienste und vierzehn Banner.
---
## 5. Dienste ## 5. Dienste
```bash ```bash
@@ -446,7 +535,7 @@ Unverzichtbar:
**Die Anwendung antwortet nicht.** **Die Anwendung antwortet nicht.**
```bash ```bash
journalctl -u tesm -n 200 --no-pager journalctl -u tesm -n 200 --no-pager
curl -s http://127.0.0.1:5000/gesundheit curl -s http://127.0.0.1:5000/health
``` ```
Antwortet gunicorn, aber der Browser nicht, liegt es an nginx. Antwortet gunicorn, aber der Browser nicht, liegt es an nginx.
+5
View File
@@ -11,6 +11,7 @@ Was geschützt wird, womit, und wo bewusst Grenzen gezogen sind.
| Speicherung | Argon2id (argon2-cffi) | | Speicherung | Argon2id (argon2-cffi) |
| Altbestand | bcrypt wird noch geprüft und bei der nächsten erfolgreichen Anmeldung stillschweigend auf Argon2id umgestellt | | Altbestand | bcrypt wird noch geprüft und bei der nächsten erfolgreichen Anmeldung stillschweigend auf Argon2id umgestellt |
| Zweiter Faktor | TOTP, optional erzwingbar für Administratoren | | Zweiter Faktor | TOTP, optional erzwingbar für Administratoren |
| Rechte | Eine Zeile je Funktion; das Administratorkennzeichen gibt den Verwaltungsbereich, nicht alles |
| Sperre je Konto | nach `LOGIN_MAX_ATTEMPTS` Versuchen | | Sperre je Konto | nach `LOGIN_MAX_ATTEMPTS` Versuchen |
| Sperre je Adresse | zusätzlich `LOGIN_MAX_ATTEMPTS_PER_IP` -- sonst lässt sich die Kontosperre umgehen, indem man viele Konten je einmal probiert | | Sperre je Adresse | zusätzlich `LOGIN_MAX_ATTEMPTS_PER_IP` -- sonst lässt sich die Kontosperre umgehen, indem man viele Konten je einmal probiert |
| Verzeichnisdienst | LDAP nur über TLS, Zertifikat wird geprüft. Ohne gültiges Zertifikat keine Anmeldung -- kein Schalter, der das abschaltet | | Verzeichnisdienst | LDAP nur über TLS, Zertifikat wird geprüft. Ohne gültiges Zertifikat keine Anmeldung -- kein Schalter, der das abschaltet |
@@ -171,6 +172,10 @@ braucht der Dienst kein `--workers 1` mehr.
* **Kein CSRF-Freibrief für cookie-autorisierte Routen.** * **Kein CSRF-Freibrief für cookie-autorisierte Routen.**
* **Kein `unsafe-inline` für Skripte**, auch nicht "vorübergehend". * **Kein `unsafe-inline` für Skripte**, auch nicht "vorübergehend".
* **Keine Rechteausweitung**, auch nicht für Administratoren untereinander. * **Keine Rechteausweitung**, auch nicht für Administratoren untereinander.
Ein echter Administrator darf sich Funktionsrechte über eine Gruppe geben --
das ist gewollt und hinterlässt eine Zeile im Änderungsprotokoll. Was es
nicht gibt, ist der stille Weg: ein Kennzeichen, das ohne Eintrag alles
aufschließt.
* **Kein Hostname im Lizenz-Fingerabdruck** -- Umbenennen darf die Bindung * **Kein Hostname im Lizenz-Fingerabdruck** -- Umbenennen darf die Bindung
nicht brechen. nicht brechen.
* **Keine von Hand gepflegte nginx-Site.** Sie wird immer neu erzeugt. * **Keine von Hand gepflegte nginx-Site.** Sie wird immer neu erzeugt.
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project] [project]
name = "tesm-core" name = "tesm-core"
version = "2.1.1" version = "2.8.22"
description = "Gemeinsames Anwendungsgeruest (Auth, RBAC, Navigation, Design-System, Migrationen) fuer TESM und TESM-Lizenzserver" description = "Gemeinsames Anwendungsgeruest (Auth, RBAC, Navigation, Design-System, Migrationen) fuer TESM und TESM-Lizenzserver"
requires-python = ">=3.11" requires-python = ">=3.11"
dependencies = [ dependencies = [
+76 -10
View File
@@ -13,10 +13,12 @@ import secrets
from datetime import datetime, timezone from datetime import datetime, timezone
from typing import Any, Sequence from typing import Any, Sequence
from flask import Blueprint, Flask, g, render_template, request from urllib.parse import urlencode
from flask import Blueprint, Flask, g, redirect, render_template, request
from werkzeug.exceptions import HTTPException from werkzeug.exceptions import HTTPException
from . import audit, logging_setup, ui from . import audit, legacy_urls, logging_setup, ui
from .config import CoreConfig from .config import CoreConfig
from .core_schema import CORE_MIGRATIONS from .core_schema import CORE_MIGRATIONS
from .db import Database from .db import Database
@@ -31,6 +33,7 @@ from .security import csrf
from .security import sessions as session_store from .security import sessions as session_store
from .security.sessions import SqliteSessionInterface from .security.sessions import SqliteSessionInterface
from .settings_store import SettingSpec, SettingsStore from .settings_store import SettingSpec, SettingsStore
from . import uploads
from .sysops import SysOps from .sysops import SysOps
from .auth import directory from .auth import directory
from .auth import routes as auth_routes from .auth import routes as auth_routes
@@ -56,18 +59,29 @@ CORE_SETTINGS: tuple[SettingSpec, ...] = (
30, 30,
"int", "int",
"Aufbewahrung im Papierkorb (Tage)", "Aufbewahrung im Papierkorb (Tage)",
hint="Nach dieser Frist werden Eintraege beim naechsten Aufruf endgueltig entfernt.", hint="Nach dieser Frist werden Einträge beim nächsten Aufruf endgültig entfernt.",
section="system", section="system",
minimum=1, minimum=1,
maximum=3650, maximum=3650,
), ),
SettingSpec(
"session_absolute_hours",
12,
"int",
"Längstmögliche Sitzungsdauer (Stunden)",
hint="Danach ist Schluss, auch bei ständiger Arbeit – eine Sitzung soll nicht "
"ewig leben. Die eingebundenen Dateifreigaben enden mit ihr.",
section="system",
minimum=1,
maximum=168,
),
SettingSpec( SettingSpec(
"session_idle_minutes", "session_idle_minutes",
30, 30,
"int", "int",
"Automatische Abmeldung nach Leerlauf (Minuten)", "Automatische Abmeldung nach Leerlauf (Minuten)",
hint="Jede Anfrage setzt den Zaehler zurueck -- gezaehlt wird nur Untaetigkeit. " hint="Jede Anfrage setzt den Zähler zurück – gezählt wird nur Untätigkeit. "
"Gilt auch fuer die eingebundenen Dateifreigaben: mit der Sitzung endet die " "Gilt auch für die eingebundenen Dateifreigaben: mit der Sitzung endet die "
"Einbindung.", "Einbindung.",
section="system", section="system",
minimum=5, minimum=5,
@@ -88,6 +102,14 @@ CORE_SETTINGS: tuple[SettingSpec, ...] = (
), ),
# -- Webserver/TLS (Host-Ebene, in beiden Anwendungen gleich) ----------- # # -- Webserver/TLS (Host-Ebene, in beiden Anwendungen gleich) ----------- #
# Lizenzserver: eigenes Zertifikat statt abgeschalteter Pruefung. # Lizenzserver: eigenes Zertifikat statt abgeschalteter Pruefung.
SettingSpec(
"modules_disabled",
[],
"json",
"Vor Ort abgeschaltete Module",
hint="Die Lizenz sagt, was erlaubt ist; das hier sagt, was benutzt wird.",
section="system",
),
SettingSpec( SettingSpec(
"license_server_ca", "license_server_ca",
"", "",
@@ -100,8 +122,8 @@ CORE_SETTINGS: tuple[SettingSpec, ...] = (
SettingSpec("web_hsts_enabled", False, "bool", "HSTS senden", section="web"), SettingSpec("web_hsts_enabled", False, "bool", "HSTS senden", section="web"),
SettingSpec("web_redirect_http", True, "bool", "HTTP auf HTTPS umleiten", section="web"), SettingSpec("web_redirect_http", True, "bool", "HTTP auf HTTPS umleiten", section="web"),
SettingSpec("web_cert_path", "", "str", "Zertifikat (Pfad)", section="web"), SettingSpec("web_cert_path", "", "str", "Zertifikat (Pfad)", section="web"),
SettingSpec("web_key_path", "", "str", "Privater Schluessel (Pfad)", section="web"), SettingSpec("web_key_path", "", "str", "Privater Schlüssel (Pfad)", section="web"),
SettingSpec("web_acme_email", "", "str", "E-Mail fuer Let's Encrypt", section="web"), SettingSpec("web_acme_email", "", "str", "E-Mail für Let's Encrypt", section="web"),
SettingSpec("web_acme_webroot", "", "str", "ACME-Webroot", section="web"), SettingSpec("web_acme_webroot", "", "str", "ACME-Webroot", section="web"),
# Abweichende Ports braucht nur eine Testinstanz neben der Produktion -- # Abweichende Ports braucht nur eine Testinstanz neben der Produktion --
# im Regelbetrieb bleibt es bei 80 und 443. # im Regelbetrieb bleibt es bei 80 und 443.
@@ -112,7 +134,7 @@ CORE_SETTINGS: tuple[SettingSpec, ...] = (
"web_https_port", 443, "int", "HTTPS-Port", section="web", minimum=1, maximum=65535 "web_https_port", 443, "int", "HTTPS-Port", section="web", minimum=1, maximum=65535
), ),
SettingSpec( SettingSpec(
"web_max_body_mb", 128, "int", "Maximale Anfragegroesse (MB)", section="web", "web_max_body_mb", 128, "int", "Maximale Anfragegröße (MB)", section="web",
minimum=1, maximum=8192, minimum=1, maximum=8192,
), ),
*directory.SETTINGS, *directory.SETTINGS,
@@ -134,7 +156,11 @@ def create_base_app(
#: Wortmarke, ohne Zeile darunter; die Marke selbst zeigen beide #: Wortmarke, ohne Zeile darunter; die Marke selbst zeigen beide
#: Anwendungen. #: Anwendungen.
auth_tagline: str = "", auth_tagline: str = "",
app_version: str = "2.1.1", #: Beide Anwendungen geben ihre eigene Paketversion mit -- und die laeuft
#: absichtlich auseinander: was sich nicht geaendert hat, bekommt keine
#: neue Nummer. Eine feste Zahl an dieser Stelle wuerde irgendwann eine
#: falsche Version in die Fussnote schreiben.
app_version: str = "unbekannt",
built_in_trusted_keys: tuple[str, ...] = (), built_in_trusted_keys: tuple[str, ...] = (),
enable_license: bool = True, enable_license: bool = True,
template_folder: str | None = None, template_folder: str | None = None,
@@ -146,6 +172,10 @@ def create_base_app(
static_folder=static_folder or "static", static_folder=static_folder or "static",
static_url_path="/assets", static_url_path="/assets",
) )
# Umlaute bleiben Umlaute -- auch in JSON. Flask wuerde sonst nach ASCII
# ausweichen ("ü"), und genau darin stehen die Flash-Meldungen in der
# Seite: eine Meldung mit "ue" war lesbar, eine mit "ü" wurde zu Zahlen.
app.json.ensure_ascii = False
# Templates und Design-System des Kerns liegen im Paket ``tesm_core`` und # Templates und Design-System des Kerns liegen im Paket ``tesm_core`` und
# werden ueber einen eigenen Blueprint bereitgestellt. Dadurch kann jede # werden ueber einen eigenen Blueprint bereitgestellt. Dadurch kann jede
@@ -163,6 +193,10 @@ def create_base_app(
config.data_dir.mkdir(parents=True, exist_ok=True) config.data_dir.mkdir(parents=True, exist_ok=True)
config.log_dir.mkdir(parents=True, exist_ok=True) config.log_dir.mkdir(parents=True, exist_ok=True)
# Die Groessengrenze einer Anfrage kommt aus der Einstellung, nicht aus
# der Konfiguration beim Start.
app.request_class = uploads.UploadLimitRequest
app.config.update( app.config.update(
SECRET_KEY=load_or_create_secret_key(config.secret_key_file), SECRET_KEY=load_or_create_secret_key(config.secret_key_file),
# site_key statt app_key: Cookies unterscheiden keine Ports. Zwei # site_key statt app_key: Cookies unterscheiden keine Ports. Zwei
@@ -172,6 +206,9 @@ def create_base_app(
SESSION_COOKIE_HTTPONLY=True, SESSION_COOKIE_HTTPONLY=True,
SESSION_COOKIE_SECURE=config.security.cookie_secure, SESSION_COOKIE_SECURE=config.security.cookie_secure,
SESSION_COOKIE_SAMESITE=config.security.cookie_samesite, SESSION_COOKIE_SAMESITE=config.security.cookie_samesite,
# Nur die aeussere Schranke aus der Umgebung. Die eigentliche Grenze
# steht in der Einstellung und wird pro Anfrage gelesen (uploads.py) --
# sonst braeuchte jede Aenderung einen Neustart aller Arbeiter.
MAX_CONTENT_LENGTH=config.security.max_content_length, MAX_CONTENT_LENGTH=config.security.max_content_length,
JSON_SORT_KEYS=False, JSON_SORT_KEYS=False,
TRAP_HTTP_EXCEPTIONS=False, TRAP_HTTP_EXCEPTIONS=False,
@@ -222,12 +259,21 @@ def create_base_app(
wert = 0 wert = 0
return wert if wert > 0 else config.security.session_idle_minutes return wert if wert > 0 else config.security.session_idle_minutes
def _gesamtdauer(conn: Any) -> int:
"""Gesamtdauer aus den Einstellungen -- ueber dieselbe offene Verbindung."""
try:
wert = int(settings.get(conn, "session_absolute_hours") or 0)
except (TypeError, ValueError):
wert = 0
return wert if wert > 0 else config.security.session_absolute_hours
app.session_interface = SqliteSessionInterface( app.session_interface = SqliteSessionInterface(
database, database,
cookie_name=app.config["SESSION_COOKIE_NAME"], cookie_name=app.config["SESSION_COOKIE_NAME"],
idle_minutes=config.security.session_idle_minutes, idle_minutes=config.security.session_idle_minutes,
absolute_hours=config.security.session_absolute_hours, absolute_hours=config.security.session_absolute_hours,
idle_lookup=_leerlauf, idle_lookup=_leerlauf,
absolute_lookup=_gesamtdauer,
) )
# -- Schema und Startzustand ------------------------------------------ # # -- Schema und Startzustand ------------------------------------------ #
@@ -328,6 +374,26 @@ def create_base_app(
# -- Fehlerseiten ------------------------------------------------------ # # -- Fehlerseiten ------------------------------------------------------ #
@app.errorhandler(HTTPException) @app.errorhandler(HTTPException)
def _http_error(error: HTTPException): # type: ignore[no-untyped-def] def _http_error(error: HTTPException): # type: ignore[no-untyped-def]
if error.code == 404:
# Vor der Fehlerseite: war das eine Adresse aus der Zeit vor 2.5.0?
# Erst hier gefragt, damit die Tabelle nie eine bestehende Route
# verdeckt -- auf dem Lizenzserver ist /lizenz/<ticket> echt.
ziel = legacy_urls.neue_adresse(request.path)
if ziel is not None:
parameter = legacy_urls.neue_parameter(request.args.to_dict(flat=True))
if parameter:
ziel = ziel + "?" + urlencode(parameter)
return redirect(ziel)
if error.code == 413:
# "Request Entity Too Large" allein hilft niemandem weiter: die
# Zahl steht in einer Einstellung, und wer hochlaedt, soll sie
# sehen. Vorher stand hier nur der nackte Status.
grenze = uploads.limit_mb()
error.description = (
f"Diese Datei ist größer als erlaubt. Zurzeit sind {grenze} MB je Upload "
"eingestellt; ändern kann das ein Administrator unter "
'"Einstellungen -> Webserver und TLS".'
)
if _wants_json(): if _wants_json():
return { return {
"error": error.name, "error": error.name,
@@ -381,7 +447,7 @@ def create_base_app(
with app.app_context(): with app.app_context():
trash_views.register_core_providers() trash_views.register_core_providers()
app.add_url_rule("/gesundheit", "health", _health, methods=["GET"]) app.add_url_rule("/health", "health", _health, methods=["GET"])
return app return app
+46 -3
View File
@@ -28,7 +28,7 @@ import sqlite3
from collections import defaultdict from collections import defaultdict
from datetime import datetime, timezone from datetime import datetime, timezone
from pathlib import Path from pathlib import Path
from typing import Any, Iterable from typing import Any, Collection, Iterable, Mapping, Sequence
from .db import Database from .db import Database
@@ -116,6 +116,49 @@ def log(
return int(cursor.lastrowid or 0) return int(cursor.lastrowid or 0)
def diff(
vorher: Mapping[str, Any] | None,
nachher: Mapping[str, Any] | None,
*,
felder: Sequence[tuple[str, str]] | None = None,
geheim: Collection[str] = (),
) -> str:
"""Was sich geaendert hat: ``Feld: alt -> neu``, mit Komma getrennt.
Damit steht im Protokoll nicht nur *dass* etwas geaendert wurde, sondern
auch *von was auf was* -- der Weg zurueck steht damit in der Zeile selbst.
``felder`` gibt Auswahl und Beschriftung vor (``[("ssh_port", "SSH-Port")]``);
ohne Angabe werden alle gemeinsamen Felder verglichen. Namen in ``geheim``
werden nie im Klartext geschrieben -- dort steht nur, dass sie sich
geaendert haben.
"""
if vorher is None or nachher is None:
return ""
paare = felder or [(name, name) for name in sorted(set(vorher) & set(nachher))]
teile: list[str] = []
for name, beschriftung in paare:
if name not in vorher or name not in nachher:
continue
alt, neu = vorher[name], nachher[name]
if alt == neu:
continue
if name in geheim:
teile.append(f"{beschriftung}: geaendert")
continue
teile.append(f"{beschriftung}: {_wert(alt)} -> {_wert(neu)}")
return ", ".join(teile)
def _wert(wert: Any) -> str:
if wert is None or wert == "":
return "(leer)"
if isinstance(wert, bool):
return "ein" if wert else "aus"
text = " ".join(str(wert).split())
return text if len(text) <= 80 else text[:79] + "…"
def verify_chain(conn: sqlite3.Connection, *, limit: int | None = None) -> dict[str, Any]: def verify_chain(conn: sqlite3.Connection, *, limit: int | None = None) -> dict[str, Any]:
"""Prueft die Hash-Kette. Rueckgabe: ``{"ok", "checked", "broken_at", "reason"}``.""" """Prueft die Hash-Kette. Rueckgabe: ``{"ok", "checked", "broken_at", "reason"}``."""
sql = "SELECT * FROM audit_log ORDER BY id" sql = "SELECT * FROM audit_log ORDER BY id"
@@ -133,14 +176,14 @@ def verify_chain(conn: sqlite3.Connection, *, limit: int | None = None) -> dict[
"ok": False, "ok": False,
"checked": checked, "checked": checked,
"broken_at": data["id"], "broken_at": data["id"],
"reason": "Vorgaenger-Hash passt nicht -- eine Zeile wurde entfernt oder eingefuegt.", "reason": "Vorgänger-Hash passt nicht – eine Zeile wurde entfernt oder eingefügt.",
} }
if compute_hash(str(data["prev_hash"]), data) != data["hash"]: if compute_hash(str(data["prev_hash"]), data) != data["hash"]:
return { return {
"ok": False, "ok": False,
"checked": checked, "checked": checked,
"broken_at": data["id"], "broken_at": data["id"],
"reason": "Zeileninhalt passt nicht zum gespeicherten Hash -- Eintrag geaendert.", "reason": "Zeileninhalt passt nicht zum gespeicherten Hash | Eintrag geändert.",
} }
expected_prev = str(data["hash"]) expected_prev = str(data["hash"])
checked += 1 checked += 1
@@ -26,6 +26,7 @@ import re
import socket import socket
import sqlite3 import sqlite3
import ssl import ssl
import time
from dataclasses import dataclass, field from dataclasses import dataclass, field
from datetime import datetime, timezone from datetime import datetime, timezone
from typing import Any, Literal from typing import Any, Literal
@@ -59,20 +60,20 @@ SETTINGS: tuple[SettingSpec, ...] = (
section="ldap", section="ldap",
choices=(("ldaps", "LDAPS (Port 636)"), ("starttls", "StartTLS (Port 389)"), ("none", "Unverschluesselt")), choices=(("ldaps", "LDAPS (Port 636)"), ("starttls", "StartTLS (Port 389)"), ("none", "Unverschluesselt")),
), ),
SettingSpec("ldap_verify_cert", True, "bool", "Serverzertifikat pruefen", section="ldap"), SettingSpec("ldap_verify_cert", True, "bool", "Serverzertifikat prüfen", section="ldap"),
SettingSpec("ldap_ca_file", "", "str", "CA-Zertifikat (Pfad)", section="ldap"), SettingSpec("ldap_ca_file", "", "str", "CA-Zertifikat (Pfad)", section="ldap"),
SettingSpec( SettingSpec(
"ldap_allow_insecure", "ldap_allow_insecure",
False, False,
"bool", "bool",
"Unverschluesselte Verbindung ausdruecklich erlauben", "Unverschlüsselte Verbindung ausdrücklich erlauben",
hint="Nur fuer Testumgebungen. Das Passwort geht dabei im Klartext ueber das Netz.", hint="Nur für Testumgebungen. Das Passwort geht dabei im Klartext über das Netz.",
section="ldap", section="ldap",
), ),
SettingSpec("ldap_base_dn", "", "str", "Basis-DN (leer = automatisch)", section="ldap"), SettingSpec("ldap_base_dn", "", "str", "Basis-DN (leer = automatisch)", section="ldap"),
SettingSpec("ldap_user_attribute", "sAMAccountName", "str", "Anmeldeattribut", section="ldap"), SettingSpec("ldap_user_attribute", "sAMAccountName", "str", "Anmeldeattribut", section="ldap"),
SettingSpec("ldap_email_attribute", "userPrincipalName", "str", "E-Mail-Attribut", section="ldap"), SettingSpec("ldap_email_attribute", "userPrincipalName", "str", "E-Mail-Attribut", section="ldap"),
SettingSpec("ldap_group_sync", True, "bool", "Gruppen aus dem Verzeichnis uebernehmen", section="ldap"), SettingSpec("ldap_group_sync", True, "bool", "Gruppen aus dem Verzeichnis übernehmen", section="ldap"),
SettingSpec("ldap_timeout", 8, "int", "Zeitlimit (Sekunden)", section="ldap", minimum=1, maximum=60), SettingSpec("ldap_timeout", 8, "int", "Zeitlimit (Sekunden)", section="ldap", minimum=1, maximum=60),
) )
@@ -123,7 +124,7 @@ def bind_credentials(conn: sqlite3.Connection) -> tuple[str, str]:
try: try:
secret = core().keystore.decrypt(encrypted, aad=AAD_LDAP_BIND) secret = core().keystore.decrypt(encrypted, aad=AAD_LDAP_BIND)
except Exception: # noqa: BLE001 - Schluessel gewechselt/Datei kaputt except Exception: # noqa: BLE001 - Schluessel gewechselt/Datei kaputt
logger.error("Bind-Passwort des Verzeichnisdienstes konnte nicht entschluesselt werden") logger.error("Bind-Passwort des Verzeichnisdienstes konnte nicht entschlüsselt werden")
return str(row.get("username") or ""), "" return str(row.get("username") or ""), ""
return str(row.get("username") or ""), secret return str(row.get("username") or ""), secret
@@ -162,19 +163,82 @@ def _tls_object(config: dict[str, Any]):
def _server(config: dict[str, Any]): def _server(config: dict[str, Any]):
"""Das Server-Objekt -- so sparsam wie moeglich.
``get_info`` entscheidet, was ldap3 beim Verbinden **zusaetzlich** holt.
Mit ``ALL`` ist das die Beschreibung des Verzeichnisses *und sein ganzes
Schema*: bei einem Active Directory sind das Tausende Attribut- und
Klassendefinitionen, mehrere Megabyte, bei jeder Verbindung neu. Fuer eine
Anmeldung braucht davon nichts -- gelesen wird hoechstens der
``defaultNamingContext``, wenn kein Basis-DN eingetragen ist, und dafuer
genuegt ``DSA`` (nur die Serverinformation, ein Datensatz).
Liegt der Domain-Controller in einem anderen Netz, ist genau das der
Unterschied zwischen "geht sofort" und "dauert manchmal".
"""
from ldap3 import Server from ldap3 import Server
security = str(config["ldap_security"]) security = str(config["ldap_security"])
braucht_basis = not str(config.get("ldap_base_dn") or "").strip()
return Server( return Server(
str(config["ldap_host"]), str(config["ldap_host"]),
port=int(config["ldap_port"]), port=int(config["ldap_port"]),
use_ssl=security == "ldaps", use_ssl=security == "ldaps",
tls=_tls_object(config) if security in {"ldaps", "starttls"} else None, tls=_tls_object(config) if security in {"ldaps", "starttls"} else None,
get_info="ALL", get_info="DSA" if braucht_basis else "NO_INFO",
connect_timeout=int(config["ldap_timeout"]), connect_timeout=int(config["ldap_timeout"]),
) )
#: Fehler, die nur "gerade jetzt nicht" bedeuten -- nicht "falsches Passwort".
#:
#: Die Liste stammt aus der Fassung 1.2.8, wo dieses Problem schon einmal
#: geloest war. Der Kommentar dort beschreibt es genauer, als ich es koennte:
#: ein Domain-Controller in einem anderen, langsameren Netzsegment -- die
#: allererste Verbindung dorthin ist oft spuerbar langsamer als jede folgende,
#: und der erste Login schlug mit "Benutzer/Passwort ungueltig" fehl, waehrend
#: ein sofortiger zweiter Versuch klappte.
TRANSIENTE_FEHLER: tuple[type[Exception], ...] = ()
def _transiente_fehler() -> tuple[type[Exception], ...]:
"""Die Ausnahmeklassen von ldap3, die einen Wiederholversuch verdienen."""
global TRANSIENTE_FEHLER
if TRANSIENTE_FEHLER:
return TRANSIENTE_FEHLER
from ldap3.core.exceptions import (
LDAPResponseTimeoutError,
LDAPSocketOpenError,
LDAPSocketReceiveError,
LDAPSocketSendError,
)
TRANSIENTE_FEHLER = (
LDAPSocketOpenError,
LDAPSocketReceiveError,
LDAPSocketSendError,
LDAPResponseTimeoutError,
)
return TRANSIENTE_FEHLER
def mit_wiederholung(aufbau: Any) -> Any:
"""Genau ein zweiter Versuch -- und nur bei einem Netzfehler.
Nicht bei einer abgelehnten Anmeldung: ein Tippfehler im Passwort wuerde
sonst den Sperrzaehler des Kontos zweimal statt einmal hochzaehlen. Das
ist die Regel aus 1.2.8, und sie ist richtig.
"""
try:
return aufbau()
except _transiente_fehler() as fehler:
logger.info(
"Verzeichnisdienst antwortete nicht (%s) -- ein zweiter Versuch",
type(fehler).__name__,
)
return aufbau()
def _connect(config: dict[str, Any], user: str, password: str): def _connect(config: dict[str, Any], user: str, password: str):
"""Baut eine Verbindung auf und bindet. Wirft bei Netz-/TLS-Problemen.""" """Baut eine Verbindung auf und bindet. Wirft bei Netz-/TLS-Problemen."""
from ldap3 import Connection from ldap3 import Connection
@@ -182,7 +246,7 @@ def _connect(config: dict[str, Any], user: str, password: str):
security = str(config["ldap_security"]) security = str(config["ldap_security"])
if security == "none" and not config["ldap_allow_insecure"]: if security == "none" and not config["ldap_allow_insecure"]:
raise PermissionError( raise PermissionError(
"Unverschluesselte Verzeichnisverbindungen sind deaktiviert. " "Unverschlüsselte Verzeichnisverbindungen sind deaktiviert. "
"Nutzen Sie LDAPS oder StartTLS." "Nutzen Sie LDAPS oder StartTLS."
) )
connection = Connection( connection = Connection(
@@ -192,6 +256,12 @@ def _connect(config: dict[str, Any], user: str, password: str):
auto_bind=False, auto_bind=False,
raise_exceptions=False, raise_exceptions=False,
receive_timeout=int(config["ldap_timeout"]), receive_timeout=int(config["ldap_timeout"]),
# Ein Active Directory antwortet auf eine Suche ab der Domaenenwurzel
# mit Verweisen auf andere Controller. ldap3 folgt ihnen von sich aus
# -- und baut dafuer neue Verbindungen auf, womoeglich in ein anderes
# Netz. Fuer eine Anmeldung ist das nicht nur unnoetig, sondern die
# Quelle genau der Wartezeiten, die niemand erklaeren kann.
auto_referrals=False,
) )
if security == "starttls": if security == "starttls":
if not connection.open(): if not connection.open():
@@ -249,12 +319,23 @@ def authenticate(conn: sqlite3.Connection, username: str, password: str) -> Dire
bind_user, bind_password = bind_credentials(conn) bind_user, bind_password = bind_credentials(conn)
warnings: list[str] = [] warnings: list[str] = []
if str(config["ldap_security"]) == "none": if str(config["ldap_security"]) == "none":
warnings.append("Unverschluesselte Verzeichnisverbindung in Benutzung") warnings.append("Unverschlüsselte Verzeichnisverbindung in Benutzung")
if not config["ldap_verify_cert"]: if not config["ldap_verify_cert"]:
warnings.append("Serverzertifikat des Verzeichnisdienstes wird nicht geprueft") warnings.append("Serverzertifikat des Verzeichnisdienstes wird nicht geprüft")
begonnen = time.monotonic()
zeiten: list[str] = []
def gemessen(name: str, seit: float) -> float:
"""Haelt eine Stufe mit ihrer Dauer fest und gibt die neue Marke."""
jetzt = time.monotonic()
zeiten.append(f"{name} {int((jetzt - seit) * 1000)}ms")
return jetzt
try: try:
service_conn, bound = _connect(config, bind_user, bind_password) service_conn, bound = mit_wiederholung(
lambda: _connect(config, bind_user, bind_password)
)
except (PermissionError, ConnectionError) as exc: except (PermissionError, ConnectionError) as exc:
logger.error("Verzeichnisdienst: %s", exc) logger.error("Verzeichnisdienst: %s", exc)
return DirectoryResult("unavailable", message=str(exc), warnings=tuple(warnings)) return DirectoryResult("unavailable", message=str(exc), warnings=tuple(warnings))
@@ -271,8 +352,10 @@ def authenticate(conn: sqlite3.Connection, username: str, password: str) -> Dire
warnings=tuple(warnings), warnings=tuple(warnings),
) )
marke = gemessen("Dienstbindung", begonnen)
try: try:
base = _base_dn(service_conn, config) base = _base_dn(service_conn, config)
marke = gemessen("Basis-DN", marke)
if not base: if not base:
return DirectoryResult( return DirectoryResult(
"unavailable", message="Basis-DN konnte nicht ermittelt werden.", warnings=tuple(warnings) "unavailable", message="Basis-DN konnte nicht ermittelt werden.", warnings=tuple(warnings)
@@ -288,8 +371,10 @@ def authenticate(conn: sqlite3.Connection, username: str, password: str) -> Dire
attributes=list(dict.fromkeys(wanted)), attributes=list(dict.fromkeys(wanted)),
size_limit=2, size_limit=2,
) )
marke = gemessen("Benutzersuche", marke)
entries = list(service_conn.entries) entries = list(service_conn.entries)
if len(entries) != 1: if len(entries) != 1:
logger.info("Verzeichnisanmeldung %s: %s", username, ", ".join(zeiten))
return DirectoryResult("invalid", warnings=tuple(warnings)) return DirectoryResult("invalid", warnings=tuple(warnings))
entry = entries[0] entry = entries[0]
@@ -300,9 +385,18 @@ def authenticate(conn: sqlite3.Connection, username: str, password: str) -> Dire
# Zweite, unabhaengige Verbindung mit den Benutzerdaten -- die eigentliche # Zweite, unabhaengige Verbindung mit den Benutzerdaten -- die eigentliche
# Passwortpruefung. Niemals ueber die Dienstkonto-Verbindung rebinden. # Passwortpruefung. Niemals ueber die Dienstkonto-Verbindung rebinden.
user_conn, user_bound = _connect(config, dn, password) #
# Mit Wiederholversuch, und genau hier zeigte sich das Symptom: bei
# einem Domain-Controller in einem anderen Netz schlug der erste
# Versuch mit "Benutzer/Passwort ungueltig" fehl, der zweite gleich
# darauf klappte. Wiederholt wird nur bei einem Netzfehler -- eine
# abgelehnte Anmeldung bleibt abgelehnt, sonst zaehlt ein Tippfehler
# den Sperrzaehler des Kontos zweimal hoch.
user_conn, user_bound = mit_wiederholung(lambda: _connect(config, dn, password))
marke = gemessen("Passwortpruefung", marke)
try: try:
if not user_bound: if not user_bound:
logger.info("Verzeichnisanmeldung %s: %s", username, ", ".join(zeiten))
return DirectoryResult("invalid", warnings=tuple(warnings)) return DirectoryResult("invalid", warnings=tuple(warnings))
finally: finally:
try: try:
@@ -313,6 +407,7 @@ def authenticate(conn: sqlite3.Connection, username: str, password: str) -> Dire
group_dns: tuple[str, ...] = () group_dns: tuple[str, ...] = ()
if config["ldap_group_sync"]: if config["ldap_group_sync"]:
group_dns = _resolve_groups(service_conn, base, dn) group_dns = _resolve_groups(service_conn, base, dn)
marke = gemessen("Gruppen", marke)
profile = DirectoryProfile( profile = DirectoryProfile(
username=_attr(entry, attribute) or username, username=_attr(entry, attribute) or username,
@@ -322,6 +417,15 @@ def authenticate(conn: sqlite3.Connection, username: str, password: str) -> Dire
email=_attr(entry, email_attribute), email=_attr(entry, email_attribute),
group_dns=group_dns, group_dns=group_dns,
) )
# Eine Zeile je Anmeldung mit den Dauern der Stufen. Aus "dauert
# manchmal laenger" wird damit eine Zahl -- und man sieht, ob es die
# Verbindung ist, die Suche oder die Gruppen.
logger.info(
"Verzeichnisanmeldung %s: %s, gesamt %dms",
username,
", ".join(zeiten),
int((time.monotonic() - begonnen) * 1000),
)
return DirectoryResult("ok", profile=profile, warnings=tuple(warnings)) return DirectoryResult("ok", profile=profile, warnings=tuple(warnings))
finally: finally:
try: try:
@@ -359,7 +463,7 @@ def _resolve_groups(connection: Any, base: str, user_dn: str) -> tuple[str, ...]
) )
return tuple(str(entry.entry_dn) for entry in connection.entries) return tuple(str(entry.entry_dn) for entry in connection.entries)
except Exception as exc: # noqa: BLE001 except Exception as exc: # noqa: BLE001
logger.warning("Gruppenaufloesung fehlgeschlagen: %s", exc) logger.warning("Gruppenauflösung fehlgeschlagen: %s", exc)
return () return ()
@@ -423,18 +527,24 @@ def _apply_group_mappings(
zugewiesene Gruppen bleiben erhalten, wenn sie kein Verzeichnis-Mapping zugewiesene Gruppen bleiben erhalten, wenn sie kein Verzeichnis-Mapping
haben -- sonst waere jede lokale Zuweisung nach dem naechsten Login weg. haben -- sonst waere jede lokale Zuweisung nach dem naechsten Login weg.
""" """
if not group_dns:
return
mappings = Database.all( mappings = Database.all(
conn, "SELECT ad_group_dn, app_group_id FROM ldap_group_mappings" conn, "SELECT ad_group_dn, app_group_id, grants_admin FROM ldap_group_mappings"
) )
if not mappings: if not mappings:
return return
mapped_group_ids = {int(row["app_group_id"]) for row in mappings}
lowered = {dn.lower() for dn in group_dns} lowered = {dn.lower() for dn in group_dns}
# Das Kennzeichen zuerst -- es haengt an keiner Gruppe und muss auch dann
# ausgewertet werden, wenn das Konto in keiner zugeordneten Gruppe steht.
_kennzeichen_setzen(conn, user_id, mappings, lowered)
if not group_dns:
return
gruppenzuordnungen = [row for row in mappings if row["app_group_id"] is not None]
if not gruppenzuordnungen:
return
mapped_group_ids = {int(row["app_group_id"]) for row in gruppenzuordnungen}
wanted = { wanted = {
int(row["app_group_id"]) int(row["app_group_id"])
for row in mappings for row in gruppenzuordnungen
if str(row["ad_group_dn"]).lower() in lowered if str(row["ad_group_dn"]).lower() in lowered
} }
@@ -452,6 +562,71 @@ def _apply_group_mappings(
) )
def _kennzeichen_setzen(
conn: sqlite3.Connection,
user_id: int,
mappings: list[dict[str, Any]],
gruppen: set[str],
) -> None:
"""Setzt oder entzieht das Administratorkennzeichen nach dem Verzeichnis.
Wirkung wie bei Gruppen: **ersetzend**. Wer aus der zugeordneten
AD-Gruppe herausfaellt, verliert das Kennzeichen bei der naechsten
Anmeldung -- sonst waere ein Austritt im Verzeichnis eine Aenderung, die
hier nie ankommt.
Zwei Grenzen, beide absichtlich:
* Nur **Verzeichniskonten**. Ein lokales Konto (etwa das, das
``tesm-admin create-admin`` anlegt) bleibt unberuehrt -- es ist der
Notausgang, wenn das Verzeichnis nicht erreichbar ist.
* Nie den letzten entsperrten Administrator herunterstufen. Ein
Tippfehler im DN wuerde sonst beim naechsten Login die Anlage
verschliessen, und niemand koennte ihn korrigieren.
"""
zuordnungen = [row for row in mappings if int(row["grants_admin"] or 0) == 1]
if not zuordnungen:
return
soll = any(str(row["ad_group_dn"]).lower() in gruppen for row in zuordnungen)
zeile = Database.one(
conn, "SELECT is_admin, auth_source FROM users WHERE id=?", (user_id,)
)
if zeile is None or str(zeile["auth_source"]) != "ldap":
return
ist = bool(zeile["is_admin"])
if ist == soll:
return
if not soll:
uebrig = Database.value(
conn,
"SELECT COUNT(*) FROM users WHERE is_admin=1 AND is_locked=0 "
"AND deleted_at IS NULL AND id<>?",
(user_id,),
default=0,
)
if not uebrig:
logger.warning(
"Administratorkennzeichen bleibt bei Benutzer %s: es waere das letzte.",
user_id,
)
return
conn.execute(
"UPDATE users SET is_admin=?, updated_at=? WHERE id=?", (int(soll), _now(), user_id)
)
audit.log(
conn,
actor="system",
action="auth.admin_flag_changed",
target=str(user_id),
detail=(
"Administrator: "
+ ("nein -> ja" if soll else "ja -> nein")
+ " (aus der Verzeichniszuordnung)"
),
severity="warning",
)
def _user_group_ids(conn: sqlite3.Connection, user_id: int) -> list[dict[str, Any]]: def _user_group_ids(conn: sqlite3.Connection, user_id: int) -> list[dict[str, Any]]:
return Database.all(conn, "SELECT group_id AS id FROM user_groups WHERE user_id=?", (user_id,)) return Database.all(conn, "SELECT group_id AS id FROM user_groups WHERE user_id=?", (user_id,))
+94 -11
View File
@@ -77,25 +77,40 @@ class User:
@property @property
def effective_permissions(self) -> frozenset[str]: def effective_permissions(self) -> frozenset[str]:
"""Rechte nach Anwendung der Bereichs-Kill-Switches.""" """Rechte nach Anwendung der Bereichs-Kill-Switches.
Ein echter Administrator hat den Verwaltungsbereich immer dabei -- auch
ohne Gruppe und ohne dass jemand ihn angehakt haette. Sonst koennte
sich das letzte Administratorkonto aus der eigenen Anlage aussperren,
indem es seine Gruppe verliert.
"""
if self._effective is None: if self._effective is None:
tree = self._tree tree = self._tree
self._effective = ( if tree is None:
frozenset(tree.effective(self.granted)) if tree is not None else frozenset() self._effective = frozenset()
) else:
eigene = set(tree.effective(self.granted))
if self.is_admin:
eigene |= set(tree.verwaltungsrechte())
self._effective = frozenset(eigene)
return self._effective return self._effective
def has(self, *permissions: str) -> bool: def has(self, *permissions: str) -> bool:
"""True, wenn **alle** angegebenen Rechte greifen. Admins duerfen immer alles.""" """True, wenn **alle** angegebenen Rechte greifen.
if self.is_admin:
return True Das Administratorkennzeichen gibt seit 2.8.0 **den Verwaltungsbereich**
frei und nicht mehr jede Pruefung: Benutzer, Gruppen, Einstellungen,
Sicherung, Papierkorb, Diagnose. Funktionsrechte -- Geraete, Neustarts,
DHCP, Freigaben -- kommen ueber Gruppen, und die darf sich ein
Administrator selbst zuweisen. Der Unterschied ist nicht, *ob* er sie
bekommen kann, sondern dass es dann eine Zeile im
Aenderungsprotokoll gibt und in der Matrix zu sehen ist.
"""
if not permissions: if not permissions:
return True return True
return all(permission in self.effective_permissions for permission in permissions) return all(permission in self.effective_permissions for permission in permissions)
def has_any(self, permissions: Iterable[str]) -> bool: def has_any(self, permissions: Iterable[str]) -> bool:
if self.is_admin:
return True
effective = self.effective_permissions effective = self.effective_permissions
return any(permission in effective for permission in permissions) return any(permission in effective for permission in permissions)
@@ -220,6 +235,66 @@ def login_required(view: Callable[..., Any]) -> Callable[..., Any]:
return wrapper return wrapper
def _verlangt_wiederanmeldung(permission: str) -> bool:
"""Fragt das Rechtemodell -- ausserhalb einer Anwendung nie."""
try:
from ..extension import core
return bool(core().permissions.verlangt_wiederanmeldung(permission))
except Exception: # noqa: BLE001 - ohne Modell keine zusaetzliche Huerde
return False
def _wiederanmeldung_frisch() -> bool:
from .service import reauth_is_fresh # zyklischen Import vermeiden
return reauth_is_fresh()
def _bitte_bestaetigen(permission: str) -> Any:
"""Zur Passwortbestaetigung schicken -- oder es einem Skript sagen.
Ein Aufruf per fetch bekaeme sonst die Anmeldeseite als Antwort und wuesste
nichts damit anzufangen.
"""
if is_automatic(request) or (
request.accept_mimetypes.accept_json and not request.accept_mimetypes.accept_html
):
abort(403, description="Bitte bestätigen Sie zur Sicherheit Ihr Passwort.")
session["_after_reauth"] = request.full_path if request.query_string else request.path
flash("Bitte bestätigen Sie zur Sicherheit Ihr Passwort.", "info")
return redirect(url_for("auth.reauth"))
def _heikel(permissions: tuple[str, ...], mode: str) -> str:
"""Welches Recht verlangt hier eine frische Bestaetigung? Sonst leer.
Bei ``mode="all"`` ist die Antwort einfach: jedes genannte Recht wird
gebraucht, also zaehlt jedes heikle darunter.
Bei ``mode="any"`` nicht. Diese Seiten nennen mehrere **unabhaengige**
Wege hinein, und welcher genommen wird, entscheidet das Konto. Der
Protokollverlauf etwa nennt "logs_history.read" und "logs_history.export":
lesen ist harmlos, mitnehmen nicht. Gezaehlt wurde aber ueber alle
genannten Rechte -- und so verlangte das blosse **Ansehen** des Verlaufs
nach fuenfzehn Minuten eine Passworteingabe, mit dem Hinweis, man greife
auf einen Datenexport zu. Das stimmte nicht, und wer es dreimal liest,
glaubt die Meldung beim vierten Mal auch dann nicht, wenn sie zutrifft.
Gefragt wird deshalb: haette dieses Konto die Seite auch ohne das heikle
Recht geoeffnet? Dann gibt es nichts zu bestaetigen. Die Ausfuhr selbst
haengt an ihrer eigenen Route, und die nennt nur ein Recht.
"""
heikle = [p for p in permissions if _verlangt_wiederanmeldung(p)]
if not heikle:
return ""
if mode == "any":
harmlos = [p for p in permissions if p not in heikle]
if any(current_user.has(p) for p in harmlos):
return ""
return heikle[0]
def permission_required(*permissions: str, mode: str = "all") -> Callable[..., Any]: def permission_required(*permissions: str, mode: str = "all") -> Callable[..., Any]:
"""Erzwingt Rechte serverseitig. """Erzwingt Rechte serverseitig.
@@ -237,7 +312,15 @@ def permission_required(*permissions: str, mode: str = "all") -> Callable[..., A
else current_user.has(*permissions) else current_user.has(*permissions)
) )
if not ok: if not ok:
return _deny("Fuer diese Seite fehlt Ihnen die Berechtigung.") return _deny("Für diese Seite fehlt Ihnen die Berechtigung.")
# Geheimnisse und Exporte verlangen eine frische Bestaetigung --
# das steht im Rechtemodell und wird deshalb hier abgefragt, nicht
# in jeder Route einzeln. Vorher hing es daran, dass jemand an den
# Dekorator dachte: dieselbe Lizenz herunterzuladen fragte nach,
# sie per Mail zu verschicken nicht.
heikel = _heikel(permissions, mode)
if heikel and not _wiederanmeldung_frisch():
return _bitte_bestaetigen(heikel)
return view(*args, **kwargs) return view(*args, **kwargs)
wrapper._required_permissions = permissions # type: ignore[attr-defined] wrapper._required_permissions = permissions # type: ignore[attr-defined]
@@ -279,7 +362,7 @@ def reauth_required(view: Callable[..., Any]) -> Callable[..., Any]:
return _deny("Anmeldung erforderlich.") return _deny("Anmeldung erforderlich.")
if not reauth_is_fresh(): if not reauth_is_fresh():
session["_after_reauth"] = request.full_path if request.query_string else request.path session["_after_reauth"] = request.full_path if request.query_string else request.path
flash("Bitte bestaetigen Sie zur Sicherheit Ihr Passwort.", "info") flash("Bitte bestätigen Sie zur Sicherheit Ihr Passwort.", "info")
return redirect(url_for("auth.reauth")) return redirect(url_for("auth.reauth"))
return view(*args, **kwargs) return view(*args, **kwargs)
@@ -66,7 +66,7 @@ def login():
) )
@bp.route("/login/zweiter-faktor", methods=["GET", "POST"]) @bp.route("/login/second-factor", methods=["GET", "POST"])
def second_factor(): def second_factor():
conn = db() conn = db()
user_row = service.pending_totp_user(conn) user_row = service.pending_totp_user(conn)
@@ -116,7 +116,7 @@ def logout_get():
return redirect(url_for("auth.login")) return redirect(url_for("auth.login"))
@bp.route("/bestaetigen", methods=["GET", "POST"]) @bp.route("/confirm", methods=["GET", "POST"])
@login_required @login_required
def reauth(): def reauth():
target = _safe_next(session.get("_after_reauth")) target = _safe_next(session.get("_after_reauth"))
@@ -151,7 +151,7 @@ def authenticate(
if ip_verdict.blocked: if ip_verdict.blocked:
return AuthOutcome( return AuthOutcome(
"rate_limited", "rate_limited",
"Zu viele Anmeldeversuche von dieser Adresse. Bitte spaeter erneut versuchen.", "Zu viele Anmeldeversuche von dieser Adresse. Bitte später erneut versuchen.",
retry_after=ip_verdict.retry_after_seconds, retry_after=ip_verdict.retry_after_seconds,
) )
@@ -165,7 +165,7 @@ def authenticate(
if user_verdict.blocked: if user_verdict.blocked:
return AuthOutcome( return AuthOutcome(
"rate_limited", "rate_limited",
"Dieses Konto ist nach mehreren Fehlversuchen voruebergehend gesperrt.", "Dieses Konto ist nach mehreren Fehlversuchen vorübergehend gesperrt.",
retry_after=user_verdict.retry_after_seconds, retry_after=user_verdict.retry_after_seconds,
) )
@@ -248,7 +248,7 @@ def _verify_credentials(
return AuthOutcome( return AuthOutcome(
"directory_unavailable", "directory_unavailable",
"Der Verzeichnisdienst ist derzeit nicht erreichbar. " "Der Verzeichnisdienst ist derzeit nicht erreichbar. "
"Bitte versuchen Sie es spaeter erneut.", "Bitte versuchen Sie es später erneut.",
) )
if result.status != "ok" or result.profile is None: if result.status != "ok" or result.profile is None:
return AuthOutcome("invalid", GENERIC_FAILURE) return AuthOutcome("invalid", GENERIC_FAILURE)
@@ -257,6 +257,24 @@ def _verify_credentials(
return AuthOutcome("ok", user_row=user_row, group_dns=result.profile.group_dns) return AuthOutcome("ok", user_row=user_row, group_dns=result.profile.group_dns)
def _ist_erstanmeldung(user_row: dict[str, Any]) -> bool:
"""Meldet sich dieses Konto zum ersten Mal ueberhaupt an?
Das Aenderungsprotokoll soll Aenderungen zeigen. Eine taegliche Anmeldung
ist keine -- sie erzeugte aber jedes Mal eine Zeile und schob damit echte
Eintraege aus dem Blickfeld. Interessant ist genau die erste: mit ihr
entsteht das Konto aus dem Verzeichnisdienst und betritt jemand die
Anwendung, der vorher nicht darin war.
Nicht betroffen: fehlgeschlagene Anmeldungen und Sperren. Die stehen
weiterhin **jedes Mal** im Protokoll -- dort liegt der sicherheitsrelevante
Teil, und Haeufung ist dort das Signal. Wer wann zuletzt angemeldet war,
steht ausserdem unveraendert am Konto und in der Sitzungsuebersicht.
"""
return not str(user_row.get("last_login_at") or "").strip()
def complete_login( def complete_login(
conn: sqlite3.Connection, conn: sqlite3.Connection,
user_row: dict[str, Any], user_row: dict[str, Any],
@@ -275,21 +293,26 @@ def complete_login(
""" """
from ..security import csrf from ..security import csrf
# Vor dem UPDATE lesen -- danach steht ueberall ein Zeitstempel.
erstanmeldung = _ist_erstanmeldung(user_row)
with core().database.transaction(conn): with core().database.transaction(conn):
session_store.rotate(conn, session, reason="login") # type: ignore[arg-type] session_store.rotate(conn, session, reason="login") # type: ignore[arg-type]
conn.execute( conn.execute(
"UPDATE users SET last_login_at=? WHERE id=?", (_iso(_now()), user_row["id"]) "UPDATE users SET last_login_at=? WHERE id=?", (_iso(_now()), user_row["id"])
) )
audit.log( if erstanmeldung:
conn, audit.log(
actor=str(user_row["username"]), conn,
actor_id=int(user_row["id"]), actor=str(user_row["username"]),
action="auth.login", actor_id=int(user_row["id"]),
target=str(user_row["username"]), action="auth.first_login",
detail=f"Quelle: {user_row.get('auth_source') or 'local'}", target=str(user_row["username"]),
ip=ip, detail=f"Erste Anmeldung | Quelle: {user_row.get('auth_source') or 'local'}",
request_id=getattr(g, "request_id", ""), ip=ip,
) request_id=getattr(g, "request_id", ""),
severity="notice",
)
session.clear() session.clear()
session[SESSION_USER_KEY] = int(user_row["id"]) session[SESSION_USER_KEY] = int(user_row["id"])
@@ -336,15 +359,11 @@ def logout(conn: sqlite3.Connection) -> None:
current_app.logger.exception( current_app.logger.exception(
"Abmeldehaken %r fehlgeschlagen", getattr(hook, "__name__", hook) "Abmeldehaken %r fehlgeschlagen", getattr(hook, "__name__", hook)
) )
with core().database.transaction(conn): # Kein Protokolleintrag fuer das Abmelden: eine Abmeldung aendert
audit.log( # nichts. Sie stand als "Info" in jeder zweiten Zeile und verdraengte
conn, # die Eintraege, um die es geht. Wer wissen will, wer wann da war,
actor=user.username, # liest die Sitzungsuebersicht; fehlgeschlagene Anmeldungen und
actor_id=user.id, # Sperren stehen weiterhin jedes Mal im Protokoll.
action="auth.logout",
target=user.username,
ip=client_ip(),
)
session.destroy() if hasattr(session, "destroy") else session.clear() # type: ignore[attr-defined] session.destroy() if hasattr(session, "destroy") else session.clear() # type: ignore[attr-defined]
g.user = ANONYMOUS g.user = ANONYMOUS
+14 -12
View File
@@ -42,6 +42,8 @@ def _config_from_settings(extension: Any, conn: Any) -> webserver.WebConfig:
], ],
) )
return webserver.WebConfig( return webserver.WebConfig(
# Die neue Schreibweise, sobald der Webserver sie kennt.
http2_directive=webserver.nginx_kann_http2(),
app_key=extension.config.site_key, app_key=extension.config.site_key,
upstream=str(extension.registry.get("upstream", "127.0.0.1:5000")), upstream=str(extension.registry.get("upstream", "127.0.0.1:5000")),
static_root=str( static_root=str(
@@ -83,7 +85,7 @@ def run_web_setup(app: Any, extension: Any, args: argparse.Namespace) -> int:
port = getattr(args, flag, None) port = getattr(args, flag, None)
if port: if port:
if not 1 <= int(port) <= 65535: if not 1 <= int(port) <= 65535:
print(f"Ungueltiger Port: {port}", file=sys.stderr) print(f"Ungültiger Port: {port}", file=sys.stderr)
return 2 return 2
values[key] = int(port) values[key] = int(port)
@@ -93,11 +95,11 @@ def run_web_setup(app: Any, extension: Any, args: argparse.Namespace) -> int:
# -- Zertifikat --------------------------------------------------- # # -- Zertifikat --------------------------------------------------- #
config = _config_from_settings(extension, conn) config = _config_from_settings(extension, conn)
cert: dict[str, Any] = {"ok": True, "message": "Zertifikat unveraendert."} cert: dict[str, Any] = {"ok": True, "message": "Zertifikat unverändert."}
if args.acme: if args.acme:
if sysops is None or not sysops.available: if sysops is None or not sysops.available:
print("Der Systemhelfer ist nicht verfuegbar.", file=sys.stderr) print("Der Systemhelfer ist nicht verfügbar.", file=sys.stderr)
return 2 return 2
cert = webserver.request_certificate( cert = webserver.request_certificate(
sysops, sysops,
@@ -118,7 +120,7 @@ def run_web_setup(app: Any, extension: Any, args: argparse.Namespace) -> int:
if args.self_signed and not args.acme: if args.self_signed and not args.acme:
if sysops is None or not sysops.available: if sysops is None or not sysops.available:
print("Der Systemhelfer ist nicht verfuegbar.", file=sys.stderr) print("Der Systemhelfer ist nicht verfügbar.", file=sys.stderr)
return 2 return 2
suggested = webserver.local_names() suggested = webserver.local_names()
cert = webserver.create_self_signed( cert = webserver.create_self_signed(
@@ -144,7 +146,7 @@ def run_web_setup(app: Any, extension: Any, args: argparse.Namespace) -> int:
if args.https: if args.https:
if not (config.cert_path and config.key_path): if not (config.cert_path and config.key_path):
print( print(
"HTTPS verlangt Zertifikat und Schluessel -- bitte --self-signed " "HTTPS verlangt Zertifikat und Schlüssel – bitte --self-signed "
"oder --acme angeben.", "oder --acme angeben.",
file=sys.stderr, file=sys.stderr,
) )
@@ -172,7 +174,7 @@ def run_web_setup(app: Any, extension: Any, args: argparse.Namespace) -> int:
return 0 return 0
if sysops is None or not sysops.available: if sysops is None or not sysops.available:
print("Der Systemhelfer ist nicht verfuegbar.", file=sys.stderr) print("Der Systemhelfer ist nicht verfügbar.", file=sys.stderr)
return 2 return 2
result = webserver.apply_config(sysops, rendered) result = webserver.apply_config(sysops, rendered)
print(result["message"]) print(result["message"])
@@ -194,7 +196,7 @@ def add_web_setup(sub: Any, runner: Any) -> None:
help="nginx und TLS einrichten (Port 80/443)", help="nginx und TLS einrichten (Port 80/443)",
description=( description=(
"Setzt die Webserver-Einstellungen und erzeugt daraus die nginx-Site. " "Setzt die Webserver-Einstellungen und erzeugt daraus die nginx-Site. "
"Die Datei wird immer vollstaendig neu geschrieben -- eine von Hand " "Die Datei wird immer vollständig neu geschrieben – eine von Hand "
"bearbeitete Site-Datei gibt es in diesem Projekt bewusst nicht." "bearbeitete Site-Datei gibt es in diesem Projekt bewusst nicht."
), ),
) )
@@ -207,20 +209,20 @@ def add_web_setup(sub: Any, runner: Any) -> None:
parser.add_argument( parser.add_argument(
"--acme", "--acme",
action="store_true", action="store_true",
help="Zertifikat per Let's Encrypt anfordern (Domain muss oeffentlich zeigen)", help="Zertifikat per Let's Encrypt anfordern (Domain muss öffentlich zeigen)",
) )
parser.add_argument("--domain", help="Oeffentlicher Name dieser Installation") parser.add_argument("--domain", help="Öffentlicher Name dieser Installation")
parser.add_argument("--acme-email", help="Kontaktadresse fuer Let's Encrypt") parser.add_argument("--acme-email", help="Kontaktadresse für Let's Encrypt")
parser.add_argument("--hsts", action="store_true", help="HSTS-Header setzen") parser.add_argument("--hsts", action="store_true", help="HSTS-Header setzen")
parser.add_argument( parser.add_argument(
"--no-redirect", "--no-redirect",
action="store_true", action="store_true",
help="Port 80 nicht auf HTTPS umleiten (Sonderfall)", help="Port 80 nicht auf HTTPS umleiten (Sonderfall)",
) )
parser.add_argument("--max-body-mb", type=int, help="Obergrenze fuer Uploads") parser.add_argument("--max-body-mb", type=int, help="Obergrenze für Uploads")
parser.add_argument("--http-port", type=int, help="Abweichender HTTP-Port (Standard 80)") parser.add_argument("--http-port", type=int, help="Abweichender HTTP-Port (Standard 80)")
parser.add_argument("--https-port", type=int, help="Abweichender HTTPS-Port (Standard 443)") parser.add_argument("--https-port", type=int, help="Abweichender HTTPS-Port (Standard 443)")
parser.add_argument("--apply", action="store_true", help="nginx schreiben, pruefen, neu laden") parser.add_argument("--apply", action="store_true", help="nginx schreiben, prüfen, neu laden")
parser.add_argument( parser.add_argument(
"--print-config", action="store_true", help="Nur die erzeugte Datei ausgeben" "--print-config", action="store_true", help="Nur die erzeugte Datei ausgeben"
) )
+2 -2
View File
@@ -175,11 +175,11 @@ def load_core_config(
bind = (_env(env_prefix, "BIND") or default_bind).strip() bind = (_env(env_prefix, "BIND") or default_bind).strip()
if not re.fullmatch(r"[0-9A-Za-z_.\[\]:-]{1,80}:\d{1,5}", bind): if not re.fullmatch(r"[0-9A-Za-z_.\[\]:-]{1,80}:\d{1,5}", bind):
raise ValueError(f"Ungueltige Bind-Adresse: {bind!r}") raise ValueError(f"Ungültige Bind-Adresse: {bind!r}")
site_key = (_env(env_prefix, "SITE_KEY") or app_key).strip().lower() site_key = (_env(env_prefix, "SITE_KEY") or app_key).strip().lower()
if not re.fullmatch(r"[a-z0-9][a-z0-9-]{0,39}", site_key): if not re.fullmatch(r"[a-z0-9][a-z0-9-]{0,39}", site_key):
raise ValueError(f"Ungueltiger SITE_KEY: {site_key!r}") raise ValueError(f"Ungültiger SITE_KEY: {site_key!r}")
return CoreConfig( return CoreConfig(
app_key=app_key, app_key=app_key,
+144 -2
View File
@@ -141,11 +141,16 @@ CREATE TABLE IF NOT EXISTS service_accounts (
updated_at TEXT NOT NULL updated_at TEXT NOT NULL
); );
-- Eine Verzeichnisgruppe zeigt entweder auf eine lokale Gruppe **oder** auf
-- das Administratorkennzeichen (grants_admin). Darum ist app_group_id
-- nullbar: "Domänen-Admins" soll echte Administratoren erzeugen koennen und
-- nicht nur Mitglieder der Gruppe "Administration".
CREATE TABLE IF NOT EXISTS ldap_group_mappings ( CREATE TABLE IF NOT EXISTS ldap_group_mappings (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
ad_group_dn TEXT NOT NULL UNIQUE, ad_group_dn TEXT NOT NULL UNIQUE,
ad_group_name TEXT NOT NULL, ad_group_name TEXT NOT NULL,
app_group_id INTEGER NOT NULL REFERENCES groups(id) ON DELETE CASCADE, app_group_id INTEGER REFERENCES groups(id) ON DELETE CASCADE,
grants_admin INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL created_at TEXT NOT NULL
); );
""" """
@@ -186,8 +191,145 @@ CREATE INDEX IF NOT EXISTS idx_jobs_kind_state ON jobs(kind, state);
CREATE INDEX IF NOT EXISTS idx_jobs_created ON jobs(created_at); CREATE INDEX IF NOT EXISTS idx_jobs_created ON jobs(created_at);
""" """
def benenne_rechte_um(conn) -> None: # type: ignore[no-untyped-def]
"""Schreibt bestehende Gruppenrechte auf das neue Vokabular um.
``devices.view`` wird ``devices.read``, ``devices.edit`` wird
``devices.write``. Ohne diesen Schritt stehen in den Gruppen nur noch
Schluessel, die der Rechtebaum nicht kennt -- und
:meth:`PermissionTree.sanitize` wirft Unbekanntes **still** weg. Nach dem
Update waeren alle Gruppen leer und niemand kaeme mehr an seine Seiten:
kein Fehler, den man sieht, sondern einer, den man erst merkt, wenn jemand
anruft.
Im Kern und nicht in den Anwendungen, weil die Tabelle beiden gehoert.
Idempotent: was schon neu heisst, wird nicht angefasst.
"""
from .rbac.legacy_permissions import UMBENANNTE_AKTIONEN
for alt, neu in UMBENANNTE_AKTIONEN.items():
# Erst die Dubletten weg, die beim Umschreiben entstehen wuerden: haette
# eine Gruppe (aus welchem Grund auch immer) beide Schluessel, brach der
# eindeutige Index. Lieber vorher aufraeumen als in der Migration
# scheitern.
conn.execute(
"DELETE FROM group_permissions WHERE permission LIKE ? AND EXISTS ("
" SELECT 1 FROM group_permissions AS andere"
" WHERE andere.group_id = group_permissions.group_id"
" AND andere.permission = substr(group_permissions.permission, 1,"
" length(group_permissions.permission) - ?) || ?)",
(f"%.{alt}", len(alt), neu),
)
conn.execute(
"UPDATE group_permissions"
" SET permission = substr(permission, 1, length(permission) - ?) || ?"
" WHERE permission LIKE ?",
(len(alt), neu, f"%.{alt}"),
)
def zuordnung_auf_kennzeichen(conn) -> None: # type: ignore[no-untyped-def]
"""Erlaubt einer Verzeichnisgruppe, das Administratorkennzeichen zu setzen.
Bisher konnte eine Zuordnung nur auf eine lokale Gruppe zeigen. "Domänen-
Admins" landete deshalb in der Gruppe *Administration* -- und wurde damit
gerade **kein** echter Administrator: die Gruppe pflegt Benutzer und
Rechte, hat aber kein Kennzeichen und kann sich keines geben.
Zwei Aenderungen an der Tabelle, und SQLite kann Spalten nicht
umdefinieren: also neu anlegen und umkopieren. ``app_group_id`` wird
nullbar (eine Zuordnung auf das Kennzeichen zeigt auf keine Gruppe), und
``grants_admin`` sagt, dass genau das gemeint ist.
"""
spalten = {
str(zeile[1]) for zeile in conn.execute("PRAGMA table_info(ldap_group_mappings)")
}
if "grants_admin" in spalten:
return
conn.execute(
"""
CREATE TABLE ldap_group_mappings_neu (
id INTEGER PRIMARY KEY AUTOINCREMENT,
ad_group_dn TEXT NOT NULL UNIQUE,
ad_group_name TEXT NOT NULL,
app_group_id INTEGER REFERENCES groups(id) ON DELETE CASCADE,
grants_admin INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL
)
"""
)
conn.execute(
"INSERT INTO ldap_group_mappings_neu "
"(id, ad_group_dn, ad_group_name, app_group_id, grants_admin, created_at) "
"SELECT id, ad_group_dn, ad_group_name, app_group_id, 0, created_at "
" FROM ldap_group_mappings"
)
conn.execute("DROP TABLE ldap_group_mappings")
conn.execute("ALTER TABLE ldap_group_mappings_neu RENAME TO ldap_group_mappings")
def teile_rechte_auf(conn) -> None: # type: ignore[no-untyped-def]
"""Verteilt ein altes Sammelrecht auf die Zeilen, die daraus wurden.
Additiv: wer ``dhcp.edit`` hatte, bekommt alle fuenf DHCP-Zeilen, und das
alte Recht verschwindet. Ohne diesen Schritt stuende in der Gruppe ein
Schluessel, den der Baum nicht kennt -- und weg waere er still, denn genau
das tut :meth:`PermissionTree.sanitize` mit Unbekanntem.
Laeuft nach ``benenne_rechte_um``: die Tabelle nennt beide Schreibweisen,
wo es darauf ankommt, und ein zweiter Lauf findet nichts mehr zu tun.
"""
from .rbac.legacy_permissions import (
ERWEITERTE_RECHTE,
GETEILTE_RECHTE,
neue_schluessel,
)
def gruppen_mit(recht: str) -> list[int]:
return [
int(zeile[0])
for zeile in conn.execute(
"SELECT DISTINCT group_id FROM group_permissions WHERE permission=?",
(recht,),
).fetchall()
]
# Geteilt: das alte Recht gibt es nicht mehr, also ersetzen.
for altes, neue in GETEILTE_RECHTE.items():
gruppen = gruppen_mit(altes)
if not gruppen:
continue
conn.executemany(
"INSERT OR IGNORE INTO group_permissions (group_id, permission) VALUES (?,?)",
[(gid, recht) for gid in gruppen for recht in neue],
)
conn.execute("DELETE FROM group_permissions WHERE permission=?", (altes,))
# Erweitert: das Recht bleibt und bekommt dazu, was bisher an ihm hing.
# Loeschen waere hier falsch -- es ist keines von gestern.
for recht, dazu in ERWEITERTE_RECHTE.items():
gruppen = gruppen_mit(recht)
if not gruppen:
continue
conn.executemany(
"INSERT OR IGNORE INTO group_permissions (group_id, permission) VALUES (?,?)",
[(gid, weiteres) for gid in gruppen for weiteres in dazu],
)
assert neue_schluessel # in derselben Tabelle nachgeschlagen wie beim Import
CORE_MIGRATIONS: tuple[Migration, ...] = ( CORE_MIGRATIONS: tuple[Migration, ...] = (
Migration(version=1, name="baseline", sql=V1_BASELINE), Migration(
version=1,
name="baseline",
sql=V1_BASELINE,
geaendert_weil=(
"Das Grundschema legt ldap_group_mappings jetzt mit grants_admin und nullbarer app_group_id an -- eine neue Installation soll nicht erst core/6 brauchen, um die Tabelle wieder umzubauen."
),
),
Migration(version=2, name="trusted-issuer-keys", sql=V2_TRUSTED_KEYS), Migration(version=2, name="trusted-issuer-keys", sql=V2_TRUSTED_KEYS),
Migration(version=3, name="jobs", sql=V3_JOBS), Migration(version=3, name="jobs", sql=V3_JOBS),
Migration(version=4, name="rechte-read-write", fn=benenne_rechte_um),
Migration(version=5, name="rechte-je-funktion", fn=teile_rechte_auf),
Migration(version=6, name="ldap-kennzeichen", fn=zuordnung_auf_kennzeichen),
) )
+25 -2
View File
@@ -80,8 +80,8 @@ class Database:
"""Oeffnet eine neue Verbindung. Nicht aufrufen, solange eine Transaktion laeuft.""" """Oeffnet eine neue Verbindung. Nicht aufrufen, solange eine Transaktion laeuft."""
if self._depth() > 0: if self._depth() > 0:
raise NestedConnectionError( raise NestedConnectionError(
"Es laeuft bereits eine Schreibtransaktion auf dieser Datenbank. " "Es läuft bereits eine Schreibtransaktion auf dieser Datenbank. "
"Reiche die bestehende Verbindung durch, statt eine neue zu oeffnen " "Reiche die bestehende Verbindung durch, statt eine neue zu öffnen "
"(sonst blockiert SQLite sich selbst)." "(sonst blockiert SQLite sich selbst)."
) )
return self._raw_connect() return self._raw_connect()
@@ -119,6 +119,29 @@ class Database:
finally: finally:
conn.close() conn.close()
@contextmanager
def dauerhaft(self, conn: sqlite3.Connection) -> Iterator[sqlite3.Connection]:
"""Fuer Schreibvorgaenge, die einen Stromausfall ueberstehen muessen.
Im Alltag laeuft die Datenbank mit ``synchronous=NORMAL``: schnell, und
bei einem Stromausfall gehen hoechstens die letzten Sekunden verloren --
die Datei bleibt heil. Fuer einen Messwert ist das genau richtig.
Fuer eine Unterschrift nicht. Dort liegt die Datei anschliessend auf der
Freigabe, und fehlte der Eintrag dazu, waere sie "unbekannt" statt
nachweisbar. Ein fsync kostet ein paar Millisekunden -- bei ein paar
Unterschriften am Tag ist das keine Diskussion wert.
Umschliesst die Transaktion, nicht umgekehrt: ``PRAGMA synchronous``
wirkt auf den naechsten Commit, und gesetzt werden kann es nur
ausserhalb einer laufenden Transaktion.
"""
conn.execute("PRAGMA synchronous=FULL")
try:
yield conn
finally:
conn.execute("PRAGMA synchronous=NORMAL")
# ------------------------------------------------------------------ # # ------------------------------------------------------------------ #
# Transaktionen # Transaktionen
# ------------------------------------------------------------------ # # ------------------------------------------------------------------ #
@@ -79,7 +79,7 @@ def core(app: Flask | None = None) -> TesmCore:
try: try:
return target.extensions["tesm"] # type: ignore[return-value] return target.extensions["tesm"] # type: ignore[return-value]
except KeyError as exc: # pragma: no cover - Programmierfehler except KeyError as exc: # pragma: no cover - Programmierfehler
raise RuntimeError("tesm_core ist fuer diese Anwendung nicht initialisiert") from exc raise RuntimeError("tesm_core ist für diese Anwendung nicht initialisiert") from exc
def db() -> sqlite3.Connection: def db() -> sqlite3.Connection:
+6 -6
View File
@@ -78,7 +78,7 @@ def check_private_file(path: Path) -> list[str]:
except OSError: except OSError:
return problems return problems
if mode & (stat.S_IRGRP | stat.S_IROTH | stat.S_IWGRP | stat.S_IWOTH): if mode & (stat.S_IRGRP | stat.S_IROTH | stat.S_IWGRP | stat.S_IWOTH):
problems.append(f"{path} ist fuer Gruppe/Andere lesbar oder schreibbar (erwartet 0600)") problems.append(f"{path} ist für Gruppe/Andere lesbar oder schreibbar (erwartet 0600)")
return problems return problems
@@ -110,11 +110,11 @@ class Keystore:
created_at=str(entry.get("created_at", "")), created_at=str(entry.get("created_at", "")),
) )
if len(key.material) != _KEY_BYTES: if len(key.material) != _KEY_BYTES:
raise ValueError(f"Schluessel {key.key_id} hat die falsche Laenge") raise ValueError(f"Schlüssel {key.key_id} hat die falsche Länge")
self._keys[key.key_id] = key self._keys[key.key_id] = key
self._active = str(raw.get("active") or "") self._active = str(raw.get("active") or "")
if self._active not in self._keys: if self._active not in self._keys:
raise ValueError("Aktiver Schluessel fehlt im Schluesselbund") raise ValueError("Aktiver Schlüssel fehlt im Schlüsselbund")
return return
self._active = "1" self._active = "1"
self._keys = { self._keys = {
@@ -160,7 +160,7 @@ class Keystore:
def drop_key(self, key_id: str) -> None: def drop_key(self, key_id: str) -> None:
"""Entfernt einen alten Schluessel -- erst nach vollstaendiger Neuverschluesselung.""" """Entfernt einen alten Schluessel -- erst nach vollstaendiger Neuverschluesselung."""
if key_id == self._active: if key_id == self._active:
raise ValueError("Der aktive Schluessel kann nicht entfernt werden") raise ValueError("Der aktive Schlüssel kann nicht entfernt werden")
self._keys.pop(key_id, None) self._keys.pop(key_id, None)
self._persist() self._persist()
@@ -183,14 +183,14 @@ class Keystore:
raise DecryptionError(f"Unbekannte Chiffrat-Version: {prefix!r}") raise DecryptionError(f"Unbekannte Chiffrat-Version: {prefix!r}")
key = self._keys.get(key_id) key = self._keys.get(key_id)
if key is None: if key is None:
raise DecryptionError(f"Schluessel {key_id} ist nicht im Schluesselbund") raise DecryptionError(f"Schlüssel {key_id} ist nicht im Schlüsselbund")
try: try:
plain = AESGCM(key.material).decrypt( plain = AESGCM(key.material).decrypt(
_b64d(nonce_b64), _b64d(ct_b64), aad.encode("utf-8") _b64d(nonce_b64), _b64d(ct_b64), aad.encode("utf-8")
) )
except (InvalidTag, ValueError) as exc: except (InvalidTag, ValueError) as exc:
raise DecryptionError( raise DecryptionError(
"Chiffrat konnte nicht entschluesselt werden (falscher Schluessel, " "Chiffrat konnte nicht entschlüsselt werden (falscher Schlüssel, "
"manipulierte Daten oder falscher Verwendungszweck)" "manipulierte Daten oder falscher Verwendungszweck)"
) from exc ) from exc
return plain.decode("utf-8") return plain.decode("utf-8")
@@ -0,0 +1,242 @@
"""Alte, deutsche Adressen auf die neuen englischen umlenken.
Mit 2.5.0 folgen die Adressen der Navigation und sind englisch:
``/monitoring/host-keys`` statt ``/hostschluessel``. Das ist eine gute
Aenderung und eine unangenehme zugleich -- Lesezeichen, offene Browsertabs und
ein Link in einer Mail zeigen weiter auf die alte Adresse. Ein offener Tab ist
dabei der unangenehmste Fall: die Statusuebersicht fragt alle 20 Sekunden ihre
Kacheln ab und bekam nach dem Update jedes Mal einen 404.
Deshalb diese Tabelle. Sie greift **nur, wenn sonst ein 404 herauskaeme** --
also nie vor einer bestehenden Route. Das ist keine Feinheit: auf dem
Lizenzserver ist ``/lizenz/<ticket>`` eine echte, weiterhin gueltige Adresse
(sie steht in verschickten Mails), waehrend dieselbe Wurzel in TESM zur
Lizenzseite fuehrte. Wer zuerst das Routing fragt, muss sich nicht zwischen
beidem entscheiden.
Die Tabelle ist bewusst vollstaendig und nicht nur auf Wurzeln gekuerzt: eine
Umleitung von ``/dateifreigaben/x/vorschau`` auf eine Uebersichtsseite waere
zwar kein 404 mehr, aber auch nicht das, was jemand angeklickt hat.
"""
from __future__ import annotations
from typing import Mapping
#: Alte Wurzel -> (neue Wurzel, Uebersetzung der Segmente darunter).
WURZELN: dict[str, tuple[str, Mapping[str, str]]] = {
"/dateifreigaben": (
"/fileshares",
{
"herunterladen": "download",
"vorschau": "preview",
"hochladen": "upload",
"formular": "form",
"ordner": "folder",
"umbenennen": "rename",
"loeschen": "delete",
"freigeben": "release",
"zuordnungen": "mappings",
},
),
"/hostschluessel": (
"/monitoring/host-keys",
{"pruefen": "probe", "freigeben": "trust", "verwerfen": "forget", "verwaist": "orphaned"},
),
"/zugangsdaten": (
"/monitoring/credentials",
{"neu": "new", "loeschen": "delete", "anzeigen": "reveal"},
),
"/switche": (
"/monitoring/switches",
{
"neu": "new",
"loeschen": "delete",
"hostkey": "host-key",
"pruefen": "probe",
"freigeben": "trust",
"verwerfen": "forget",
"kommando": "command",
"konsole": "console",
},
),
"/clients": (
"/monitoring/clients",
{
"neu": "new",
"aktiv": "active",
"loeschen": "delete",
"neustart": "restart",
"hostkey": "host-key",
"pruefen": "probe",
"freigeben": "trust",
"verwerfen": "forget",
"konsole": "console",
},
),
"/wartung": (
"/maintenance",
{
"paket-nachinstallieren": "install-package",
"auftrag": "job",
"ausgabe": "output",
"aktualisieren": "update",
"neustarten": "restart",
},
),
"/protokolle": (
"/logs",
{
"kea": "dhcp",
"neustarts": "restarts",
"laufend": "live",
"inhalt": "content",
"verlauf": "history",
"datei": "file",
"archiv": "archive",
"archiv-export": "archive-export",
"archiv-loeschen": "archive-delete",
"aenderungen": "audit",
},
),
"/verwaltung": (
"/administration",
{
"benutzer": "users",
"neu": "new",
"loeschen": "delete",
"sitzungen-beenden": "end-sessions",
"verzeichnis-suche": "directory-search",
"verzeichnis-uebernehmen": "directory-import",
"gruppen": "groups",
"vorlage-anwenden": "apply-preset",
"sitzungen": "sessions",
"beenden": "end",
},
),
"/einstellungen": (
"/administration",
{
"zeitzone": "timezone",
"pruefung-jetzt": "check-now",
"netzwerk": "network",
"bestaetigen": "confirm",
"webserver": "web",
"uebernehmen": "apply",
"zertifikat": "certificate",
"selbstsigniert": "self-signed",
"verzeichnisdienst": "directory",
"gruppensuche": "group-search",
"zuordnung": "mapping",
"loeschen": "delete",
"anbieter": "vendor",
"logo-entfernen": "remove-logo",
},
),
"/sicherung": (
"/administration/backup",
{"vorschau": "preview", "uebernehmen": "apply", "verwerfen": "discard"},
),
"/papierkorb": (
"/administration/trash",
{"wiederherstellen": "restore", "endgueltig-loeschen": "purge", "leeren": "empty"},
),
"/diagnose": (
"/administration/diagnostics",
{"aufraeumen": "cleanup", "schluessel-rotieren": "rotate-keys"},
),
"/eigene-lizenz": (
"/own-license",
{
"einspielen": "install",
"aktivieren": "activate",
"deaktivieren": "deactivate",
"offline-antwort": "offline-response",
"signaturschluessel": "signing-key",
},
),
"/lizenzen": (
"/licenses",
{
"aus-ticket": "from-ticket",
"datei": "file",
"neu-signieren": "resign",
"widerrufen": "revoke",
"loeschen": "delete",
"versenden": "send",
"sub-lizenzserver": "sub-servers",
},
),
"/lizenz": (
"/administration/license",
{
"einspielen": "install",
"aktivieren": "activate",
"deaktivieren": "deactivate",
"offline-antwort": "offline-response",
"serverzertifikat": "server-certificate",
"aussteller": "issuer",
"entfernen": "remove",
},
),
"/kunden": ("/customers", {"neu": "new", "loeschen": "delete"}),
"/konto": (
"/account",
{
"stammdaten": "profile",
"profilbild": "avatar",
"sicherheit": "security",
"passwort": "password",
"zwei-faktor": "two-factor",
"starten": "start",
"bestaetigen": "confirm",
"deaktivieren": "disable",
"codes-erneuern": "recovery-codes",
"sitzungen": "sessions",
"beenden": "end",
"alle-beenden": "end-all",
},
),
"/uebersicht": ("/overview", {"kacheln": "tiles"}),
"/gesundheit": ("/health", {}),
"/bestaetigen": ("/confirm", {}),
}
#: Abfrageparameter, die mit umbenannt wurden.
PARAMETER = {
"freigabe": "share",
"pfad": "path",
"aktion": "action",
"benutzer": "actor",
"stufe": "severity",
"seite": "page",
"methode": "method",
"kunde": "customer",
"freischalten": "unlocked",
}
#: Wurzeln, unter denen eine unbekannte Unterseite **nicht** umgelenkt wird.
#: ``/lizenz/<ticket>`` ist auf dem Lizenzserver eine echte Adresse; ein
#: abgelaufenes oder falsches Ticket gehoert dort auf die Fehlerseite und nicht
#: in die Verwaltung.
STRENG = {"/lizenz"}
def neue_adresse(pfad: str) -> str | None:
"""Uebersetzt eine alte Adresse -- oder ``None``, wenn es keine ist."""
for alt, (neu, segmente) in WURZELN.items():
if pfad != alt and not pfad.startswith(alt + "/"):
continue
rest = pfad[len(alt) :]
teile = rest.split("/")
if alt in STRENG:
erstes = next((teil for teil in teile if teil), "")
if erstes and erstes not in segmente:
return None
return neu + "/".join(segmente.get(teil, teil) for teil in teile)
return None
def neue_parameter(args: Mapping[str, str]) -> dict[str, str]:
return {PARAMETER.get(name, name): wert for name, wert in args.items()}
@@ -34,7 +34,7 @@ import urllib.request
from dataclasses import dataclass, field from dataclasses import dataclass, field
from datetime import datetime, timezone from datetime import datetime, timezone
from pathlib import Path from pathlib import Path
from typing import Any, Callable from typing import Any, Callable, Iterable
import tesm_licensing as lic import tesm_licensing as lic
@@ -54,6 +54,21 @@ SETTING_HEARTBEAT_ERROR = "license_heartbeat_error"
EXPIRY_HINT_DAYS = 90 EXPIRY_HINT_DAYS = 90
SETTING_EXPORT_GRACE = "license_export_grace" SETTING_EXPORT_GRACE = "license_export_grace"
#: Module, die der Betreiber vor Ort abgeschaltet hat (Liste von Namen).
#:
#: Die Lizenz sagt, *was erlaubt ist*; sie sagt nicht, was ein Haus auch
#: benutzen will. Wer kein DHCP ueber TESM fahren will, weil ein anderer Server
#: das tut, soll den Punkt nicht wegdruecken muessen -- und vor allem soll
#: niemand versehentlich eine fremde Kea-Konfiguration ueberschreiben koennen.
#: Abschalten ist deshalb mehr als Aufraeumen der Navigation: die Sperre sitzt
#: an derselben Stelle wie die Lizenzpruefung, also vor jeder Route.
#: Bewusst **ohne** das Praefix ``license_``: Sicherungen lassen alles mit
#: diesem Praefix draussen, weil Lizenz- und Aktivierungszustand zur Maschine
#: gehoeren und nicht in ein Backup. Der Schalter ist aber keine Lizenzangabe,
#: sondern eine Entscheidung des Hauses -- die soll eine Wiederherstellung
#: mitbringen.
SETTING_MODULES_OFF = "modules_disabled"
STATUS_INACTIVE = "inactive" STATUS_INACTIVE = "inactive"
STATUS_PENDING = "pending" STATUS_PENDING = "pending"
STATUS_ACTIVE = "active" STATUS_ACTIVE = "active"
@@ -68,10 +83,10 @@ API_PATHS = {
#: die einzige Wahrheit -- Templates lesen sie, Routen erzwingen sie. #: die einzige Wahrheit -- Templates lesen sie, Routen erzwingen sie.
LICENSED_FEATURES: dict[str, str] = { LICENSED_FEATURES: dict[str, str] = {
"directory": "Verzeichnisanmeldung (LDAP/AD) konfigurieren", "directory": "Verzeichnisanmeldung (LDAP/AD) konfigurieren",
"nginx": "Webserver-Einstellungen aendern", "nginx": "Webserver-Einstellungen ändern",
"export": "Daten exportieren", "export": "Daten exportieren",
"trash_restore": "Papierkorb wiederherstellen und endgueltig loeschen", "trash_restore": "Papierkorb wiederherstellen und endgültig löschen",
"monitor_interval": "Pruefintervall der Ueberwachung aendern", "monitor_interval": "Prüfintervall der Überwachung ändern",
"auto_poe_restart": "Automatischer PoE-Neustart bei Ausfall", "auto_poe_restart": "Automatischer PoE-Neustart bei Ausfall",
} }
@@ -85,7 +100,7 @@ def describe_transport_error(exc: BaseException, endpoint: str) -> str:
""" """
text = str(exc) text = str(exc)
if "CERTIFICATE_VERIFY_FAILED" in text or "certificate verify failed" in text: if "CERTIFICATE_VERIFY_FAILED" in text or "certificate verify failed" in text:
detail = "Das Zertifikat konnte nicht geprueft werden" detail = "Das Zertifikat konnte nicht geprüft werden"
if "self-signed" in text or "self signed" in text: if "self-signed" in text or "self signed" in text:
detail = "Der Lizenzserver benutzt ein selbstsigniertes Zertifikat" detail = "Der Lizenzserver benutzt ein selbstsigniertes Zertifikat"
return ( return (
@@ -94,7 +109,7 @@ def describe_transport_error(exc: BaseException, endpoint: str) -> str:
"einer anerkannten Stelle." "einer anerkannten Stelle."
) )
if "Name or service not known" in text or "nodename nor servname" in text: if "Name or service not known" in text or "nodename nor servname" in text:
return f"Der Name im Endpunkt ({endpoint}) laesst sich nicht aufloesen." return f"Der Name im Endpunkt ({endpoint}) lässt sich nicht auflösen."
if "Connection refused" in text: if "Connection refused" in text:
return f"{endpoint} nimmt keine Verbindung an." return f"{endpoint} nimmt keine Verbindung an."
if "timed out" in text or "timeout" in text.lower(): if "timed out" in text or "timeout" in text.lower():
@@ -121,6 +136,8 @@ class LicenseState:
last_heartbeat_at: str = "" last_heartbeat_at: str = ""
heartbeat_error: str = "" heartbeat_error: str = ""
pending_action: str = "" pending_action: str = ""
#: Vor Ort abgeschaltet -- unabhaengig davon, was die Lizenz erlaubt.
modules_off: frozenset[str] = frozenset()
@property @property
def role(self) -> str: def role(self) -> str:
@@ -128,8 +145,14 @@ class LicenseState:
@property @property
def modules(self) -> frozenset[str]: def modules(self) -> frozenset[str]:
"""Was die Lizenz freischaltet -- ohne Ruecksicht auf den Schalter."""
return self.status.modules if self.status else frozenset() return self.status.modules if self.status else frozenset()
@property
def modules_in_use(self) -> frozenset[str]:
"""Was tatsaechlich laeuft: lizenziert **und** nicht abgeschaltet."""
return self.modules - self.modules_off
@property @property
def customer_name(self) -> str: def customer_name(self) -> str:
return str((self.status.customer if self.status else {}).get("name") or "") return str((self.status.customer if self.status else {}).get("name") or "")
@@ -139,6 +162,67 @@ class LicenseState:
return dict(self.status.vendor) if self.status else {} return dict(self.status.vendor) if self.status else {}
#: Ausstiegscodes der Abfrage -- danach schlaegt ein RMM Alarm, nicht nach Text.
ABFRAGE_OK = 0
ABFRAGE_LAEUFT_AB = 1
ABFRAGE_UNGUELTIG = 2
#: Ab hier gilt eine Lizenz als "laeuft bald ab". Dieselbe Zahl wie in der
#: Oberflaeche -- zwei Schwellen fuer dieselbe Frage waeren eine zu viel.
ABFRAGE_WARNSCHWELLE_TAGE = 30
def zustand_als_auskunft(state: "LicenseState") -> dict[str, Any]:
"""Der Lizenzzustand als schlichtes Woerterbuch -- fuer JSON und Skripte.
Gedacht fuer ein RMM, das auf der Maschine selbst laeuft: kein Netz, kein
Token, keine Anmeldung. Was hier steht, beantwortet die eine Frage, die ein
Kunde wirklich hat -- wie lange laeuft die Lizenz noch --, und nichts
darueber hinaus: keine Schluessel, keine Kennungen anderer Installationen.
"""
status = state.status
return {
"valid": bool(state.valid),
"activated": bool(state.activated),
"type": str(status.type) if status else "",
"role": state.role,
"lifetime": bool(status.lifetime) if status else False,
"expires_at": lic.iso(status.expires_at) if status and status.expires_at else "",
# Bei einer unbefristeten Lizenz ist die Zahl bewusst ``null`` und
# keine grosse Zahl: "noch 99999 Tage" waere eine Behauptung.
"days_left": (status.days_left if status and not status.lifetime else None),
"expiring_soon": bool(status.expiring_soon) if status else False,
"grace_active": bool(status.grace_active) if status else False,
# Drei Angaben, weil zwei davon verschieden sein koennen und die
# Verwechslung teuer ist: "modules" ist, was die **Lizenz** freigibt --
# dieselbe Liste, die der Lizenzserver zeigt. Was davon vor Ort
# abgeschaltet wurde, steht daneben.
#
# Vorher stand hier allein das Laufende. Beim Kunden fehlte darin
# "dhcp", waehrend die Lizenzseite es auswies -- es war lediglich unter
# Verwaltung -> System abgeschaltet. Aus der Antwort ging das nicht
# hervor, und damit sah es nach einem Lizenzproblem aus.
"modules": sorted(state.modules),
"modules_active": sorted(state.modules_in_use),
"modules_off": sorted(state.modules_off),
"customer": state.customer_name,
"last_heartbeat_at": state.last_heartbeat_at,
"error": state.error,
}
def abfrage_ausstiegscode(auskunft: dict[str, Any]) -> int:
"""0 heil, 1 laeuft bald ab, 2 ungueltig -- die Sprache eines RMM."""
if not auskunft.get("valid"):
return ABFRAGE_UNGUELTIG
if auskunft.get("lifetime"):
return ABFRAGE_OK
tage = auskunft.get("days_left")
if tage is None or int(tage) <= ABFRAGE_WARNSCHWELLE_TAGE:
return ABFRAGE_LAEUFT_AB
return ABFRAGE_OK
class LicenseManager: class LicenseManager:
"""Haelt den Lizenzzustand und fuehrt die Handshakes durch.""" """Haelt den Lizenzzustand und fuehrt die Handshakes durch."""
@@ -179,6 +263,7 @@ class LicenseManager:
"""Liest Lizenzdatei und Status neu ein.""" """Liest Lizenzdatei und Status neu ein."""
with self._lock: with self._lock:
state = LicenseState() state = LicenseState()
state.modules_off = self.abgeschaltete_module(conn)
state.last_heartbeat_at = str(self.settings.get(conn, SETTING_LAST_HEARTBEAT) or "") state.last_heartbeat_at = str(self.settings.get(conn, SETTING_LAST_HEARTBEAT) or "")
state.heartbeat_error = str(self.settings.get(conn, SETTING_HEARTBEAT_ERROR) or "") state.heartbeat_error = str(self.settings.get(conn, SETTING_HEARTBEAT_ERROR) or "")
pending = self._pending(conn) pending = self._pending(conn)
@@ -239,9 +324,51 @@ class LicenseManager:
) )
def module_active(self, name: str) -> bool: def module_active(self, name: str) -> bool:
"""Ist ein Vollmodul (dhcp/fileshare/maintenance) freigeschaltet?""" """Ist ein Vollmodul (dhcp/fileshare/maintenance) in Betrieb?
Zwei Bedingungen, und beide muessen gelten: die Lizenz muss es
freischalten, und der Betreiber darf es nicht abgeschaltet haben. Diese
eine Funktion fragen die Navigation, jede Route und die Hintergrund-
aufgaben -- darum sitzt der Schalter hier und nicht in einer Vorlage.
"""
return (
self.active()
and name in self._state.modules
and name not in self._state.modules_off
)
def module_licensed(self, name: str) -> bool:
"""Schaltet die Lizenz das Modul frei? (Ohne den Schalter vor Ort.)"""
return self.active() and name in self._state.modules return self.active() and name in self._state.modules
def abgeschaltete_module(self, conn: sqlite3.Connection) -> frozenset[str]:
"""Liest die Liste aus den Einstellungen."""
gespeichert = self.settings.get(conn, SETTING_MODULES_OFF) or []
if isinstance(gespeichert, str): # Altbestand: ein einzelner Name
gespeichert = [gespeichert]
return frozenset(str(name) for name in gespeichert)
def module_abschalten(
self, conn: sqlite3.Connection, namen: Iterable[str], *, actor: str = ""
) -> tuple[frozenset[str], frozenset[str]]:
"""Setzt die Liste der abgeschalteten Module neu.
Gibt ``(eingeschaltet, abgeschaltet)`` zurueck -- also nur das, was sich
wirklich geaendert hat; der Aufrufer schreibt daraus seinen
Protokolleintrag.
Der Zustand wird hier nicht neu eingelesen, sondern nur an dieser einen
Stelle nachgezogen: ein voller Neuaufbau wuerde die Lizenzdatei erneut
pruefen, obwohl sich an ihr nichts geaendert hat -- und der Schalter ist
das Einzige, was jetzt anders ist.
"""
vorher = self.abgeschaltete_module(conn)
nachher = frozenset(str(name) for name in namen)
self.settings.set(conn, SETTING_MODULES_OFF, sorted(nachher), actor=actor)
with self._lock:
self._state.modules_off = nachher
return vorher - nachher, nachher - vorher
def feature_allowed(self, feature: str) -> bool: def feature_allowed(self, feature: str) -> bool:
"""Lizenzpflichtige Einzelfunktion. """Lizenzpflichtige Einzelfunktion.
@@ -257,8 +384,8 @@ class LicenseManager:
def require(self, feature: str) -> None: def require(self, feature: str) -> None:
if not self.feature_allowed(feature): if not self.feature_allowed(feature):
raise LicenseError( raise LicenseError(
f"{LICENSED_FEATURES.get(feature, feature)} ist ohne gueltige, " f"{LICENSED_FEATURES.get(feature, feature)} ist ohne gültige, "
"aktivierte Lizenz nicht moeglich." "aktivierte Lizenz nicht möglich."
) )
# ------------------------------------------------------------------ # # ------------------------------------------------------------------ #
@@ -284,7 +411,7 @@ class LicenseManager:
try: try:
payload = json.loads(text) payload = json.loads(text)
except ValueError as exc: except ValueError as exc:
raise LicenseError("Die Datei ist kein gueltiges JSON.") from exc raise LicenseError("Die Datei ist kein gültiges JSON.") from exc
try: try:
document, client_key = lic.split_bundle(payload) document, client_key = lic.split_bundle(payload)
@@ -294,20 +421,20 @@ class LicenseManager:
try: try:
lic.verify_license(document) # innere Konsistenz lic.verify_license(document) # innere Konsistenz
except lic.LicensingError as exc: except lic.LicensingError as exc:
raise LicenseError(f"Die Lizenzdatei ist ungueltig: {exc}") from exc raise LicenseError(f"Die Lizenzdatei ist ungültig: {exc}") from exc
master_pubkey = str(document.get("master_pubkey") or "") master_pubkey = str(document.get("master_pubkey") or "")
known = self.trusted_keys(conn) known = self.trusted_keys(conn)
if known and master_pubkey not in set(known) and not allow_new_issuer: if known and master_pubkey not in set(known) and not allow_new_issuer:
raise LicenseError( raise LicenseError(
"Diese Lizenz wurde von einem anderen Lizenzserver ausgestellt " "Diese Lizenz wurde von einem anderen Lizenzserver ausgestellt "
f"(Schluessel {lic.key_id(master_pubkey)}). Ein Administrator muss den " f"(Schlüssel {lic.key_id(master_pubkey)}). Ein Administrator muss den "
"Wechsel des Ausstellers ausdruecklich bestaetigen." "Wechsel des Ausstellers ausdrücklich bestätigen."
) )
if not client_key and not self.key_path.is_file(): if not client_key and not self.key_path.is_file():
raise LicenseError( raise LicenseError(
"Der Datei fehlt der Client-Schluessel. Bitte das vollstaendige " "Der Datei fehlt der Client-Schlüssel. Bitte das vollständige "
"Lizenz-Bundle des Lizenzservers verwenden." "Lizenz-Bundle des Lizenzservers verwenden."
) )
@@ -332,7 +459,7 @@ class LicenseManager:
actor=actor, actor=actor,
action="license.issuer_pinned", action="license.issuer_pinned",
target=lic.key_id(master_pubkey), target=lic.key_id(master_pubkey),
detail="Trust on first use" if not known else "Aussteller-Wechsel bestaetigt", detail="Trust on first use" if not known else "Aussteller-Wechsel bestätigt",
severity="notice", severity="notice",
) )
@@ -397,11 +524,11 @@ class LicenseManager:
self, action: str, *, hostname: str = "", report: dict[str, Any] | None = None self, action: str, *, hostname: str = "", report: dict[str, Any] | None = None
) -> dict[str, Any]: ) -> dict[str, Any]:
if not self._state.valid: if not self._state.valid:
raise LicenseError("Es ist keine gueltige Lizenz installiert.") raise LicenseError("Es ist keine gültige Lizenz installiert.")
key = self.client_key() key = self.client_key()
if not key: if not key:
raise LicenseError( raise LicenseError(
"Der Client-Schluessel dieser Lizenz fehlt. Bitte die Lizenzdatei erneut importieren." "Der Client-Schlüssel dieser Lizenz fehlt. Bitte die Lizenzdatei erneut importieren."
) )
return lic.build_client_request( return lic.build_client_request(
action, self._state.document, key, hostname=hostname, report=report action, self._state.document, key, hostname=hostname, report=report
@@ -442,8 +569,8 @@ class LicenseManager:
) )
else: else:
reason = ( reason = (
"In dieser Lizenz ist kein Lizenzserver hinterlegt -- sie kann sich nur " "In dieser Lizenz ist kein Lizenzserver hinterlegt – sie kann sich nur "
"ueber den Offline-Weg aktivieren." "über den Offline-Weg aktivieren."
) )
# Offline-Weg: Anfrage merken -- **mit** dem Code. Zwischen Anfrage und # Offline-Weg: Anfrage merken -- **mit** dem Code. Zwischen Anfrage und
@@ -475,7 +602,7 @@ class LicenseManager:
"code": code, "code": code,
"message": ( "message": (
(reason + " " if reason else "") (reason + " " if reason else "")
+ "Der Offline-Weg steht offen: uebermitteln Sie den angezeigten Code an " + "Der Offline-Weg steht offen: übermitteln Sie den angezeigten Code an "
"Ihren Ansprechpartner und tragen Sie die Antwort hier ein." "Ihren Ansprechpartner und tragen Sie die Antwort hier ein."
), ),
} }
@@ -527,7 +654,7 @@ class LicenseManager:
current_seq=current_seq, current_seq=current_seq,
) )
except lic.LicensingError as exc: except lic.LicensingError as exc:
raise LicenseError(f"Die Antwort des Lizenzservers ist nicht gueltig: {exc}") from exc raise LicenseError(f"Die Antwort des Lizenzservers ist nicht gültig: {exc}") from exc
status = str(response.get("status") or "") status = str(response.get("status") or "")
detail = str(response.get("detail") or "") detail = str(response.get("detail") or "")
@@ -597,7 +724,7 @@ class LicenseManager:
actor=actor, actor=actor,
) )
self.settings.set(conn, SETTING_HEARTBEAT_ERROR, "", actor=actor) self.settings.set(conn, SETTING_HEARTBEAT_ERROR, "", actor=actor)
message = "Verbindung zum Lizenzserver bestaetigt." message = "Verbindung zum Lizenzserver bestätigt."
self.settings.set(conn, SETTING_PENDING, "", actor=actor) self.settings.set(conn, SETTING_PENDING, "", actor=actor)
# Ein erfolgreicher Heartbeat laeuft alle paar Minuten und ist kein # Ein erfolgreicher Heartbeat laeuft alle paar Minuten und ist kein
@@ -648,7 +775,7 @@ class LicenseManager:
url: str, payload: dict[str, Any], *, timeout: int, ca_pem: str = "" url: str, payload: dict[str, Any], *, timeout: int, ca_pem: str = ""
) -> dict[str, Any]: ) -> dict[str, Any]:
if not url.startswith(("https://", "http://")): if not url.startswith(("https://", "http://")):
raise ValueError("Ungueltiger Endpunkt") raise ValueError("Ungültiger Endpunkt")
data = json.dumps(payload).encode("utf-8") data = json.dumps(payload).encode("utf-8")
request = urllib.request.Request( # noqa: S310 - Schema oben geprueft request = urllib.request.Request( # noqa: S310 - Schema oben geprueft
url, url,
@@ -748,7 +875,7 @@ class LicenseManager:
} }
if not state.valid: if not state.valid:
return { return {
"label": "Lizenz ungueltig", "label": "Lizenz ungültig",
"css_class": "status-pill--danger", "css_class": "status-pill--danger",
"dot": "1", "dot": "1",
"dot_class": "status-pill__dot--pulse", "dot_class": "status-pill__dot--pulse",
@@ -773,29 +900,29 @@ class LicenseManager:
"dot": "1", "dot": "1",
"dot_class": "status-pill__dot--pulse", "dot_class": "status-pill__dot--pulse",
"title": ( "title": (
"Die Kulanzfrist laeuft noch, Funktionen bleiben vorerst verfuegbar." "Die Kulanzfrist läuft noch, Funktionen bleiben vorerst verfügbar."
if status.grace_active if status.grace_active
else "Lizenzpflichtige Funktionen sind abgeschaltet." else "Lizenzpflichtige Funktionen sind abgeschaltet."
), ),
} }
if status.expiring_soon: if status.expiring_soon:
return { return {
"label": f"Lizenz laeuft in {status.days_left} Tagen ab", "label": f"Lizenz läuft in {status.days_left} Tagen ab",
"css_class": "status-pill--warning", "css_class": "status-pill--warning",
"dot": "1", "dot": "1",
"dot_class": "status-pill__dot--pulse", "dot_class": "status-pill__dot--pulse",
"title": "Bitte rechtzeitig verlaengern.", "title": "Bitte rechtzeitig verlängern.",
} }
# Zwischen der Warnschwelle und 90 Tagen: gruen, rein als Hinweis. Wer # Zwischen der Warnschwelle und 90 Tagen: gruen, rein als Hinweis. Wer
# eine Verlaengerung im Voraus plant, will das Datum sehen, ohne dass es # eine Verlaengerung im Voraus plant, will das Datum sehen, ohne dass es
# nach einem Problem aussieht. # nach einem Problem aussieht.
if status.days_left is not None and 0 < status.days_left <= EXPIRY_HINT_DAYS: if status.days_left is not None and 0 < status.days_left <= EXPIRY_HINT_DAYS:
return { return {
"label": f"Lizenz laeuft in {status.days_left} Tagen ab", "label": f"Lizenz läuft in {status.days_left} Tagen ab",
"css_class": "", "css_class": "",
"dot": "1", "dot": "1",
"dot_class": "", "dot_class": "",
"title": "Nur ein Hinweis -- es ist nichts eingeschraenkt.", "title": "Nur ein Hinweis – es ist nichts eingeschränkt.",
} }
if state.last_heartbeat_at: if state.last_heartbeat_at:
try: try:
@@ -809,6 +936,6 @@ class LicenseManager:
"css_class": "status-pill--warning", "css_class": "status-pill--warning",
"dot": "1", "dot": "1",
"dot_class": "", "dot_class": "",
"title": "Rein informativ -- es wird nichts abgeschaltet.", "title": "Rein informativ – es wird nichts abgeschaltet.",
} }
return None return None
@@ -94,7 +94,7 @@ def init_app(app: Flask, *, log_dir: Path, debug: bool, json_logs: bool = False)
except OSError: except OSError:
pass pass
except OSError as exc: # pragma: no cover - z. B. fehlende Rechte except OSError as exc: # pragma: no cover - z. B. fehlende Rechte
root.warning("Konnte app.log nicht oeffnen: %s", exc) root.warning("Konnte app.log nicht öffnen: %s", exc)
# Zugriffsprotokoll separat: eine Zeile pro Anfrage, ohne Stacktraces. # Zugriffsprotokoll separat: eine Zeile pro Anfrage, ohne Stacktraces.
access = logging.getLogger(ACCESS_LOGGER) access = logging.getLogger(ACCESS_LOGGER)
+34 -12
View File
@@ -50,6 +50,12 @@ class Migration:
sql: str = "" sql: str = ""
fn: MigrationFn | None = None fn: MigrationFn | None = None
allow_integrity_error: bool = False allow_integrity_error: bool = False
#: Begruendung, falls der Text einer bereits angewendeten Migration
#: absichtlich geaendert wurde -- etwa weil eine frische Installation
#: mit der neuen Schreibweise starten muss. Ohne Begruendung warnt der
#: Lauf, und das soll er auch: ein stiller Umbau einer angewendeten
#: Migration ist fast immer ein Versehen.
geaendert_weil: str = ""
tags: tuple[str, ...] = field(default=()) tags: tuple[str, ...] = field(default=())
@property @property
@@ -175,17 +181,33 @@ class MigrationRunner:
record = known.get(migration.version) record = known.get(migration.version)
if record is not None: if record is not None:
if record["checksum"] != migration.checksum: if record["checksum"] != migration.checksum:
logger.warning( if migration.geaendert_weil:
"Migration %s/%s (%s) wurde nachtraeglich geaendert " # Mit Begruendung ist es eine Notiz, keine Warnung:
"(Pruefsumme %s != %s). Bereits angewendete Migrationen werden " # manchmal muss der Text einer laengst angewendeten
"nicht erneut ausgefuehrt -- neue Aenderungen brauchen eine neue " # Migration mit, damit eine **frische** Installation
"Versionsnummer.", # denselben Stand erreicht. Wer das Protokoll liest,
component, # soll den Unterschied zu einem Versehen sehen.
migration.version, logger.info(
migration.name, "Migration %s/%s (%s) wurde absichtlich geändert: %s "
record["checksum"], "Auf dieser Installation ist sie längst angewendet und "
migration.checksum, "wird nicht erneut ausgeführt.",
) component,
migration.version,
migration.name,
migration.geaendert_weil,
)
else:
logger.warning(
"Migration %s/%s (%s) wurde nachträglich geändert "
"(Prüfsumme %s != %s). Bereits angewendete Migrationen werden "
"nicht erneut ausgeführt – neue Änderungen brauchen eine neue "
"Versionsnummer.",
component,
migration.version,
migration.name,
record["checksum"],
migration.checksum,
)
continue continue
try: try:
@@ -210,7 +232,7 @@ class MigrationRunner:
raise raise
logger.error( logger.error(
"Migration %s/%s (%s) konnte wegen bestehender Altdaten nicht " "Migration %s/%s (%s) konnte wegen bestehender Altdaten nicht "
"angewendet werden. Der Start laeuft weiter; die Daten muessen " "angewendet werden. Der Start läuft weiter; die Daten müssen "
"manuell bereinigt werden.", "manuell bereinigt werden.",
component, component,
migration.version, migration.version,
+57 -33
View File
@@ -16,10 +16,10 @@ A = Action
USERS = Resource( USERS = Resource(
key="users", key="users",
label="Benutzer", label="Benutzer",
actions=(A.VIEW, A.CREATE, A.EDIT, A.DELETE), actions=(A.READ, A.CREATE, A.WRITE, A.DELETE),
hint="Konten anlegen, sperren, Gruppen zuordnen", hint="Konten anlegen, sperren, Gruppen zuordnen",
action_hints={ action_hints={
A.EDIT: "Stammdaten, Sperre und Gruppenzuordnung aendern.", A.WRITE: "Stammdaten, Sperre und Gruppenzuordnung ändern.",
A.DELETE: "Konto in den Papierkorb legen (Verzeichniskonten sofort entfernen).", A.DELETE: "Konto in den Papierkorb legen (Verzeichniskonten sofort entfernen).",
}, },
) )
@@ -27,35 +27,46 @@ USERS = Resource(
GROUPS = Resource( GROUPS = Resource(
key="groups", key="groups",
label="Gruppen und Rechte", label="Gruppen und Rechte",
actions=(A.VIEW, A.CREATE, A.EDIT, A.DELETE), actions=(A.READ, A.CREATE, A.WRITE, A.DELETE),
hint="Rechtematrix pflegen", hint="Rechtematrix pflegen",
action_hints={ action_hints={
A.EDIT: "Rechte vergeben -- nur Rechte, die man selbst besitzt.", A.WRITE: "Rechte vergeben – nur Rechte, die man selbst besitzt.",
}, },
) )
SETTINGS_SYSTEM = Resource( SETTINGS_SYSTEM = Resource(
key="settings_system", key="settings_system",
label="Systemeinstellungen", label="Systemeinstellungen",
actions=(A.VIEW, A.EDIT, A.EXECUTE), actions=(A.READ, A.WRITE, A.EXECUTE),
hint="Hostname, Zeitzone, Protokollrotation, Navigation", hint="Hostname, Zeitzone, Protokollrotation, Sitzungsdauer",
action_hints={A.EXECUTE: "Systemaktionen ausloesen (Dienste, Netzwerk uebernehmen)."}, action_hints={A.EXECUTE: "Systemaktionen auslösen (Dienste, Netzwerk übernehmen)."},
)
#: Die Reihenfolge des Menues lief unter "Systemaktionen auslösen" mit -- also
#: unter demselben Recht wie das Aendern des Hostnamens. Es ist aber das
#: harmloseste, was man in der Verwaltung tun kann, und das haeufigste.
SETTINGS_NAV = Resource(
key="settings_nav",
parent="settings_system",
label="Navigation",
actions=(A.READ, A.WRITE),
hint="Reihenfolge der Menüpunkte und ihrer Unterpunkte",
) )
SETTINGS_LICENSE = Resource( SETTINGS_LICENSE = Resource(
key="settings_license", key="settings_license",
label="Lizenz", label="Lizenz",
actions=(A.VIEW, A.EDIT), actions=(A.READ, A.WRITE),
hint="Lizenzdatei einspielen, aktivieren, deaktivieren", hint="Lizenzdatei einspielen, aktivieren, deaktivieren",
) )
SETTINGS_DIRECTORY = Resource( SETTINGS_DIRECTORY = Resource(
key="settings_directory", key="settings_directory",
label="Verzeichnisdienst (LDAP/AD)", label="Verzeichnisdienst (LDAP/AD)",
actions=(A.VIEW, A.EDIT, A.EXECUTE, A.SECRETS), actions=(A.READ, A.WRITE, A.EXECUTE, A.SECRETS),
hint="Anbindung und Gruppen-Zuordnung", hint="Anbindung und Gruppen-Zuordnung",
action_hints={ action_hints={
A.EXECUTE: "Verbindungstest ausfuehren.", A.EXECUTE: "Verbindungstest ausführen.",
A.SECRETS: "Bind-Passwort des Dienstkontos setzen.", A.SECRETS: "Bind-Passwort des Dienstkontos setzen.",
}, },
) )
@@ -63,67 +74,78 @@ SETTINGS_DIRECTORY = Resource(
SETTINGS_WEB = Resource( SETTINGS_WEB = Resource(
key="settings_web", key="settings_web",
label="Webserver und TLS", label="Webserver und TLS",
actions=(A.VIEW, A.EDIT, A.EXECUTE), actions=(A.READ, A.WRITE, A.EXECUTE),
hint="Domain, Zertifikat, HSTS", hint="Domain, Zertifikat, HSTS",
action_hints={A.EXECUTE: "Konfiguration uebernehmen, Zertifikat anfordern."}, action_hints={A.EXECUTE: "Konfiguration übernehmen, Zertifikat anfordern."},
) )
SETTINGS_NETWORK = Resource( SETTINGS_NETWORK = Resource(
key="settings_network", key="settings_network",
label="Netzwerkeinstellungen", label="Netzwerkeinstellungen",
actions=(A.VIEW, A.EDIT), actions=(A.READ, A.WRITE),
hint="IP-Adresse, Gateway, DNS des Hosts", hint="IP-Adresse, Gateway, DNS des Hosts",
) )
BACKUP = Resource( #: Sichern und Einspielen sind zwei Zeilen, weil es zwei Vorgaenge mit sehr
key="backup", #: verschiedenen Folgen sind: eine Sicherung zu erzeugen aendert nichts, eine
label="Sicherung und Uebertragung", #: einzuspielen aendert alles. Sie liefen unter einem Recht, und wer eine
actions=(A.VIEW, A.EXECUTE, A.EXPORT, A.SECRETS), #: Sicherung ziehen durfte, durfte damit auch eine fremde einspielen.
hint="Verschluesselter Export und Import", BACKUP_EXPORT = Resource(
key="backup_export",
label="Sicherung erstellen",
actions=(A.READ, A.EXPORT, A.SECRETS),
hint="Verschlüsselter Export des Datenstands",
action_hints={ action_hints={
A.EXECUTE: "Eine Sicherung einspielen.",
A.EXPORT: "Sicherung erzeugen und herunterladen.", A.EXPORT: "Sicherung erzeugen und herunterladen.",
A.SECRETS: "Zugangsdaten in die Sicherung einschliessen.", A.SECRETS: "Zugangsdaten in die Sicherung einschließen.",
}, },
) )
BACKUP_IMPORT = Resource(
key="backup_import",
label="Sicherung einspielen",
actions=(A.READ, A.EXECUTE),
hint="Prüfen, übernehmen, verwerfen",
action_hints={A.EXECUTE: "Eine geprüfte Sicherung anwenden."},
)
TRASH = Resource( TRASH = Resource(
key="trash", key="trash",
label="Papierkorb", label="Papierkorb",
actions=(A.VIEW, A.EDIT, A.DELETE), actions=(A.READ, A.WRITE, A.DELETE),
hint="Geloeschtes wiederherstellen oder endgueltig entfernen", hint="Gelöschtes wiederherstellen oder endgültig entfernen",
action_hints={ action_hints={
A.EDIT: "Eintraege wiederherstellen.", A.WRITE: "Einträge wiederherstellen.",
A.DELETE: "Eintraege endgueltig entfernen.", A.DELETE: "Einträge endgültig entfernen.",
}, },
) )
DIAGNOSTICS = Resource( DIAGNOSTICS = Resource(
key="diagnostics", key="diagnostics",
label="Diagnose", label="Diagnose",
actions=(A.VIEW,), actions=(A.READ,),
hint="Schemastand, Protokollintegritaet, Schluesselrechte", hint="Schemastand, Protokollintegrität, Schlüsselrechte",
) )
LOGS_LIVE = Resource( LOGS_LIVE = Resource(
key="logs_live", key="logs_live",
label="Laufendes Protokoll", label="Live-Protokoll",
actions=(A.VIEW,), actions=(A.READ,),
) )
LOGS_HISTORY = Resource( LOGS_HISTORY = Resource(
key="logs_history", key="logs_history",
label="Protokollverlauf", label="Protokollverlauf",
actions=(A.VIEW, A.DELETE, A.EXPORT), actions=(A.READ, A.DELETE, A.EXPORT),
hint="Rotierte und archivierte Protokolle", hint="Rotierte und archivierte Protokolle",
action_hints={A.DELETE: "Archivierte Tage vom Server entfernen."}, action_hints={A.DELETE: "Archivierte Tage vom Server entfernen."},
) )
LOGS_AUDIT = Resource( LOGS_AUDIT = Resource(
key="logs_audit", key="logs_audit",
label="Aenderungsprotokoll", label="Änderungsprotokoll",
actions=(A.VIEW, A.EXPORT), actions=(A.READ, A.EXPORT),
hint="Wer hat wann was geaendert", hint="Wer hat wann was geändert",
) )
@@ -139,11 +161,13 @@ def administration_area(*extra: Resource) -> Area:
USERS, USERS,
GROUPS, GROUPS,
SETTINGS_SYSTEM, SETTINGS_SYSTEM,
SETTINGS_NAV,
SETTINGS_LICENSE, SETTINGS_LICENSE,
SETTINGS_DIRECTORY, SETTINGS_DIRECTORY,
SETTINGS_WEB, SETTINGS_WEB,
SETTINGS_NETWORK, SETTINGS_NETWORK,
BACKUP, BACKUP_EXPORT,
BACKUP_IMPORT,
TRASH, TRASH,
DIAGNOSTICS, DIAGNOSTICS,
*extra, *extra,
@@ -0,0 +1,171 @@
"""Alte Rechteschluessel auf neue -- an einer Stelle, nicht an dreien.
Mit 2.8.0 heisst das Aktionsvokabular durchgehend wie das, was es tut:
``view`` wurde ``read``, ``edit`` wurde ``write``. Danach steht in der Matrix
"Write" und im Aenderungsprotokoll ``write``, nicht zweierlei.
Drei Stellen brauchen die Zuordnung, und darum steht sie hier und nicht dort:
* die **Migration**, die bestehende Gruppenzeilen umschreibt,
* der **Import**, dem eine Sicherung aus 2.7.x untergeschoben wird -- ohne die
Zuordnung landen dort lauter unbekannte Schluessel, und
:meth:`PermissionTree.sanitize` wirft Unbekanntes absichtlich **still** weg.
Aus einer Sicherung wuerden Gruppen ohne Rechte.
* der **Test**, der prueft, dass kein alter Schluessel mehr im Code steht.
Warum nicht dreimal dieselbe Ersetzung tippen: weil die dritte Kopie beim
naechsten Umbau vergessen wird. Genau so entstand das Muster bei den Adressen
(:mod:`tesm_core.legacy_urls`).
"""
from __future__ import annotations
#: Aktion alt -> neu. Die Ressource bleibt, wie sie heisst.
UMBENANNTE_AKTIONEN: dict[str, str] = {
"view": "read",
"edit": "write",
}
#: Ein altes Recht, das in mehrere neue zerfaellt -- ein Recht je Funktion.
#:
#: Die Aufteilung ist **additiv**: wer das alte Recht hatte, bekommt alle
#: neuen. Niemand verliert etwas; ab jetzt laesst sich jedes Stueck einzeln
#: vergeben. Der umgekehrte Weg waere auch denkbar gewesen -- nur das
#: Harmloseste uebernehmen --, waere aber ein Update, das Leuten Rechte
#: wegnimmt, ohne dass jemand es entschieden hat.
#:
#: Die Schluessel stehen fuer beide Anwendungen in einer Tabelle. Auf einer
#: TESM-Datenbank tut die Zeile fuer ``licenses.write`` nichts, weil sie dort
#: in keiner Gruppe steht -- und umgekehrt. Zwei getrennte Tabellen waeren
#: zwei Stellen, die man pflegen muss.
GETEILTE_RECHTE: dict[str, tuple[str, ...]] = {
# Drei Neustartwege mit drei verschiedenen Folgen.
"devices.execute": (
"device_restart_poe.read",
"device_restart_poe.execute",
"device_restart_ssh.read",
"device_restart_ssh.execute",
"device_restart_rpc.read",
"device_restart_rpc.execute",
),
# Die Switch-Konsole war "Switch ausfuehren".
"switches.execute": ("switch_console.read", "switch_console.execute"),
# Vier Funktionen: aktualisieren, neu starten, Werkzeug installieren --
# und die Client-Konsole, die jetzt bei den Clients steht.
"maintenance.read": (
"servicing_update.read",
"servicing_reboot.read",
"servicing_tool.read",
"device_console.read",
),
"maintenance.execute": (
"servicing_update.read",
"servicing_update.execute",
"servicing_reboot.read",
"servicing_reboot.execute",
"servicing_tool.read",
"servicing_tool.execute",
"device_console.read",
"device_console.execute",
),
# Elf Funktionen an einem Recht.
"dhcp.read": (
"dhcp_subnets.read",
"dhcp_options.read",
"dhcp_reservations.read",
"dhcp_config.read",
"dhcp_service.read",
),
"dhcp.write": (
"dhcp_subnets.read",
"dhcp_subnets.create",
"dhcp_subnets.write",
"dhcp_subnets.delete",
"dhcp_options.read",
"dhcp_options.write",
"dhcp_reservations.read",
"dhcp_reservations.create",
"dhcp_reservations.write",
"dhcp_reservations.delete",
"dhcp_config.read",
"dhcp_config.execute",
),
"dhcp.execute": ("dhcp_service.read", "dhcp_service.execute"),
# Sichern und Einspielen.
"backup.read": ("backup_export.read", "backup_import.read"),
"backup.export": ("backup_export.read", "backup_export.export"),
"backup.secrets": ("backup_export.read", "backup_export.secrets"),
"backup.execute": ("backup_import.read", "backup_import.execute"),
# Lizenzserver: "aendern" hiess widerrufen **oder** erneuern -- zwei
# Eingriffe mit entgegengesetzter Wirkung unter einem Haken.
"licenses.create": ("license_issue.read", "license_issue.execute"),
"licenses.write": (
"license_renew.read",
"license_renew.execute",
"license_revoke.read",
"license_revoke.execute",
),
"licenses.execute": ("license_activation.read", "license_activation.execute"),
}
#: Ein Recht, das **bleibt** und bisher noch etwas anderes mitgab.
#:
#: Der Unterschied zu :data:`GETEILTE_RECHTE` ist keine Formsache: dort ist der
#: alte Schluessel verschwunden und muss ersetzt werden, hier gilt er weiter
#: und bekommt nur Gesellschaft. Beides in eine Tabelle zu werfen hiesse, in
#: der Migration ein Recht zu loeschen, das es noch gibt -- und im Test nicht
#: mehr pruefen zu koennen, welcher Fall gemeint war.
ERWEITERTE_RECHTE: dict[str, tuple[str, ...]] = {
# Die Navigationsreihenfolge lief unter "Systemaktionen ausloesen" mit.
"settings_system.execute": ("settings_nav.read", "settings_nav.write"),
# Einen Host-Schluessel freizugeben hing an den Stammdaten.
"devices.write": ("hostkeys.read", "hostkeys.execute"),
"switches.write": ("hostkeys.read", "hostkeys.execute"),
# Umbenennen war auch "Freigaben zuordnen".
"fileshare.write": (
"fileshare_mappings.read",
"fileshare_mappings.create",
"fileshare_mappings.write",
"fileshare_mappings.delete",
),
# Ausgefuellt ablegen war auch "unterschreiben".
"fileshare_pdf.create": ("fileshare_signature.read", "fileshare_signature.execute"),
}
def neue_schluessel(alt: str) -> tuple[str, ...]:
"""Was aus einem alten Rechteschluessel wird -- eins oder mehrere.
Unbekanntes kommt unveraendert zurueck: ob ein Schluessel gilt, entscheidet
der Rechtebaum, nicht diese Tabelle. Sie benennt nur um.
"""
if alt in GETEILTE_RECHTE:
return GETEILTE_RECHTE[alt]
ressource, punkt, aktion = alt.rpartition(".")
if not punkt:
return (alt,)
neu = UMBENANNTE_AKTIONEN.get(aktion)
selbst = f"{ressource}.{neu}" if neu else alt
# Ein Recht, das bleibt, nimmt mit, was es bisher mitgab -- sonst verliert
# jemand beim Update genau die Funktion, die vorher an diesem Haken hing.
return (selbst, *ERWEITERTE_RECHTE.get(selbst, ()))
def umgeschrieben(rechte: object) -> list[str]:
"""Eine ganze Liste umschreiben, ohne Dubletten und in fester Reihenfolge."""
if not isinstance(rechte, (list, tuple, set, frozenset)):
return []
ergebnis: set[str] = set()
for recht in rechte:
ergebnis.update(neue_schluessel(str(recht)))
return sorted(ergebnis)
__all__ = [
"UMBENANNTE_AKTIONEN",
"GETEILTE_RECHTE",
"ERWEITERTE_RECHTE",
"neue_schluessel",
"umgeschrieben",
]
+147 -32
View File
@@ -1,6 +1,14 @@
"""Rechtemodell: Bereiche, Ressourcen, Aktionen. """Rechtemodell: Bereiche, Ressourcen, Aktionen.
Das Vorgaengermodell kannte fuenf Spalten (R/W/E/D/X), wobei ``E`` ("Aendern") Das Vokabular heisst seit 2.8.0 durchgehend wie das, was es tut: **Read,
Create, Write, Delete, Execute, Export, Secrets**. Bis dahin hiessen zwei
Aktionen anders (``view``, ``edit``), waehrend die Matrix "Bearbeiten" zeigte
und die Kurzfassung "U" -- dieselbe Aktion in drei Schreibweisen. Der
Kill-Switch eines Bereichs heisst entsprechend ``<bereich>.read``.
:mod:`tesm_core.rbac.legacy_permissions` schreibt alte Schluessel um; die
Migration ``core/4`` tut das fuer bestehende Gruppen.
Das Modell vor 2.0 kannte fuenf Spalten (R/W/E/D/X), wobei ``E`` ("Aendern")
das Loeschen **mit abdeckte** und ``D``/``X`` je nach Zeile etwas voellig anderes das Loeschen **mit abdeckte** und ``D``/``X`` je nach Zeile etwas voellig anderes
bedeuteten (Dashboard-Neustart bzw. Export). Das war kompakt, aber: bedeuteten (Dashboard-Neustart bzw. Export). Das war kompakt, aber:
@@ -15,7 +23,7 @@ Aktionen eine Ressource ueberhaupt kennt, entscheidet die Ressource; die Matrix
rendert nur die Spalten, die im jeweiligen Bereich wirklich vorkommen. rendert nur die Spalten, die im jeweiligen Bereich wirklich vorkommen.
Beibehalten wird der bewaehrte **Kill-Switch**: jeder Bereich hat ein Beibehalten wird der bewaehrte **Kill-Switch**: jeder Bereich hat ein
``<bereich>.view``-Recht. Ohne dieses greift kein einziges Recht innerhalb des ``<bereich>.read``-Recht. Ohne dieses greift kein einziges Recht innerhalb des
Bereichs -- unabhaengig davon, was einzeln angehakt ist. Das erlaubt es, einer Bereichs -- unabhaengig davon, was einzeln angehakt ist. Das erlaubt es, einer
Gruppe einen ganzen Bereich mit einem Klick zu entziehen, ohne die fein Gruppe einen ganzen Bereich mit einem Klick zu entziehen, ohne die fein
eingestellten Einzelrechte zu verlieren. eingestellten Einzelrechte zu verlieren.
@@ -29,38 +37,68 @@ from typing import Iterable, Mapping, Sequence
class Action(str, Enum): class Action(str, Enum):
"""Aktionsvokabular. Bedeutung ist ueberall dieselbe -- keine Ausnahmen.""" """Aktionsvokabular. Bedeutung ist ueberall dieselbe -- keine Ausnahmen.
VIEW = "view" Die Namen sind die englischen, weil sie in jedem Rechtesystem dieselben
sind und keiner Uebersetzung bedarf: wer ``write`` liest, weiss es. Zwei
hiessen bis 2.7.x anders (``view``, ``edit``) -- in der Matrix stand
"Bearbeiten" und im Protokoll ``edit``, also zweierlei fuer dasselbe.
:mod:`tesm_core.rbac.legacy_permissions` schreibt die alten Schluessel um.
"""
READ = "read"
CREATE = "create" CREATE = "create"
EDIT = "edit" WRITE = "write"
DELETE = "delete" DELETE = "delete"
EXECUTE = "execute" EXECUTE = "execute"
EXPORT = "export" EXPORT = "export"
SECRETS = "secrets" SECRETS = "secrets"
#: Kuerzel und Erklaerung je Aktion -- Kopfzeile der Rechtematrix. #: Kuerzel, englischer Name, deutsche Beschriftung, Erklaerung.
ACTION_META: dict[Action, tuple[str, str, str]] = { #:
Action.VIEW: ("R", "Ansehen", "Datensaetze und Seiten dieses Bereichs lesen."), #: Beides hat seinen Ort: die Matrix fuehrt die Spalte unter ihrem englischen
Action.CREATE: ("C", "Anlegen", "Neue Datensaetze erstellen."), #: Namen (das ist das Vokabular, und es steht so auch im Schluessel), die
Action.EDIT: ("U", "Bearbeiten", "Bestehende Datensaetze aendern -- ohne Loeschen."), #: Kleinschrift darunter und jeder Satz in der Oberflaeche bleiben deutsch.
Action.DELETE: ("D", "Loeschen", "Datensaetze loeschen bzw. endgueltig entfernen."), ACTION_META: dict[Action, tuple[str, str, str, str]] = {
Action.EXECUTE: ("X", "Ausfuehren", "Aktionen ausloesen (Neustart, Dienste, Jobs, Import)."), Action.READ: ("R", "Read", "ansehen", "Datensätze und Seiten dieses Bereichs ansehen."),
Action.EXPORT: ("E", "Exportieren", "Daten aus dem System heraustragen (Datei-Download)."), Action.CREATE: ("C", "Create", "anlegen", "Neue Datensätze anlegen."),
Action.WRITE: (
"W",
"Write",
"ändern",
"Bestehende Datensätze ändern – ohne Anlegen, ohne Löschen.",
),
Action.DELETE: ("D", "Delete", "löschen", "Datensätze löschen bzw. endgültig entfernen."),
Action.EXECUTE: (
"X",
"Execute",
"auslösen",
"Aktionen auslösen (Neustart, Dienste, Aufträge, Import).",
),
Action.EXPORT: (
"E",
"Export",
"mitnehmen",
"Daten aus dem System heraustragen (Datei-Download).",
),
Action.SECRETS: ( Action.SECRETS: (
"S", "S",
"Geheimnisse", "Secrets",
"Passwoerter und Schluessel im Klartext sehen oder mitnehmen.", "Klartext",
"Passwörter und Schlüssel im Klartext sehen oder mitnehmen.",
), ),
} }
#: Aktionen, die zusaetzlich eine frische Passworteingabe verlangen (sudo-Modus). #: Aktionen, die zusaetzlich eine frische Passworteingabe verlangen (sudo-Modus).
SENSITIVE_ACTIONS: frozenset[Action] = frozenset({Action.SECRETS, Action.EXPORT}) SENSITIVE_ACTIONS: frozenset[Action] = frozenset({Action.SECRETS, Action.EXPORT})
#: ``view`` ist Voraussetzung fuer alles andere in derselben Zeile. #: ``read`` ist Voraussetzung fuer alles andere in derselben Zeile. Umgekehrt
#: gilt nichts: wer anlegen darf, darf damit nicht aendern, und wer aendern
#: darf, nicht anlegen. Das ist die Trennung, die man in einer
#: Verwaltungsanwendung am haeufigsten braucht.
IMPLIED_BY: dict[Action, tuple[Action, ...]] = { IMPLIED_BY: dict[Action, tuple[Action, ...]] = {
action: (Action.VIEW,) for action in Action if action is not Action.VIEW action: (Action.READ,) for action in Action if action is not Action.READ
} }
@@ -74,6 +112,32 @@ class Resource:
hint: str = "" hint: str = ""
#: Abweichende Erklaerung einzelner Aktionen (z. B. was "Ausfuehren" hier meint). #: Abweichende Erklaerung einzelner Aktionen (z. B. was "Ausfuehren" hier meint).
action_hints: Mapping[Action, str] = field(default_factory=dict) action_hints: Mapping[Action, str] = field(default_factory=dict)
#: Zu welcher Zeile diese gehoert -- rein fuer die Darstellung.
#:
#: Aus einer Zeile "Clients" sind sechs geworden (Stammdaten, drei
#: Neustartwege, Konsole, Host-Schluessel). Untereinander und alle gleich
#: eingerueckt liest man sie als sechs unabhaengige Dinge; eingerueckt
#: unter ihrer Hauptzeile sieht man, dass sie zusammengehoeren. Auf die
#: Rechte hat das keine Wirkung: jede Zeile bleibt einzeln vergebbar, und
#: die Hauptzeile ist **nicht** Voraussetzung fuer ihre Unterzeilen.
parent: str = ""
#: Aktionen, die hier **keine** frische Passwortbestaetigung verlangen,
#: obwohl sie in SENSITIVE_ACTIONS stehen. Bewusst hier und nicht als
#: stilles Weglassen in der Route: eine Ausnahme von einer
#: Sicherheitsregel gehoert dorthin, wo man sie sieht und begruenden muss.
ohne_wiederanmeldung: tuple[Action, ...] = ()
@property
def short_label(self) -> str:
"""Beschriftung ohne den Namen der Hauptzeile.
Unter "Clients" steht "Neustart über PoE" und nicht "Clients: Neustart
über PoE" -- das Wort stand sonst siebenmal untereinander. Ausserhalb
der Matrix (Protokoll, Meldungen) bleibt der vollstaendige Name.
"""
if self.parent and ": " in self.label:
return self.label.split(": ", 1)[1]
return self.label
def permission(self, action: Action) -> str: def permission(self, action: Action) -> str:
return f"{self.key}.{action.value}" return f"{self.key}.{action.value}"
@@ -96,8 +160,9 @@ class Area:
hint: str = "" hint: str = ""
@property @property
def view_key(self) -> str: def read_key(self) -> str:
return f"{self.key}.view" """Der Kill-Switch des Bereichs. Ohne ihn greift keine Zeile darunter."""
return f"{self.key}.read"
def columns(self) -> tuple[Action, ...]: def columns(self) -> tuple[Action, ...]:
"""Nur die Aktionen, die in diesem Bereich tatsaechlich vorkommen.""" """Nur die Aktionen, die in diesem Bereich tatsaechlich vorkommen."""
@@ -123,45 +188,92 @@ class PermissionTree:
self._parent: dict[str, str] = {} self._parent: dict[str, str] = {}
self._all: list[str] = [] self._all: list[str] = []
self._resources: dict[str, tuple[Area, Resource]] = {} self._resources: dict[str, tuple[Area, Resource]] = {}
self._verwaltung: frozenset[str] | None = None
seen_keys: set[str] = set() seen_keys: set[str] = set()
for area in self.areas: for area in self.areas:
if area.key in seen_keys: if area.key in seen_keys:
raise ValueError(f"Doppelter Bereichsschluessel: {area.key}") raise ValueError(f"Doppelter Bereichsschlüssel: {area.key}")
seen_keys.add(area.key) seen_keys.add(area.key)
self._by_key[area.key] = area self._by_key[area.key] = area
self._all.append(area.view_key) self._all.append(area.read_key)
for resource in area.resources: for resource in area.resources:
if resource.key in seen_keys: if resource.key in seen_keys:
raise ValueError(f"Doppelter Ressourcenschluessel: {resource.key}") raise ValueError(f"Doppelter Ressourcenschlüssel: {resource.key}")
seen_keys.add(resource.key) seen_keys.add(resource.key)
if Action.VIEW not in resource.actions: if Action.READ not in resource.actions:
raise ValueError( raise ValueError(
f"Ressource {resource.key} muss die Aktion 'view' anbieten -- " f"Ressource {resource.key} muss die Aktion 'read' anbieten – "
"sie ist Voraussetzung fuer jede andere Aktion." "sie ist Voraussetzung für jede andere Aktion."
) )
self._resources[resource.key] = (area, resource) self._resources[resource.key] = (area, resource)
for permission in resource.permissions(): for permission in resource.permissions():
self._all.append(permission) self._all.append(permission)
self._parent[permission] = area.view_key self._parent[permission] = area.read_key
# ------------------------------------------------------------------ # # ------------------------------------------------------------------ #
def all_keys(self) -> tuple[str, ...]: def all_keys(self) -> tuple[str, ...]:
return tuple(self._all) return tuple(self._all)
#: Der Bereich, den ein echter Administrator immer ganz hat.
#:
#: Nicht "alles": das Kennzeichen ``is_admin`` gab bisher jede Pruefung
#: frei, auch Geraete, DHCP und Dateifreigaben. Ein Administrator ist aber
#: jemand, der die **Anlage** verwaltet -- Benutzer, Rechte,
#: Einstellungen --, und nicht automatisch jemand, der Maschinen neu
#: startet oder in Freigaben liest. Diese Rechte holt er sich ueber eine
#: Gruppe, und dann steht es im Aenderungsprotokoll.
VERWALTUNGSBEREICH = "administration"
def verwaltungsrechte(self) -> frozenset[str]:
"""Alle Rechte des Verwaltungsbereichs, samt seinem Kill-Switch.
Einmal gerechnet und gemerkt: die Antwort haengt nur am Baum, und der
entsteht beim Import.
"""
if self._verwaltung is None:
bereich = self._by_key.get(self.VERWALTUNGSBEREICH)
if bereich is None:
self._verwaltung = frozenset()
else:
rechte = {bereich.read_key}
for resource in bereich.resources:
rechte.update(resource.permissions())
self._verwaltung = frozenset(rechte)
return self._verwaltung
def area(self, key: str) -> Area | None: def area(self, key: str) -> Area | None:
return self._by_key.get(key) return self._by_key.get(key)
def parent_view_key(self, permission: str) -> str | None: def parent_read_key(self, permission: str) -> str | None:
return self._parent.get(permission) return self._parent.get(permission)
def resource(self, key: str) -> tuple[Area, Resource] | None: def resource(self, key: str) -> tuple[Area, Resource] | None:
return self._resources.get(key) return self._resources.get(key)
def verlangt_wiederanmeldung(self, permission: str) -> bool:
"""Braucht dieses Recht eine frische Passwortbestaetigung?
Die Antwort steht im Modell (``SENSITIVE_ACTIONS``) und nicht in der
Route -- sonst vergisst sie die naechste, die dazukommt.
"""
resource_key, _, action_value = permission.rpartition(".")
entry = self._resources.get(resource_key)
if entry is None:
return False
_, resource = entry
try:
action = Action(action_value)
except ValueError:
return False
if action in resource.ohne_wiederanmeldung:
return False
return action in SENSITIVE_ACTIONS
def label_for(self, permission: str) -> str: def label_for(self, permission: str) -> str:
"""Menschenlesbare Beschreibung eines Rechteschluessels -- fuer Audit-Logs.""" """Menschenlesbare Beschreibung eines Rechteschluessels -- fuer Audit-Logs."""
if permission.endswith(".view") and permission[:-5] in self._by_key: if permission.endswith(".read") and permission[:-5] in self._by_key:
return f"Bereich {self._by_key[permission[:-5]].label}: Zugang" return f"Bereich {self._by_key[permission[:-5]].label}: Zugang"
resource_key, _, action_value = permission.rpartition(".") resource_key, _, action_value = permission.rpartition(".")
entry = self._resources.get(resource_key) entry = self._resources.get(resource_key)
@@ -172,7 +284,10 @@ class PermissionTree:
action = Action(action_value) action = Action(action_value)
except ValueError: except ValueError:
return permission return permission
return f"{resource.label}: {ACTION_META[action][1]}" # Die deutsche Beschriftung, nicht der englische Name: dieser Text
# landet in Saetzen ("Sie koennen kein Recht vergeben ...") und im
# Aenderungsprotokoll, und die sind deutsch.
return f"{resource.label}: {ACTION_META[action][2]}"
def sanitize(self, permissions: Iterable[str]) -> list[str]: def sanitize(self, permissions: Iterable[str]) -> list[str]:
"""Wirft unbekannte Rechte weg und ergaenzt implizierte. """Wirft unbekannte Rechte weg und ergaenzt implizierte.
@@ -180,7 +295,7 @@ class PermissionTree:
Regeln: Regeln:
* Ein Recht, das nicht im Baum steht, wird verworfen (kein stilles * Ein Recht, das nicht im Baum steht, wird verworfen (kein stilles
Weiterschleppen von Altrechten nach einem Refactoring). Weiterschleppen von Altrechten nach einem Refactoring).
* Jede Aktion impliziert ``view`` derselben Zeile. * Jede Aktion impliziert ``read`` derselben Zeile -- aber nur diese.
* Existiert irgendein Recht eines Bereichs, wird der Kill-Switch des * Existiert irgendein Recht eines Bereichs, wird der Kill-Switch des
Bereichs mitgesetzt -- sonst waere die Auswahl wirkungslos und der Bereichs mitgesetzt -- sonst waere die Auswahl wirkungslos und der
Bearbeiter wuerde sich wundern. Bearbeiter wuerde sich wundern.
@@ -200,13 +315,13 @@ class PermissionTree:
result.add(candidate) result.add(candidate)
for area in self.areas: for area in self.areas:
if any(p in result for resource in area.resources for p in resource.permissions()): if any(p in result for resource in area.resources for p in resource.permissions()):
result.add(area.view_key) result.add(area.read_key)
return sorted(result) return sorted(result)
def effective(self, permissions: Iterable[str]) -> set[str]: def effective(self, permissions: Iterable[str]) -> set[str]:
"""Rechte, die nach Anwendung der Kill-Switches tatsaechlich greifen.""" """Rechte, die nach Anwendung der Kill-Switches tatsaechlich greifen."""
granted = set(permissions) granted = set(permissions)
active = {key for key in granted if key.endswith(".view") and key[:-5] in self._by_key} active = {key for key in granted if key.endswith(".read") and key[:-5] in self._by_key}
return { return {
key key
for key in granted for key in granted
+155 -32
View File
@@ -29,7 +29,7 @@ from typing import Any, Iterable, Sequence
from ..db import Database from ..db import Database
from ..security import passwords from ..security import passwords
from .model import Action, PermissionTree, RolePreset from .model import ACTION_META, Action, PermissionTree, RolePreset
class PermissionDenied(Exception): class PermissionDenied(Exception):
@@ -124,7 +124,20 @@ def group_permissions(conn: sqlite3.Connection, group_id: int) -> list[str]:
def guard_no_amplification( def guard_no_amplification(
actor: Any, tree: PermissionTree, added: Iterable[str], *, what: str = "Recht" actor: Any, tree: PermissionTree, added: Iterable[str], *, what: str = "Recht"
) -> None: ) -> None:
"""Verhindert, dass jemand Rechte vergibt, die er selbst nicht besitzt.""" """Verhindert, dass jemand Rechte vergibt, die er selbst nicht besitzt.
Ein echter Administrator darf es -- und das ist seit 2.8.0 die bewusst
offene Tuer: sein Kennzeichen gibt ihm den Verwaltungsbereich, die
Funktionen holt er sich ueber eine Gruppe, auch fuer sich selbst. Der
Unterschied zu vorher ist nicht, *ob* er an die Funktionen kommt, sondern
dass es dann eine Zeile im Aenderungsprotokoll gibt und in der Matrix zu
sehen ist, statt unsichtbar im Kennzeichen zu stecken.
Fuer alle anderen bleibt die Regel dicht -- auch fuer die Gruppe
"Administration", die Benutzer und Gruppen pflegt: sie kann keiner Gruppe
ein Recht geben, das sie nicht selbst hat, und sich damit nicht ueber eine
neue Gruppe hochstufen.
"""
if getattr(actor, "is_admin", False): if getattr(actor, "is_admin", False):
return return
missing = sorted(p for p in added if not actor.has(p)) missing = sorted(p for p in added if not actor.has(p))
@@ -132,7 +145,7 @@ def guard_no_amplification(
labels = ", ".join(tree.label_for(p) for p in missing[:6]) labels = ", ".join(tree.label_for(p) for p in missing[:6])
more = "" if len(missing) <= 6 else f" (und {len(missing) - 6} weitere)" more = "" if len(missing) <= 6 else f" (und {len(missing) - 6} weitere)"
raise PermissionDenied( raise PermissionDenied(
f"Sie koennen kein {what} vergeben, das Sie selbst nicht besitzen: {labels}{more}." f"Sie können kein {what} vergeben, das Sie selbst nicht besitzen: {labels}{more}."
) )
@@ -160,7 +173,7 @@ def create_group(
if existing["deleted_at"]: if existing["deleted_at"]:
raise ValidationError( raise ValidationError(
f"Eine Gruppe namens {clean_name!r} liegt im Papierkorb. " f"Eine Gruppe namens {clean_name!r} liegt im Papierkorb. "
"Stellen Sie sie wieder her oder loeschen Sie sie endgueltig." "Stellen Sie sie wieder her oder löschen Sie sie endgültig."
) )
raise ValidationError(f"Eine Gruppe namens {clean_name!r} existiert bereits.") raise ValidationError(f"Eine Gruppe namens {clean_name!r} existiert bereits.")
@@ -205,7 +218,7 @@ def update_group(
if name is not None and name.strip() != group.name: if name is not None and name.strip() != group.name:
if group.is_system: if group.is_system:
raise PermissionDenied("Systemgruppen koennen nicht umbenannt werden.") raise PermissionDenied("Systemgruppen können nicht umbenannt werden.")
clean = name.strip() clean = name.strip()
if not clean: if not clean:
raise ValidationError("Der Gruppenname darf nicht leer sein.") raise ValidationError("Der Gruppenname darf nicht leer sein.")
@@ -224,13 +237,13 @@ def update_group(
"UPDATE groups SET description=?, updated_at=? WHERE id=?", "UPDATE groups SET description=?, updated_at=? WHERE id=?",
(description.strip(), _now(), group_id), (description.strip(), _now(), group_id),
) )
changes.append("Beschreibung geaendert") changes.append("Beschreibung geändert")
if permissions is not None: if permissions is not None:
if group.is_system and not unlock_system: if group.is_system and not unlock_system:
raise PermissionDenied( raise PermissionDenied(
"Die Rechte dieser Systemgruppe sind gesperrt. " "Die Rechte dieser Systemgruppe sind gesperrt. "
"Sie muessen sie zuerst ausdruecklich freischalten." "Sie müssen sie zuerst ausdrücklich freischalten."
) )
wanted = tree.sanitize(permissions) wanted = tree.sanitize(permissions)
added = sorted(set(wanted) - set(group.permissions)) added = sorted(set(wanted) - set(group.permissions))
@@ -240,7 +253,7 @@ def update_group(
_write_permissions(conn, group_id, wanted) _write_permissions(conn, group_id, wanted)
conn.execute("UPDATE groups SET updated_at=? WHERE id=?", (_now(), group_id)) conn.execute("UPDATE groups SET updated_at=? WHERE id=?", (_now(), group_id))
if added: if added:
changes.append("Rechte hinzugefuegt: " + ", ".join(tree.label_for(p) for p in added)) changes.append("Rechte hinzugefügt: " + ", ".join(tree.label_for(p) for p in added))
if removed: if removed:
changes.append("Rechte entfernt: " + ", ".join(tree.label_for(p) for p in removed)) changes.append("Rechte entfernt: " + ", ".join(tree.label_for(p) for p in removed))
@@ -252,7 +265,7 @@ def soft_delete_group(conn: sqlite3.Connection, group_id: int) -> None:
if group is None: if group is None:
raise ValidationError("Diese Gruppe existiert nicht.") raise ValidationError("Diese Gruppe existiert nicht.")
if group.is_system: if group.is_system:
raise PermissionDenied("Systemgruppen koennen nicht geloescht werden.") raise PermissionDenied("Systemgruppen können nicht gelöscht werden.")
conn.execute("UPDATE groups SET deleted_at=? WHERE id=?", (_now(), group_id)) conn.execute("UPDATE groups SET deleted_at=? WHERE id=?", (_now(), group_id))
@@ -315,6 +328,113 @@ def ensure_presets(
return created return created
def unberuehrte_vorlagen_entfernen(
conn: sqlite3.Connection, tree: PermissionTree, presets: Sequence[RolePreset]
) -> list[str]:
"""Legt Vorlagengruppen in den Papierkorb -- aber nur unberuehrte.
Wird gebraucht, wenn eine Vorlage aus der Auslieferung verschwindet. Auf
einer frischen Installation entsteht sie dann gar nicht erst; auf einer
bestehenden steht sie in der Datenbank, und eine Migration, die sie einfach
loescht, nimmt einem Kunden womoeglich eine Gruppe weg, mit der er
arbeitet.
Darum diese fuenf Bedingungen, und alle fuenf muessen zutreffen:
* Die Gruppe stammt **aus der Auslieferung**: ihr ``preset_key`` ist der der
Vorlage. Diese Kennung setzt nur :func:`ensure_presets`; eine selbst
angelegte Gruppe hat sie nicht, auch wenn sie zufaellig denselben Namen
traegt. Ueber den Namen wird bewusst *nicht* gesucht -- sonst raeumte
dieser Schritt jemandem seine eigene Gruppe "Betrieb" weg.
* Sie heisst noch so, wie sie ausgeliefert wurde. Ein anderer Name heisst:
jemand hat sie uebernommen.
* Die Gruppe hat **keine Mitglieder**. Wer Mitglieder hat, wird benutzt.
* Ihre Rechte sind **unveraendert** die der Vorlage. Jede Abweichung heisst:
jemand hat sie angepasst, also ist sie seine, nicht unsere.
* Sie ist keine Systemgruppe.
Geloescht wird weich: die Gruppe liegt im Papierkorb und kommt mit einem
Klick zurueck. Gibt die Namen der einsortierten Gruppen zurueck.
"""
entfernt: list[str] = []
gruppen = list_groups(conn)
for preset in presets:
gruppe = next(
(
g
for g in gruppen
if g.preset_key
and g.preset_key == preset.key
and g.name.lower() == preset.name.lower()
),
None,
)
if gruppe is None or gruppe.is_system or gruppe.member_count:
continue
if set(gruppe.permissions) != set(tree.expand_preset(preset)):
continue
conn.execute("UPDATE groups SET deleted_at=? WHERE id=?", (_now(), gruppe.id))
entfernt.append(gruppe.name)
return entfernt
def funktionsgruppe_anlegen(
conn: sqlite3.Connection, tree: PermissionTree, preset: RolePreset
) -> int:
"""Legt die Vollzugriffsgruppe an, falls sie fehlt, und gibt ihre Nummer.
Getrennt von :func:`funktionsgruppe_fuer_administratoren`, weil das zwei
verschiedene Dinge sind: hier entsteht nur die Gruppe, dort bekommt sie
**jeder** Administrator. Wer ein einzelnes Konto aufnehmen will -- etwa
das, das an der Kommandozeile entsteht --, braucht das erste ohne das
zweite; sonst faengt sich ein Administrator, der die Gruppe bewusst
abgelegt hat, sie beim naechsten Aufruf wieder ein.
"""
zeile = Database.one(
conn, "SELECT id FROM groups WHERE name=? COLLATE NOCASE", (preset.name,)
)
if zeile is not None:
return int(zeile["id"])
cursor = conn.execute(
"INSERT INTO groups (name, description, is_system, is_default, preset_key, "
"created_at, updated_at) VALUES (?,?,0,0,?,?,?)",
(preset.name, preset.description, preset.key, _now(), _now()),
)
gruppe = int(cursor.lastrowid or 0)
_write_permissions(conn, gruppe, tree.expand_preset(preset))
return gruppe
def funktionsgruppe_fuer_administratoren(
conn: sqlite3.Connection, tree: PermissionTree, preset: RolePreset
) -> int:
"""Legt die Vollzugriffsgruppe an und gibt sie allen Administratoren.
Der Uebergang zu 2.8.0: bis dahin gab das Kennzeichen ``is_admin`` jede
Pruefung frei. Danach gibt es den Verwaltungsbereich, und alles andere
kommt ueber Gruppen. Ohne diesen Schritt fehlt einem bestehenden
Administrator nach dem Update die halbe Anwendung -- und er haelt das fuer
einen Fehler, nicht fuer eine Absicht.
Auf einer frischen Installation gibt es beim Migrieren noch kein Konto;
dann entsteht nur die Gruppe, und wer sie will, weist sie sich zu. Genau so
ist die neue Trennung gedacht.
Gibt die Nummer der Gruppe zurueck.
"""
gruppe = funktionsgruppe_anlegen(conn, tree, preset)
conn.executemany(
"INSERT OR IGNORE INTO user_groups (user_id, group_id) VALUES (?,?)",
[
(int(row["id"]), gruppe)
for row in Database.all(
conn, "SELECT id FROM users WHERE is_admin=1 AND deleted_at IS NULL"
)
],
)
return gruppe
def default_group_id(conn: sqlite3.Connection) -> int | None: def default_group_id(conn: sqlite3.Connection) -> int | None:
value = Database.value( value = Database.value(
conn, "SELECT id FROM groups WHERE is_default=1 AND deleted_at IS NULL ORDER BY id LIMIT 1" conn, "SELECT id FROM groups WHERE is_default=1 AND deleted_at IS NULL ORDER BY id LIMIT 1"
@@ -410,7 +530,7 @@ def guard_target_account(actor: Any, target: dict[str, Any]) -> None:
"""Ein Administratorkonto darf nur ein echter Administrator anfassen.""" """Ein Administratorkonto darf nur ein echter Administrator anfassen."""
if bool(target.get("is_admin")) and not getattr(actor, "is_admin", False): if bool(target.get("is_admin")) and not getattr(actor, "is_admin", False):
raise PermissionDenied( raise PermissionDenied(
"Administratorkonten koennen nur von Administratoren bearbeitet werden." "Administratorkonten können nur von Administratoren bearbeitet werden."
) )
@@ -420,8 +540,8 @@ def guard_last_admin(conn: sqlite3.Connection, user_id: int, *, action: str) ->
return return
if count_active_admins(conn, exclude_id=user_id) == 0: if count_active_admins(conn, exclude_id=user_id) == 0:
raise PermissionDenied( raise PermissionDenied(
f"{action} nicht moeglich: es muss mindestens ein entsperrtes " f"{action} nicht möglich: es muss mindestens ein entsperrtes "
"Administratorkonto uebrig bleiben." "Administratorkonto übrig bleiben."
) )
@@ -482,12 +602,12 @@ def create_user(
if existing["deleted_at"]: if existing["deleted_at"]:
raise ValidationError( raise ValidationError(
f"Der Benutzername {clean!r} liegt im Papierkorb. Stellen Sie das Konto " f"Der Benutzername {clean!r} liegt im Papierkorb. Stellen Sie das Konto "
"wieder her oder loeschen Sie es endgueltig." "wieder her oder löschen Sie es endgültig."
) )
raise ValidationError(f"Der Benutzername {clean!r} ist bereits vergeben.") raise ValidationError(f"Der Benutzername {clean!r} ist bereits vergeben.")
if is_admin and not getattr(actor, "is_admin", False): if is_admin and not getattr(actor, "is_admin", False):
raise PermissionDenied("Nur Administratoren koennen weitere Administratoren anlegen.") raise PermissionDenied("Nur Administratoren können weitere Administratoren anlegen.")
if auth_source == "local": if auth_source == "local":
problems = policy.check(password, username=clean) problems = policy.check(password, username=clean)
@@ -552,24 +672,30 @@ def update_user(
# ueberschrieben -- die Bearbeitung waere eine Illusion. # ueberschrieben -- die Bearbeitung waere eine Illusion.
raise ValidationError( raise ValidationError(
"Name und E-Mail eines Verzeichniskontos werden bei jeder Anmeldung " "Name und E-Mail eines Verzeichniskontos werden bei jeder Anmeldung "
"aus dem Verzeichnis uebernommen und koennen hier nicht geaendert werden." "aus dem Verzeichnis übernommen und können hier nicht geändert werden."
) )
fields: dict[str, Any] = {} fields: dict[str, Any] = {}
# Mit Werten, nicht nur "geaendert": aus dem Protokoll soll man den
# vorherigen Stand ablesen koennen, ohne eine Sicherung auszupacken.
beschriftungen = {"first_name": "Vorname", "last_name": "Nachname", "email": "E-Mail"}
for column, value in ( for column, value in (
("first_name", first_name), ("first_name", first_name),
("last_name", last_name), ("last_name", last_name),
("email", email), ("email", email),
): ):
if value is not None and value.strip() != str(target.get(column) or ""): alt = str(target.get(column) or "")
if value is not None and value.strip() != alt:
fields[column] = value.strip() fields[column] = value.strip()
changes.append(f"{column} geaendert") changes.append(
f"{beschriftungen[column]}: {alt or '(leer)'} -> {value.strip() or '(leer)'}"
)
if is_admin is not None and bool(is_admin) != bool(target["is_admin"]): if is_admin is not None and bool(is_admin) != bool(target["is_admin"]):
if not getattr(actor, "is_admin", False): if not getattr(actor, "is_admin", False):
raise PermissionDenied("Das Admin-Kennzeichen kann nur ein Administrator aendern.") raise PermissionDenied("Das Admin-Kennzeichen kann nur ein Administrator ändern.")
if int(target["id"]) == int(getattr(actor, "id", 0)): if int(target["id"]) == int(getattr(actor, "id", 0)):
raise PermissionDenied("Sie koennen Ihr eigenes Admin-Kennzeichen nicht aendern.") raise PermissionDenied("Sie können Ihr eigenes Admin-Kennzeichen nicht ändern.")
if not is_admin: if not is_admin:
guard_last_admin(conn, user_id, action="Herabstufen") guard_last_admin(conn, user_id, action="Herabstufen")
fields["is_admin"] = int(bool(is_admin)) fields["is_admin"] = int(bool(is_admin))
@@ -577,7 +703,7 @@ def update_user(
if is_locked is not None and bool(is_locked) != bool(target["is_locked"]): if is_locked is not None and bool(is_locked) != bool(target["is_locked"]):
if int(target["id"]) == int(getattr(actor, "id", 0)): if int(target["id"]) == int(getattr(actor, "id", 0)):
raise PermissionDenied("Sie koennen Ihr eigenes Konto nicht sperren.") raise PermissionDenied("Sie können Ihr eigenes Konto nicht sperren.")
if is_locked: if is_locked:
guard_last_admin(conn, user_id, action="Sperren") guard_last_admin(conn, user_id, action="Sperren")
fields["is_locked"] = int(bool(is_locked)) fields["is_locked"] = int(bool(is_locked))
@@ -595,7 +721,7 @@ def update_user(
after = {int(gid) for gid in group_ids} after = {int(gid) for gid in group_ids}
if before != after: if before != after:
set_user_groups(conn, user_id, sorted(after), actor=actor, tree=tree) set_user_groups(conn, user_id, sorted(after), actor=actor, tree=tree)
changes.append("Gruppenzuordnung geaendert") changes.append("Gruppenzuordnung geändert")
return changes return changes
@@ -613,7 +739,7 @@ def set_password(
raise ValidationError("Dieses Konto existiert nicht.") raise ValidationError("Dieses Konto existiert nicht.")
if str(row.get("auth_source")) == "ldap": if str(row.get("auth_source")) == "ldap":
raise ValidationError( raise ValidationError(
"Verzeichniskonten haben hier kein Passwort -- es wird im Verzeichnisdienst gepflegt." "Verzeichniskonten haben hier kein Passwort – es wird im Verzeichnisdienst gepflegt."
) )
problems = policy.check(new_password, username=str(row["username"])) problems = policy.check(new_password, username=str(row["username"]))
if problems: if problems:
@@ -631,7 +757,7 @@ def soft_delete_user(conn: sqlite3.Connection, user_id: int, *, actor: Any) -> s
raise ValidationError("Dieses Konto existiert nicht (mehr).") raise ValidationError("Dieses Konto existiert nicht (mehr).")
guard_target_account(actor, target) guard_target_account(actor, target)
if int(target["id"]) == int(getattr(actor, "id", 0)): if int(target["id"]) == int(getattr(actor, "id", 0)):
raise PermissionDenied("Sie koennen Ihr eigenes Konto nicht loeschen.") raise PermissionDenied("Sie können Ihr eigenes Konto nicht löschen.")
guard_last_admin(conn, user_id, action="Loeschen") guard_last_admin(conn, user_id, action="Loeschen")
if str(target.get("auth_source")) == "ldap": if str(target.get("auth_source")) == "ldap":
@@ -677,7 +803,7 @@ def matrix_state(
granted = set(permissions) granted = set(permissions)
state: dict[str, dict[str, bool]] = {} state: dict[str, dict[str, bool]] = {}
for area in tree.areas: for area in tree.areas:
state[area.key] = {"view": area.view_key in granted} state[area.key] = {"read": area.read_key in granted}
for resource in area.resources: for resource in area.resources:
state[resource.key] = { state[resource.key] = {
action.value: resource.permission(action) in granted for action in resource.actions action.value: resource.permission(action) in granted for action in resource.actions
@@ -690,7 +816,7 @@ def summarize_permissions(tree: PermissionTree, permissions: Iterable[str]) -> l
granted = set(permissions) granted = set(permissions)
lines: list[str] = [] lines: list[str] = []
for area in tree.areas: for area in tree.areas:
if area.view_key not in granted: if area.read_key not in granted:
continue continue
parts: list[str] = [] parts: list[str] = []
for resource in area.resources: for resource in area.resources:
@@ -706,12 +832,9 @@ def summarize_permissions(tree: PermissionTree, permissions: Iterable[str]) -> l
return lines return lines
#: Abgeleitet und nicht abgeschrieben: die Buchstaben stehen in ACTION_META.
#: Die zweite Liste hier hatte "U" fuer Bearbeiten, waehrend die Matrix "W"
#: zeigte -- dieselbe Aktion, zwei Kuerzel, je nachdem wo man hinsah.
ACTION_LETTERS: dict[Action, str] = { ACTION_LETTERS: dict[Action, str] = {
Action.VIEW: "R", action: meta[0] for action, meta in ACTION_META.items()
Action.CREATE: "C",
Action.EDIT: "U",
Action.DELETE: "D",
Action.EXECUTE: "X",
Action.EXPORT: "E",
Action.SECRETS: "S",
} }
@@ -112,7 +112,7 @@ def protect() -> None:
if not expected or not provided: if not expected or not provided:
abort(403, description="Das Sicherheitstoken des Formulars fehlt. Bitte Seite neu laden.") abort(403, description="Das Sicherheitstoken des Formulars fehlt. Bitte Seite neu laden.")
if not hmac.compare_digest(str(expected), str(provided)): if not hmac.compare_digest(str(expected), str(provided)):
abort(403, description="Das Sicherheitstoken des Formulars ist ungueltig. Bitte Seite neu laden.") abort(403, description="Das Sicherheitstoken des Formulars ist ungültig. Bitte Seite neu laden.")
def exempt(view): def exempt(view):
@@ -65,10 +65,10 @@ class PasswordPolicy:
if classes < self.require_classes: if classes < self.require_classes:
problems.append( problems.append(
f"Mindestens {self.require_classes} verschiedene Zeichenarten " f"Mindestens {self.require_classes} verschiedene Zeichenarten "
"(Klein-/Grossbuchstaben, Ziffern, Sonderzeichen)." "(Klein-/Großbuchstaben, Ziffern, Sonderzeichen)."
) )
if normalized.lower() in _FORBIDDEN: if normalized.lower() in _FORBIDDEN:
problems.append("Dieses Passwort ist zu haeufig und damit unsicher.") problems.append("Dieses Passwort ist zu häufig und damit unsicher.")
if username and len(username) >= 3 and username.lower() in normalized.lower(): if username and len(username) >= 3 and username.lower() in normalized.lower():
problems.append("Das Passwort darf den Benutzernamen nicht enthalten.") problems.append("Das Passwort darf den Benutzernamen nicht enthalten.")
if len(set(normalized)) <= 3 and normalized: if len(set(normalized)) <= 3 and normalized:
@@ -163,6 +163,7 @@ class SqliteSessionInterface(SessionInterface):
idle_minutes: int, idle_minutes: int,
absolute_hours: int, absolute_hours: int,
idle_lookup: Any = None, idle_lookup: Any = None,
absolute_lookup: Any = None,
) -> None: ) -> None:
self.database = database self.database = database
self.cookie_name = cookie_name self.cookie_name = cookie_name
@@ -172,6 +173,16 @@ class SqliteSessionInterface(SessionInterface):
#: Zeit zur Laufzeit einstellbar, ohne den Dienst neu zu starten -- und #: Zeit zur Laufzeit einstellbar, ohne den Dienst neu zu starten -- und
#: ohne eine zweite Datenbankverbindung, denn sie bekommt die offene. #: ohne eine zweite Datenbankverbindung, denn sie bekommt die offene.
self._idle_lookup = idle_lookup self._idle_lookup = idle_lookup
self._absolute_lookup = absolute_lookup
def _absolute(self, conn: Any) -> int:
"""Gesamtdauer in Stunden -- einstellbar wie die Leerlaufgrenze."""
if self._absolute_lookup is None:
return self.absolute_hours
try:
return int(self._absolute_lookup(conn)) or self.absolute_hours
except Exception: # pragma: no cover - Einstellung unlesbar
return self.absolute_hours
def _idle(self, conn: Any) -> int: def _idle(self, conn: Any) -> int:
if self._idle_lookup is None: if self._idle_lookup is None:
@@ -280,7 +291,10 @@ class SqliteSessionInterface(SessionInterface):
payload = json.dumps(dict(session), separators=(",", ":"), sort_keys=True) payload = json.dumps(dict(session), separators=(",", ":"), sort_keys=True)
with self.database.session() as conn, self.database.transaction(conn): with self.database.session() as conn, self.database.transaction(conn):
if session.new or not session.sid: if session.new or not session.sid:
session.sid = secrets.token_urlsafe(SESSION_ID_BYTES) # Eine vorhandene ID nicht wegwerfen: nach einem Wechsel beim
# Anmelden steht sie schon, und andere haben sie sich gemerkt.
if not session.sid:
session.sid = secrets.token_urlsafe(SESSION_ID_BYTES)
conn.execute( conn.execute(
"INSERT INTO user_sessions " "INSERT INTO user_sessions "
"(id, user_id, created_at, last_seen_at, idle_expires_at, " "(id, user_id, created_at, last_seen_at, idle_expires_at, "
@@ -292,7 +306,7 @@ class SqliteSessionInterface(SessionInterface):
_iso(now), _iso(now),
_iso(now), _iso(now),
_iso(now + timedelta(minutes=self._idle(conn))), _iso(now + timedelta(minutes=self._idle(conn))),
_iso(now + timedelta(hours=self.absolute_hours)), _iso(now + timedelta(hours=self._absolute(conn))),
session.get("_ip", ""), session.get("_ip", ""),
session.get("_ua", ""), session.get("_ua", ""),
payload, payload,
@@ -371,7 +385,12 @@ def rotate(conn: sqlite3.Connection, session: ServerSession, *, reason: str = "p
"UPDATE user_sessions SET revoked_at=?, revoked_reason=? WHERE id=?", "UPDATE user_sessions SET revoked_at=?, revoked_reason=? WHERE id=?",
(_iso(_now()), reason, old_sid), (_iso(_now()), reason, old_sid),
) )
session.sid = "" # Die neue ID entsteht **jetzt** und nicht erst beim Speichern der Antwort.
# Zwischen beidem laufen die Anmeldehaken, und einer davon bindet die
# Dateifreigaben ein und schreibt sich die Sitzung dazu. Mit einer leeren
# Sitzung hielt der Aufraeumer diese Einbindungen fuer verwaist und loeste
# sie binnen Minuten wieder.
session.sid = secrets.token_urlsafe(SESSION_ID_BYTES)
session.new = True session.new = True
session.modified = True session.modified = True
return old_sid return old_sid
@@ -58,6 +58,30 @@ class SettingSpec:
return str(raw) return str(raw)
def serialize(self, value: Any) -> str: def serialize(self, value: Any) -> str:
"""Ein Wert als Text fuer die Datenbank.
Der Wert kann als Python-Wert kommen (aus einem Formular, aus dem
Code) -- oder als die Zeichenkette, die schon in der Datenbank stand.
Genau so liest eine **Sicherung** ihn wieder ein, und genau dort lag
ein stiller Fehler: ``serialize("0")`` ergab ``"1"``, weil jede nicht
leere Zeichenkette wahr ist. Aus jedem abgeschalteten Schalter wurde
beim Einspielen ein eingeschalteter -- auch aus
``ldap_allow_insecure``. Ein JSON-Feld wurde ein zweites Mal kodiert,
und aus der Navigationsreihenfolge damit eine Zeichenkette.
Deshalb: eine Zeichenkette erst lesen. Was hier herauskommt, muss
durch ``serialize`` unveraendert hindurchgehen -- sonst ueberlebt kein
Wert eine Sicherung.
"""
if isinstance(value, str):
if self.kind == "bool":
value = self.parse(value)
elif self.kind == "json":
try:
value = json.loads(value)
except ValueError:
# Kein JSON-Text, also ein Wert: dann wird er kodiert.
pass
if self.kind == "bool": if self.kind == "bool":
return "1" if value else "0" return "1" if value else "0"
if self.kind == "json": if self.kind == "json":
@@ -67,6 +91,45 @@ class SettingSpec:
return "" if value is None else str(value) return "" if value is None else str(value)
#: So lang darf ein Wert im Protokoll werden -- danach wird gekuerzt. Ein
#: JSON-Feld mit der halben Navigationsreihenfolge macht die Zeile unlesbar.
WERT_GRENZE = 80
class Aenderung(str):
"""Eine geaenderte Einstellung, lesbar als ``schluessel: alt -> neu``.
Eine Zeichenkette und kein eigener Typ, weil die Aufrufer die Liste
weiterhin mit ``", ".join(...)`` in einen Protokolleintrag schreiben --
so gibt es keine Stelle, die man beim Umbau vergessen kann.
"""
key: str
alt: str
neu: str
def __new__(cls, key: str, alt: Any, neu: Any, *, geheim: bool = False) -> "Aenderung":
alt_text = "(gesetzt)" if geheim and alt else _kurz(alt)
neu_text = "(geaendert)" if geheim else _kurz(neu)
selbst = super().__new__(cls, f"{key}: {alt_text} -> {neu_text}")
selbst.key = key
selbst.alt = alt_text
selbst.neu = neu_text
return selbst
def _kurz(wert: Any) -> str:
"""Ein Wert als kurzer Text -- leer wird sichtbar, lang wird gekuerzt."""
if wert is None or wert == "":
return "(leer)"
if isinstance(wert, bool):
return "ein" if wert else "aus"
text = " ".join(str(wert).split())
if len(text) > WERT_GRENZE:
return text[: WERT_GRENZE - 1] + "…"
return text
class SettingsStore: class SettingsStore:
"""Zugriff auf ``settings``. Alle Methoden nehmen die bestehende Verbindung.""" """Zugriff auf ``settings``. Alle Methoden nehmen die bestehende Verbindung."""
@@ -140,16 +203,24 @@ class SettingsStore:
def set_many( def set_many(
self, conn: sqlite3.Connection, values: dict[str, Any], *, actor: str = "" self, conn: sqlite3.Connection, values: dict[str, Any], *, actor: str = ""
) -> list[str]: ) -> list["Aenderung"]:
"""Setzt mehrere Werte, gibt die tatsaechlich geaenderten Schluessel zurueck.""" """Setzt mehrere Werte und gibt die Aenderungen zurueck.
changed: list[str] = []
Jede Aenderung ist eine Zeichenkette der Form ``schluessel: alt -> neu``
-- und traegt die Einzelteile als Attribute. Damit steht im
Aenderungsprotokoll, was sich geaendert hat, ohne dass jede
Einstellungsseite es einzeln zusammenbauen muss. Vorher stand dort nur
der Schluessel, und die Frage "was war vorher eingestellt?" liess sich
aus dem Protokoll nicht beantworten.
"""
changed: list[Aenderung] = []
current = self.get_many(conn, list(values)) current = self.get_many(conn, list(values))
for key, value in values.items(): for key, value in values.items():
spec = self.spec(key) spec = self.spec(key)
if spec.serialize(current.get(key)) == spec.serialize(value): if spec.serialize(current.get(key)) == spec.serialize(value):
continue continue
self.set(conn, key, value, actor=actor) self.set(conn, key, value, actor=actor)
changed.append(key) changed.append(Aenderung(key, current.get(key), value, geheim=spec.kind == "secret"))
return changed return changed
def delete(self, conn: sqlite3.Connection, key: str) -> None: def delete(self, conn: sqlite3.Connection, key: str) -> None:
@@ -657,9 +657,19 @@
gap: var(--sp-5); gap: var(--sp-5);
} }
/* Bleibt stehen wie die Kopfzeile: Fassung und angemeldeter Benutzer sind
Angaben, nach denen man mitten in einer langen Seite fragt -- und bis
hierher zu scrollen, nur um zu sehen, welche Fassung laeuft, ist eine
Zumutung. Derselbe milchige Untergrund wie oben, damit Inhalt, der
darunter durchlaeuft, nicht mitliest. */
.page-footer { .page-footer {
padding: var(--sp-4) var(--sp-5); position: sticky;
bottom: 0;
z-index: var(--z-sticky);
padding: var(--sp-3) var(--sp-5);
border-top: 1px solid var(--border-subtle); border-top: 1px solid var(--border-subtle);
background: color-mix(in srgb, var(--canvas) 82%, transparent);
backdrop-filter: blur(12px) saturate(1.4);
color: var(--text-faint); color: var(--text-faint);
font-size: var(--fs-xs); font-size: var(--fs-xs);
display: flex; display: flex;
@@ -685,7 +685,10 @@
.modal__sub { font-size: var(--fs-xs); color: var(--text-faint); margin-top: 2px; } .modal__sub { font-size: var(--fs-xs); color: var(--text-faint); margin-top: 2px; }
/* Eine Datei-Vorschau soll die ganze Flaeche des Dialogs nutzen; der /* Eine Datei-Vorschau soll die ganze Flaeche des Dialogs nutzen; der
uebliche Innenabstand des Dialogkoerpers wuerde sie einrahmen. */ uebliche Innenabstand des Dialogkoerpers wuerde sie einrahmen. */
.modal__body--flush { padding: 0; max-height: none; } /* Zwei Klassen am selben Element: diese hier muss gewinnen, obwohl
.modal__body weiter unten steht. Mit der doppelten Klasse haengt das
nicht mehr an der Reihenfolge im Stylesheet. */
.modal__body.modal__body--flush { padding: 0; max-height: none; overflow: visible; }
.modal__caption { .modal__caption {
padding: 0 var(--sp-5) var(--sp-2); padding: 0 var(--sp-5) var(--sp-2);
color: var(--text-muted); color: var(--text-muted);
@@ -702,6 +705,174 @@
darunter. --surface-0 gibt es nicht, der Rahmen blieb durchsichtig. */ darunter. --surface-0 gibt es nicht, der Rahmen blieb durchsichtig. */
background: var(--surface-1); background: var(--surface-1);
} }
/* -- Betrachter: Bild, PDF, Rahmen ------------------------------------- */
/* Eine feste Hoehe fuer alle drei Anzeigen: sonst springt der Dialog beim
Wechsel zwischen Foto und PDF. */
.viewer {
position: relative;
display: flex;
flex-direction: column;
/* Was der Dialog uebrig laesst: Kopf, Dateiname und Fusszeile brauchen
zusammen rund 190 Pixel. Ein fester Anteil der Fensterhoehe schob die
Zoomleiste auf kleinen Fenstern unter den Rand. */
height: min(calc(100dvh - 240px), 760px);
min-height: 220px;
background: var(--surface-1);
}
/* Hochkant -- Tablet oder Monitor im Hochformat -- gibt es unter dem Dialog
viel Platz, den die Obergrenze verschenkt. Beim Ausfuellen eines
Formulars zaehlt jede sichtbare Zeile, also nehmen wir dort die Hoehe,
die da ist. Der Rest (Kopf, Dateiname, Fusszeile) braucht rund 200 Pixel;
die 240 lassen etwas Luft. */
@media (orientation: portrait) {
.viewer {
height: calc(100dvh - 240px);
}
}
.viewer > .file-preview {
flex: 1;
height: auto;
}
.viewer__stage {
position: relative;
flex: 1;
/* Ohne min-height waechst die Buehne mit dem Bild, und dessen
max-height:100% haengt dann an sich selbst: ein 4032x3024-Foto stand
36 Pixel ueber den Rand hinaus. */
min-height: 0;
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
/* Die Seite darf nicht mitwandern, wenn im Bild geschoben wird. */
touch-action: none;
cursor: zoom-in;
}
.viewer__stage--gezogen {
cursor: grab;
}
.viewer__img {
/* Der Ausgangszustand ist "passt in die Flaeche" -- genau das fehlte:
ein Foto vom iPad ist 4032 Pixel breit und wurde im Rahmen 1:1
gezeigt, also als Ausschnitt aus der Mitte. */
max-width: 100%;
max-height: 100%;
/* Zoomen soll nicht unscharf wirken, solange nicht vergroessert wird. */
transform-origin: center center;
will-change: transform;
user-select: none;
-webkit-user-drag: none;
}
/* pdf.js verlangt einen absolut positionierten, scrollenden Behaelter --
der liegt deshalb *innen*. Aussen ein gewoehnlicher Flex-Kasten, damit
Zoomleiste und Speicherzeile ihren Platz behalten. Vorher rechnete der
Bereich mit "bottom: var(--sp-10)", also mit genau einer Leiste; die
zweite Zeile lag dann unter dem Dialogrand. */
.viewer__pdf {
position: relative;
flex: 1;
min-height: 0;
/* Ein PDF ist weisses Papier. pdf.js malt Formularfelder mit Systemfarben
und ueberlaesst Ankreuzfelder dem Browser -- im dunklen Design heisst
CanvasText weiss, und die Haken waren auf dem Papier unsichtbar. Fuer
diesen Ausschnitt gilt deshalb immer das helle Schema. */
color-scheme: light;
}
/* pdf.js rechnet fuer die Seite mit dem Standard "content-box": ihr
9-Pixel-Rahmen liegt *ausserhalb* der angegebenen Breite. Unser globales
"border-box" rechnete ihn hinein -- die Leinwand wurde 18 Pixel schmaler
als die Anmerkungsebene, und die Formularfelder wanderten nach rechts
unten, immer weiter je weiter unten sie lagen. */
.viewer__pdf .pdfViewer .page {
box-sizing: content-box;
}
.viewer__pdfscroll {
position: absolute;
inset: 0;
overflow: auto;
-webkit-overflow-scrolling: touch;
}
.viewer__bar {
display: flex;
align-items: center;
gap: var(--sp-2);
padding: var(--sp-2) var(--sp-4);
border-top: 1px solid var(--border);
background: var(--surface-2, var(--surface-1));
}
.viewer__hint {
margin-inline-start: auto;
}
/* Bildfeld eines Formulars: der Kasten zeigt das gewaehlte Foto, die
Schaltflaeche liegt darin. Acrobats eigener Knopf tut im Browser nichts --
das hier ist der Ersatz. */
.viewer__bildfeld [data-viewer-bildvorschau] {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
object-fit: contain;
background: #fff;
}
.viewer__bildwahl {
position: absolute;
inset: auto 0 4px 0;
display: flex;
justify-content: center;
cursor: pointer;
}
.viewer__bildwahl > span {
padding: 4px 10px;
border-radius: var(--r-sm, 6px);
background: var(--accent);
color: #fff;
font-size: var(--fs-xs);
font-weight: 600;
box-shadow: var(--shadow-sm, 0 1px 3px rgb(0 0 0 / 0.3));
}
.viewer__bildfeld--belegt .viewer__bildwahl > span {
opacity: 0.85;
}
.viewer__save {
display: flex;
flex-wrap: wrap;
align-items: flex-end;
gap: var(--sp-3);
padding: var(--sp-3) var(--sp-4);
border-top: 1px solid var(--border);
background: var(--surface-2, var(--surface-1));
}
.viewer__save .viewer__feld {
flex: 1 1 12rem;
margin: 0;
}
/* Die Entscheidung steht rechts und der Haken darueber: er gehoert zum
Speichern, nicht zu den Feldern davor. */
.viewer__saveaktionen {
margin-left: auto;
display: flex;
flex-direction: column;
align-items: flex-end;
gap: var(--sp-2);
}
.viewer__saveknoepfe {
display: flex;
gap: var(--sp-2);
}
/* Nach "gibt es schon": der Haken ist die Antwort -- also faellt er auf. */
.viewer__saveaktionen .check.is-gefragt {
outline: 2px solid var(--accent);
outline-offset: 3px;
border-radius: var(--r-xs);
}
/* Eigener Name: ".field--inline" gibt es schon und steht dort fuer
"waagerecht" -- diese Regel haette sie app-weit umgestellt. */
.viewer__feld {
display: flex;
flex-direction: column;
gap: 2px;
}
/* Der Dateiname ist ein Knopf (er oeffnet die Vorschau), soll aber wie ein /* Der Dateiname ist ein Knopf (er oeffnet die Vorschau), soll aber wie ein
Link aussehen -- ein Knopf mit Rahmen mitten in einer Tabellenzeile waere Link aussehen -- ein Knopf mit Rahmen mitten in einer Tabellenzeile waere
unruhig. */ unruhig. */
@@ -839,7 +1010,25 @@
text-align: center; text-align: center;
} }
.matrix thead th:first-child { text-align: left; } .matrix thead th:first-child { text-align: left; }
.matrix td:not(:first-child), .matrix th:not(:first-child) { width: 62px; text-align: center; } /* Kuerzel, englischer Name, deutsche Beschriftung -- untereinander. Nur das
Kuerzel allein war eine Spalte, die man nachschlagen musste; die Legende
steht unter einer Tabelle, die man dafuer erst zu Ende scrollt. */
.matrix__spalte { display: flex; flex-direction: column; align-items: center; line-height: 1.15; }
.matrix__spalte b {
font-family: var(--font-mono);
font-size: var(--fs-sm);
color: var(--text);
font-weight: 650;
}
.matrix__spalte span { font-size: var(--fs-xs); color: var(--text-muted); text-transform: none; }
.matrix__spalte small {
font-size: 10px;
color: var(--text-faint);
text-transform: none;
letter-spacing: 0;
font-weight: 400;
}
.matrix td:not(:first-child), .matrix th:not(:first-child) { width: 72px; text-align: center; }
.matrix tr.matrix__area > td { .matrix tr.matrix__area > td {
background: var(--surface-2); background: var(--surface-2);
font-weight: 640; font-weight: 640;
@@ -865,6 +1054,46 @@
} }
.matrix__resource-label { display: flex; flex-direction: column; gap: 1px; } .matrix__resource-label { display: flex; flex-direction: column; gap: 1px; }
.matrix__resource-label small { color: var(--text-faint); font-size: var(--fs-xs); } .matrix__resource-label small { color: var(--text-faint); font-size: var(--fs-xs); }
.matrix__zeile { display: flex; align-items: baseline; gap: var(--sp-2); }
.matrix__zeile b { font-weight: 600; }
/* Unterzeile: gehoert zur Zeile darueber. Ohne Einrueckung liest man sechs
unabhaengige Dinge, wo sechs Teile eines Ganzen stehen. */
.matrix tr.matrix__unterzeile > td:first-child { padding-left: var(--sp-6); }
.matrix tr.matrix__unterzeile > td:first-child .matrix__zeile b { font-weight: 500; }
/* Was eine Aktion in dieser Zeile bedeutet -- in der Zeile, nicht im
Titelattribut: mit dem Finger gibt es kein "hover". */
.matrix__bedeutung { color: var(--text-faint); font-size: var(--fs-xs); }
.matrix__bedeutung b {
font-family: var(--font-mono);
color: var(--text-muted);
font-weight: 500;
}
.matrix__alle {
border: 1px solid var(--border);
background: transparent;
color: var(--text-muted);
border-radius: var(--r-sm);
font-size: 10px;
line-height: 1;
padding: 2px 4px;
cursor: pointer;
}
.matrix__alle:hover { color: var(--accent); border-color: var(--accent); }
.matrix__filter {
display: flex;
align-items: baseline;
gap: var(--sp-3);
margin-bottom: var(--sp-3);
flex-wrap: wrap;
}
.matrix__filter-hint { color: var(--text-faint); font-size: var(--fs-xs); }
/* Eine ausgefilterte Zeile ist versteckt, nicht entfernt: ihre Kaestchen
bleiben im Formular, sonst loescht ein Filter beim Speichern Rechte. */
.matrix tr[hidden] { display: none; }
.matrix__legend { .matrix__legend {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
@@ -902,8 +1131,55 @@
white-space: pre-wrap; white-space: pre-wrap;
word-break: break-word; word-break: break-word;
} }
/* ---------------------------------------------------------------------- #
Aufklappbares
---------------------------------------------------------------------- #
Der native Marker eines <summary> verschwindet, sobald das Element ein
Flex-Container ist -- und genau das sind unsere Kartenkoepfe. Auf der
DHCP-Seite stand der Optionskatalog deshalb als Kasten da, dem man nicht
ansah, dass er sich oeffnen laesst.
Der Pfeil wird deshalb selbst gezeichnet: als Flex- bzw. Inline-Element,
das in beiden Faellen erscheint, in Akzentfarbe (das ist die Farbe, mit
der diese Oberflaeche "hier kannst du etwas tun" sagt) und mit einer
Drehung beim Oeffnen. Eine Stelle fuer alle Aufklapper -- Karten,
Vorschauen, Abschnitte. */
details > summary {
list-style: none;
cursor: pointer;
}
details > summary::-webkit-details-marker { display: none; }
details:not(.nav-tree) > summary::before {
content: "";
display: inline-block;
flex: 0 0 auto;
align-self: center;
width: 0;
height: 0;
margin-right: var(--sp-2);
border-left: 6px solid var(--accent);
border-top: 5px solid transparent;
border-bottom: 5px solid transparent;
transition: transform var(--dur) var(--ease);
}
details:not(.nav-tree)[open] > summary::before { transform: rotate(90deg); }
/* Der Navigationsbaum bringt seinen eigenen Winkel mit -- der bekommt
dieselbe Farbe, damit "aufklappbar" ueberall gleich aussieht. */
.nav-tree__chevron { color: var(--accent); }
.log-view mark { background: var(--warning-soft); color: var(--warning); border-radius: 3px; } .log-view mark { background: var(--warning-soft); color: var(--warning); border-radius: 3px; }
/* Stufen im laufenden Protokoll. Die Zuordnung der Farbrollen ist Absicht:
--warning ist bernsteinfarben und traegt den HINWEIS, --danger ist rot und
traegt die WARNUNG. Wer das nach den Namen "aufraeumt", dreht die Bedeutung
um: ein Geraet, das nicht antwortet, waere dann orange und eines, das neu
startet, rot. */
.log-view__zeile--info { color: var(--success); }
.log-view__zeile--hinweis { color: var(--warning); }
.log-view__zeile--warnung { color: var(--danger); }
.log-view__zeile--fehler { color: var(--danger); font-weight: 600; }
.kv { display: grid; grid-template-columns: auto 1fr; gap: var(--sp-2) var(--sp-4); font-size: var(--fs-base); } .kv { display: grid; grid-template-columns: auto 1fr; gap: var(--sp-2) var(--sp-4); font-size: var(--fs-base); }
.kv dt { color: var(--text-faint); white-space: nowrap; } .kv dt { color: var(--text-faint); white-space: nowrap; }
.kv dd { margin: 0; min-width: 0; overflow-wrap: anywhere; } .kv dd { margin: 0; min-width: 0; overflow-wrap: anywhere; }
@@ -979,6 +1255,12 @@
--tile-accent: var(--text-faint); --tile-accent: var(--text-faint);
position: relative; position: relative;
overflow: hidden; overflow: hidden;
/* Spalte, damit die Fusszeile unten haengen kann (siehe .tile__actions).
Die Kacheln eines Rasters sind gleich hoch, ihr Inhalt aber nicht: mal
fehlt der Switch, mal der Standort. Ohne diesen Halt stuenden die Knoepfe
in jeder Kachel auf einer anderen Hoehe -- und das Auge sucht sie. */
display: flex;
flex-direction: column;
padding: var(--sp-4); padding: var(--sp-4);
padding-left: calc(var(--sp-4) + 3px); padding-left: calc(var(--sp-4) + 3px);
border: 1px solid var(--border-subtle); border: 1px solid var(--border-subtle);
@@ -1040,6 +1322,73 @@
color: var(--text-faint); color: var(--text-faint);
} }
/* Mitlesen auf der Wartungsseite: die Ausgabe eines laufenden Auftrags,
direkt unter seinem Geraet. Dunkler Grund wie in jeder Konsole -- was
dort steht, kommt von einer fremden Maschine und nicht aus dieser
Oberflaeche. */
.job-output {
margin: 0;
max-height: 220px;
overflow: auto;
padding: var(--sp-3) var(--sp-4);
background: #101216;
color: #d7dbe0;
font-family: var(--font-mono);
font-size: var(--fs-xs);
line-height: 1.5;
white-space: pre-wrap;
word-break: break-word;
}
/* ---------------------------------------------------------------------- #
Bildschirmsitzung (VNC)
Die Leinwand bekommt einen dunklen Grund -- unabhaengig vom Thema der
Oberflaeche: was darauf erscheint, ist der Bildschirm eines fremden
Geraets, und ein heller Rahmen darum laesst ihn wie einen Teil dieser
Seite aussehen. Er ist keiner.
---------------------------------------------------------------------- */
.screen {
border: 1px solid var(--border-subtle);
border-radius: var(--r-md);
background: #101216;
overflow: hidden;
}
.screen__canvas {
width: 100%;
/* Genug Hoehe, dass ein Bildschirm erkennbar ist, ohne die Seite zu
sprengen; noVNC skaliert das Bild hinein. */
height: min(72vh, 760px);
}
/* Im Fenster ist weniger Platz -- und das Fenster soll nicht ueber den
Bildschirmrand hinauswachsen, nur weil das Geraet hochaufloesend ist. */
dialog.modal .screen__canvas { height: min(62vh, 620px); }
.screen__status {
font-size: var(--fs-sm);
color: var(--text-faint);
}
.screen__status--ok { color: var(--success); }
.screen__status--warnung { color: var(--warning); }
.screen__status--fehler { color: var(--danger); }
/* Fusszeile der Kachel: links der Bildschirm, rechts der Neustart.
``margin-top: auto`` schiebt sie an den unteren Rand -- unabhaengig
davon, wie viel darueber steht. Die beiden Eingriffe stehen damit in
jeder Kachel an derselben Stelle, und der eine liegt nicht dort, wo eben
noch der andere war. */
.tile__actions {
display: flex;
align-items: center;
gap: var(--sp-2);
margin-top: auto;
padding-top: var(--sp-3);
}
.tile__actions > :last-child { margin-left: auto; }
/* Steht nur ein Knopf da, bleibt er links -- sonst spraenge der Neustart bei
einem Geraet ohne Bildschirmzugang an die Stelle, an der nebenan VNC
steht. Das Fuellstueck haelt den Platz. */
.tile__spacer { flex: 1 1 auto; }
/* Marke und Karte stehen uebereinander im normalen Fluss. Vorher lag die /* Marke und Karte stehen uebereinander im normalen Fluss. Vorher lag die
Marke absolut positioniert hinter der Karte -- auf einem flachen Fenster Marke absolut positioniert hinter der Karte -- auf einem flachen Fenster
schnitt die Karte mitten durch den Schriftzug und die Funktionszeile. Ein schnitt die Karte mitten durch den Schriftzug und die Funktionszeile. Ein
@@ -1163,3 +1512,340 @@
.hide-sm { display: none !important; } .hide-sm { display: none !important; }
} }
} }
/* -- Sortierbare Liste (Navigationsreihenfolge) --------------------------- */
/* Symbol und Beschriftung stehen in einer Zeile -- so wie im Menue selbst.
Vorher brach die Beschriftung unter das Symbol, und die Liste sah aus wie
etwas anderes als das, was sie sortiert. */
.reorder {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 4px;
}
.reorder__row {
display: flex;
align-items: center;
gap: 10px;
padding: 6px 10px;
border: 1px solid var(--border);
border-radius: var(--r-sm);
background: var(--surface-2);
cursor: grab;
}
.reorder__row > .flex-1 {
display: flex;
align-items: center;
gap: 8px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.reorder__row.is-dragging {
opacity: 0.5;
}
.reorder__grip {
color: var(--text-muted);
display: inline-flex;
}
.reorder__pos {
min-width: 1.6em;
text-align: right;
font-variant-numeric: tabular-nums;
color: var(--text-muted);
font-size: var(--fs-xs);
}
.reorder__moves {
display: inline-flex;
gap: 2px;
}
.reorder__group {
list-style: none;
}
.reorder--child {
margin: 4px 0 4px 28px;
}
.reorder--child .reorder__row {
background: transparent;
border-style: dashed;
}
/* -- Unterschrift --------------------------------------------------------- */
/* Die Zeichenflaeche braucht "touch-action: none": sonst scrollt ein iPad die
Seite, statt einen Strich zu zeichnen -- und es sieht aus, als reagierte das
Feld nicht. */
.viewer__sign {
display: flex;
flex-direction: column;
gap: 8px;
padding: 10px 12px;
border-top: 1px solid var(--border);
background: var(--surface-2);
}
.viewer__signfelder {
display: flex;
gap: 10px;
flex-wrap: wrap;
}
.viewer__signpad {
width: 100%;
height: 160px;
border: 1px dashed var(--border-strong, var(--border));
border-radius: var(--r-sm);
background: #fff;
touch-action: none;
cursor: crosshair;
}
.viewer__signaktionen {
display: flex;
align-items: center;
gap: 8px;
justify-content: flex-end;
flex-wrap: wrap;
}
.viewer__pdfscroll--setzen {
cursor: crosshair;
}
.viewer__pdfscroll--setzen .page {
outline: 2px dashed var(--accent, #c8791f);
outline-offset: -2px;
}
/* Die gesetzte Unterschrift liegt auf der Seite -- in Prozent, damit sie beim
Zoomen an ihrer Stelle bleibt. Sie nimmt Zeiger an: verschieben mit dem
Block, Groesse mit dem Griff unten rechts, entfernen mit dem Kreuz.
"touch-action: none" ist auf dem iPad die Bedingung dafuer, dass ein Finger
zieht statt die Seite zu scrollen. */
/* Die Marke ist genau die Zeichnung: ihre Unterkante liegt auf dem
Ankerpunkt -- dieselbe Kante, an der pdf-lib das Bild setzt. Der Text
haengt darunter und darf die Hoehe nicht mitbestimmen. */
.viewer__unterschrift {
position: absolute;
z-index: 3;
cursor: grab;
touch-action: none;
/* "outline" statt "border": ein Rahmen haette die Marke um zwei Pixel
groesser gemacht und die Bildkante vom Ankerpunkt weggeschoben. */
outline: 1px dashed transparent;
outline-offset: 1px;
}
.viewer__unterschrift:hover,
.viewer__unterschrift.is-fassend {
outline-color: var(--accent);
cursor: grabbing;
}
.viewer__unterschrift img {
display: block;
width: 100%;
pointer-events: none;
}
/* Dieselbe Rechnung wie im PDF (Breite durch 18): auf dem Schirm steht, was
spaeter in der Datei steht. */
/* "width: max-content": der Strich ist so lang wie der Text darunter -- so
wie im PDF. Waere er so breit wie der Block, zeigte die Vorschau etwas
anderes als die Datei. */
.viewer__unterschrifttext {
/* Unter der Zeichnung, nicht in ihr: "top: 100%" setzt die Oberkante des
Strichs auf die Bildkante -- genau dort zieht ihn auch pdf-lib. Abstand,
Strichstaerke und Innenabstand rechnet das Skript in Punkten. */
position: absolute;
top: 100%;
left: 0;
/* Helvetica: dieselbe Schrift, die das PDF benutzt (StandardFonts.Helvetica).
Mit der Hausschrift sah die Vorschau anders aus als das Ergebnis. Die
Groesse setzt das Skript je Unterschrift -- hier steht nur ein Rueckfall. */
font-family: Helvetica, Arial, sans-serif;
/* Die tatsaechliche Groesse setzt das Skript in Pixeln (Punkte mal
Pixel-je-Punkt der Seite). Dieser Wert gilt nur, solange sie noch nicht
gerechnet wurde -- geerbt wuerde sonst die Schriftgroesse der Seite. */
font-size: 8px;
line-height: 1.15;
color: #111;
border-top: 1px solid rgba(0, 0, 0, 0.85);
pointer-events: none;
white-space: nowrap;
width: max-content;
}
.viewer__unterschriftgriff,
.viewer__unterschriftweg {
position: absolute;
width: 16px;
height: 16px;
padding: 0;
border: 1px solid var(--accent);
border-radius: 3px;
background: var(--surface-1);
color: var(--accent);
font-size: 11px;
line-height: 1;
opacity: 0;
cursor: nwse-resize;
}
.viewer__unterschriftweg {
cursor: pointer;
top: -8px;
right: -8px;
}
.viewer__unterschriftgriff {
right: -8px;
bottom: -8px;
}
.viewer__unterschrift:hover .viewer__unterschriftgriff,
.viewer__unterschrift:hover .viewer__unterschriftweg,
.viewer__unterschrift.is-fassend .viewer__unterschriftgriff {
opacity: 1;
}
/* Auf einem Tastbildschirm gibt es kein "hover" -- dort stehen die Knoepfe
immer, sonst kaeme man nie an sie heran. */
@media (hover: none) {
.viewer__unterschriftgriff,
.viewer__unterschriftweg {
opacity: 1;
}
}
/* -- Ergebnis der Signaturpruefung ---------------------------------------- */
/* Der Schleier liegt ueber der Seite und nimmt keine Klicks an: ein
unterschriebenes Dokument ist flach, aber bei einem Irrtum soll die
Kennzeichnung nicht auch noch die Bedienung blockieren. */
.viewer__pdf--unveraendert {
box-shadow: inset 0 0 0 3px var(--success);
}
.viewer__pdf--unveraendert::after,
.viewer__pdf--veraendert::after {
content: "";
position: absolute;
inset: 0;
pointer-events: none;
z-index: 4;
}
.viewer__pdf--unveraendert::after {
background: rgba(47, 158, 68, 0.1);
}
.viewer__pdf--veraendert {
box-shadow: inset 0 0 0 3px var(--danger);
}
.viewer__pdf--veraendert::after {
background: rgba(201, 42, 42, 0.08);
}
.viewer__pruefung {
margin: 0;
padding: 6px 12px;
font-size: var(--fs-xs);
border-top: 1px solid var(--border);
}
.viewer__pruefung--gut {
color: var(--success);
background: rgba(47, 158, 68, 0.12);
}
.viewer__pruefung--schlecht {
color: var(--danger);
background: rgba(201, 42, 42, 0.12);
}
/* -- Tabelle, die schmal zu Bloecken wird -------------------------------- */
/* Eine Protokollzeile besteht aus sieben Angaben, von denen eine lang ist:
das Detail. Auf einem breiten Schirm passt das nebeneinander. Wird es eng,
ist eine Spalte von zwei Zentimetern fuer eine Pruefsumme keine Loesung --
dann steht jede Angabe auf ihrer eigenen Zeile, beschriftet. */
.table--stapelbar .td--detail {
/* Zeilenumbrueche aus dem Eintrag bleiben stehen: die Pruefsumme steht
unter dem lesbaren Teil, nicht mitten darin. Und ein Hash ohne Trennstelle
darf notfalls hart umbrechen. */
white-space: pre-line;
overflow-wrap: anywhere;
max-width: 52ch;
}
@media (max-width: 1100px) {
.table--stapelbar thead {
display: none;
}
.table--stapelbar,
.table--stapelbar tbody,
.table--stapelbar tr,
.table--stapelbar td {
display: block;
width: auto;
}
.table--stapelbar tr {
padding: var(--sp-3) 0;
border-bottom: 1px solid var(--border);
}
.table--stapelbar td {
display: grid;
grid-template-columns: 8.5rem 1fr;
gap: var(--sp-2);
align-items: baseline;
border: 0;
padding: 2px var(--sp-3);
white-space: normal;
}
.table--stapelbar td::before {
content: attr(data-spalte);
color: var(--text-muted);
font-size: var(--fs-xs);
}
.table--stapelbar .td--detail {
max-width: none;
}
/* Leere Zellen brauchen keine Zeile. */
.table--stapelbar td:empty {
display: none;
}
}
/* -- Dateiliste: Symbol und Name in einer Zeile -------------------------- */
/* Das Symbol ist ein Block-SVG; ohne diese Regel stand der Name darunter und
eine Ordnerzeile sah doppelt so hoch aus wie eine Dateizeile. */
.file-cell {
display: flex;
align-items: center;
gap: 6px;
}
.file-cell > svg {
flex: 0 0 auto;
}
@@ -109,7 +109,7 @@ export function showToast(message, type = "info", { timeout = 6000 } = {}) {
const close = document.createElement("button"); const close = document.createElement("button");
close.type = "button"; close.type = "button";
close.className = "toast__close"; close.className = "toast__close";
close.setAttribute("aria-label", "Meldung schliessen"); close.setAttribute("aria-label", "Meldung schließen");
close.textContent = "×"; close.textContent = "×";
toast.append(body, close); toast.append(body, close);
toastStack.appendChild(toast); toastStack.appendChild(toast);
@@ -293,7 +293,7 @@ function initConfirmations() {
event.preventDefault(); event.preventDefault();
event.stopPropagation(); event.stopPropagation();
pending = trigger; pending = trigger;
titleNode.textContent = trigger.dataset.confirmTitle || "Bitte bestaetigen"; titleNode.textContent = trigger.dataset.confirmTitle || "Bitte bestätigen";
textNode.textContent = trigger.dataset.confirm; textNode.textContent = trigger.dataset.confirm;
okButton.textContent = trigger.dataset.confirmOk || "Fortfahren"; okButton.textContent = trigger.dataset.confirmOk || "Fortfahren";
okButton.className = `btn btn--${trigger.dataset.confirmStyle || "danger"}`; okButton.className = `btn btn--${trigger.dataset.confirmStyle || "danger"}`;
@@ -440,6 +440,56 @@ function initPermissionMatrix() {
}); });
}; };
/* -- Ganze Zeile, ganze Spalte, Filter -----------------------------
Bei 37 Zeilen und bis zu sieben Spalten je Bereich sind das ueber
zweihundert Kaestchen. Ohne diese drei Hilfen setzt man oben den
Bereichsschalter und laesst es dabei -- und die feine Aufteilung
haette nichts gebracht. */
// Nur was man sieht: eine vom Filter ausgeblendete Zeile darf ein
// Sammelknopf nicht mitnehmen. Sonst setzt man "alle Write" waehrend nach
// "Neustart" gefiltert ist und aendert dabei Zeilen, die gar nicht auf dem
// Schirm stehen -- und merkt es erst nach dem Speichern.
const sichtbar = (box) => !box.disabled && !box.closest("tr").hidden;
const felder = (bereich) =>
$$('input[type="checkbox"][name][data-action]', bereich).filter(sichtbar);
table.addEventListener("click", (event) => {
const ziel = event.target;
if (!(ziel instanceof HTMLElement)) return;
const zeilenknopf = ziel.closest("[data-matrix-row]");
if (zeilenknopf) {
event.preventDefault();
const row = zeilenknopf.closest("tr");
if (!row) return;
const kaestchen = felder(row);
// Erst wenn alles gesetzt ist, nimmt der Knopf weg -- sonst waere
// ein zweiter Klick auf eine halb gesetzte Zeile ein Ruecksprung.
const alleGesetzt = kaestchen.length > 0 && kaestchen.every((box) => box.checked);
kaestchen.forEach((box) => {
box.checked = !alleGesetzt;
box.dispatchEvent(new Event("change", { bubbles: true }));
});
return;
}
const spaltenknopf = ziel.closest("[data-matrix-column]");
if (spaltenknopf) {
event.preventDefault();
const aktion = spaltenknopf.dataset.matrixColumn;
const bereich = spaltenknopf.dataset.area;
const kaestchen = $$(
`tr[data-area="${bereich}"] input[data-action="${aktion}"][name]`,
table
).filter(sichtbar);
const alleGesetzt = kaestchen.length > 0 && kaestchen.every((box) => box.checked);
kaestchen.forEach((box) => {
box.checked = !alleGesetzt;
box.dispatchEvent(new Event("change", { bubbles: true }));
});
}
});
$$("[data-area-switch]", table).forEach((master) => { $$("[data-area-switch]", table).forEach((master) => {
const areaKey = master.dataset.areaSwitch; const areaKey = master.dataset.areaSwitch;
syncArea(areaKey); syncArea(areaKey);
@@ -451,8 +501,12 @@ function initPermissionMatrix() {
if (!(box instanceof HTMLInputElement) || !box.dataset.action) return; if (!(box instanceof HTMLInputElement) || !box.dataset.action) return;
const row = box.closest("tr"); const row = box.closest("tr");
if (!row) return; if (!row) return;
const viewBox = $('input[data-action="view"]', row); // "read", nicht "view": die Aktion heisst seit 2.8.0 wie das, was sie
if (box.dataset.action === "view") { // tut. Mit dem alten Namen fand diese Zeile das Kaestchen nicht mehr --
// die Matrix haette sich weiter bedienen lassen und dabei die Regel
// "jede Aktion setzt Ansehen voraus" stillschweigend fallen gelassen.
const viewBox = $('input[data-action="read"]', row);
if (box.dataset.action === "read") {
// "Ansehen" abwaehlen entfernt alles andere dieser Zeile. // "Ansehen" abwaehlen entfernt alles andere dieser Zeile.
if (!box.checked) { if (!box.checked) {
$$('input[type="checkbox"][name]', row).forEach((other) => { $$('input[type="checkbox"][name]', row).forEach((other) => {
@@ -469,6 +523,30 @@ function initPermissionMatrix() {
master.dispatchEvent(new Event("change")); master.dispatchEvent(new Event("change"));
} }
}); });
const suche = $("[data-matrix-filter]");
const zaehler = $("[data-matrix-count]");
if (suche) {
const zeilen = $$("tr[data-suchtext]", table);
const filtern = () => {
const begriff = suche.value.trim().toLowerCase();
let sichtbar = 0;
zeilen.forEach((row) => {
const passt = !begriff || (row.dataset.suchtext || "").includes(begriff);
// "hidden" und nicht aus dem Formular nehmen: ein Filter, der
// Kaestchen entfernt, loescht beim Speichern Rechte.
row.hidden = !passt;
if (passt) sichtbar += 1;
});
if (zaehler) {
zaehler.textContent = begriff
? `${sichtbar} von ${zeilen.length} Zeilen`
: `${zeilen.length} Zeilen`;
}
};
suche.addEventListener("input", filtern);
filtern();
}
}); });
} }
@@ -497,14 +575,14 @@ function initAutoRefresh() {
nameField.value = entry.name || ""; nameField.value = entry.name || "";
dnField.value = entry.dn || ""; dnField.value = entry.dn || "";
results.hidden = true; results.hidden = true;
show(`Uebernommen: ${entry.name || entry.dn}`); show(`Übernommen: ${entry.name || entry.dn}`);
}; };
const search = async () => { const search = async () => {
const term = query.value.trim(); const term = query.value.trim();
if (term.length < 2) { show("Bitte mindestens zwei Zeichen eingeben."); return; } if (term.length < 2) { show("Bitte mindestens zwei Zeichen eingeben."); return; }
button.disabled = true; button.disabled = true;
show("Suche laeuft ..."); show("Suche läuft ...");
results.innerHTML = ""; results.innerHTML = "";
try { try {
const url = `${picker.dataset.searchUrl}?q=${encodeURIComponent(term)}`; const url = `${picker.dataset.searchUrl}?q=${encodeURIComponent(term)}`;
@@ -532,10 +610,10 @@ function initAutoRefresh() {
results.appendChild(row); results.appendChild(row);
}); });
results.hidden = false; results.hidden = false;
show(`${entries.length} Treffer -- zum Uebernehmen anklicken.`); show(`${entries.length} Treffer – zum Übernehmen anklicken.`);
} catch (error) { } catch (error) {
results.hidden = true; results.hidden = true;
show("Die Suche ist fehlgeschlagen. Laeuft die Verbindung zum Verzeichnis?"); show("Die Suche ist fehlgeschlagen. Läuft die Verbindung zum Verzeichnis?");
} finally { } finally {
button.disabled = false; button.disabled = false;
} }
@@ -720,7 +798,7 @@ function initClipboard() {
timeout: 2500, timeout: 2500,
}); });
} catch { } catch {
showToast("Kopieren nicht moeglich -- bitte manuell markieren.", "warning"); showToast("Kopieren nicht möglich – bitte manuell markieren.", "warning");
} }
}); });
} }
@@ -856,6 +934,81 @@ function initTileFilters() {
}); });
} }
/* -- Reihenfolge aendern -------------------------------------------------- */
/**
* Eine Liste, deren Reihenfolge man aendern kann -- ohne Bibliothek.
*
* Die Reihenfolge steckt in der Reihenfolge der versteckten Felder: das
* Formular schickt sie als wiederholtes Feld, und der Server liest sie als
* Liste. Verschiebt man einen Eintrag, verschiebt sich das Feld mit ihm.
*
* Zwei Wege, weil es zwei Arten von Geraeten gibt: Ziehen mit der Maus und
* zwei Knoepfe je Zeile. Die Knoepfe sind nicht die Notloesung -- auf einem
* Tablet sind sie der einzige Weg, der zuverlaessig funktioniert, und mit der
* Tastatur ebenso.
*/
function initReorder() {
$$('[data-behavior="reorder"]').forEach((liste) => {
// Nur die eigenen Zeilen: Gruppen enthalten wiederum eine sortierbare
// Liste, und deren Zeilen gehoeren der inneren Liste, nicht dieser.
const zeilen = () => Array.from(liste.querySelectorAll(":scope > [data-reorder-item]"));
const nummerieren = () => {
zeilen().forEach((zeile, index) => {
const nummer = zeile.querySelector("[data-reorder-position-text]");
if (nummer) nummer.textContent = String(index + 1);
const hoch = zeile.querySelector('[data-reorder-move="up"]');
const runter = zeile.querySelector('[data-reorder-move="down"]');
if (hoch) hoch.disabled = index === 0;
if (runter) runter.disabled = index === zeilen().length - 1;
});
};
liste.addEventListener("click", (event) => {
const knopf = event.target.closest("[data-reorder-move]");
if (!knopf) return;
const zeile = knopf.closest("[data-reorder-item]");
if (!zeile || zeile.parentElement !== liste) return;
event.preventDefault();
if (knopf.dataset.reorderMove === "up") {
const davor = zeile.previousElementSibling;
if (davor) liste.insertBefore(zeile, davor);
} else {
const danach = zeile.nextElementSibling;
if (danach) liste.insertBefore(danach, zeile);
}
nummerieren();
zeile.querySelector(`[data-reorder-move="${knopf.dataset.reorderMove}"]`)?.focus();
});
let gezogen = null;
liste.addEventListener("dragstart", (event) => {
const zeile = event.target.closest("[data-reorder-item]");
if (!zeile || zeile.parentElement !== liste) return;
gezogen = zeile;
gezogen.classList.add("is-dragging");
if (event.dataTransfer) event.dataTransfer.effectAllowed = "move";
});
liste.addEventListener("dragend", () => {
if (gezogen) gezogen.classList.remove("is-dragging");
gezogen = null;
nummerieren();
});
liste.addEventListener("dragover", (event) => {
if (!gezogen) return;
event.preventDefault();
const ziel = event.target.closest("[data-reorder-item]");
if (!ziel || ziel === gezogen || ziel.parentElement !== liste) return;
const kasten = ziel.getBoundingClientRect();
const obereHaelfte = event.clientY < kasten.top + kasten.height / 2;
liste.insertBefore(gezogen, obereHaelfte ? ziel : ziel.nextElementSibling);
});
nummerieren();
});
}
/* -- Start ---------------------------------------------------------------- */ /* -- Start ---------------------------------------------------------------- */
/** /**
@@ -893,8 +1046,23 @@ function boot() {
initPalette(); initPalette();
initSubmitGuards(); initSubmitGuards();
initTileFilters(); initTileFilters();
initReorder();
initLogTails(); initLogTails();
unsavedGuard.init(); unsavedGuard.init();
initViewer();
}
/* Der Dateibetrachter kommt nur auf Seiten mit Vorschau -- und er zieht bei
einem PDF noch pdf.js nach. Beides erst dann, wenn es gebraucht wird. */
function initViewer() {
if (!document.querySelector("[data-viewer]")) return;
// Dieselbe Version wie diese Datei: app.js wird als ".../app.js?v=<Zeit>"
// eingebunden, und nginx liefert /core-assets/ mit "expires 7d". Ohne das
// Anhaengsel bliebe der alte Betrachter nach einem Update im Cache liegen.
const version = new URL(import.meta.url).search;
import(`./viewer.js${version}`)
.then((modul) => modul.initFilePreview())
.catch((fehler) => console.error("Dateibetrachter", fehler));
} }
if (document.readyState === "loading") { if (document.readyState === "loading") {
@@ -0,0 +1,468 @@
// Bildschirmsitzung im Browser: noVNC an die WebSocket-Strecke von TESM.
//
// Der Client handelt das RFB-Protokoll unmittelbar mit dem Geraet aus; TESM
// reicht nur Bytes durch. Alles, was diese Datei tut, ist: verbinden, den
// Zustand in Worte fassen und nach Zugangsdaten fragen, wenn keine hinterlegt
// sind.
//
// Die Meldungen sind der eigentliche Wert. Eine Sitzung, die schwarz bleibt,
// laesst offen, ob es an der Berechtigung, am Netz, am Server oder am
// Passwort liegt -- und wer das nicht sieht, sucht den Fehler in TESM.
//
// **Ein Bauteil, zwei Orte.** Dieselbe Sitzung laeuft auf der Bildschirmseite
// und im Fenster der Statusuebersicht. Sie holt alles, was sie braucht, aus
// einer JSON-Auskunft des Servers -- damit gibt es keine zweite Fassung, die
// man beim naechsten Fund vergisst nachzuziehen.
import RFB from "../vendor/novnc/core/rfb.js";
// Drei Eingriffe in private Methoden der mitgelieferten Bibliothek. Bewusst
// hier und nicht in der Bibliothek selbst: eine neue Fassung von noVNC laesst
// sich dann austauschen, ohne eine Aenderung nachzupflegen -- faellt eine
// Methode weg, entfaellt nur die Auskunft, nicht die Sitzung.
//
// Zwei davon holen nur Auskunft (_fail, _socketClose). Der dritte greift in
// die Wahl des Sicherheitsverfahrens ein -- aber erst, **nachdem** eines
// nachweislich nicht getragen hat, oder weil eines schon einmal getragen hat.
//
// Die Vorgeschichte gehoert dazu, damit niemand den Schritt zurueckgeht: hier
// stand einmal eine Vorauswahl, die ohne hinterlegte Zugangsdaten ein
// Verfahren ohne Anmeldung erzwang, weil RealVNC RA2ne zuerst nennt. Das war
// geraten und falsch. Was jetzt hier steht, ist das Gegenteil einer Vermutung:
// ein Befund aus einer vorherigen Sitzung.
const istUnterstuetzt = RFB.prototype._isSupportedSecurityType;
//: Den *Grund* eines Fehlschlags kennt nur ``_fail`` -- das Ereignis
//: "disconnect" traegt ihn nicht, und der Weg ueber die Protokollausgabe
//: funktioniert nicht: die Bibliothek bindet ``console.error`` beim Laden,
//: also bevor eine Seite etwas daran aendern koennte.
let letzterGrund = "";
const originalFail = RFB.prototype._fail;
RFB.prototype._fail = function (details) {
letzterGrund = String(details);
return originalFail.call(this, details);
};
//: Und der Abschluss der Leitung selbst: der Schliesscode sagt, ob TESM
//: abgewiesen hat (1008), ob es ein Serverfehler war (1011) -- oder ob die
//: Verbindung nie zustande kam (1006).
let letzterCode = 0;
const originalSocketClose = RFB.prototype._socketClose;
RFB.prototype._socketClose = function (ereignis) {
letzterCode = (ereignis && ereignis.code) || 0;
if (ereignis && ereignis.reason) {
letzterGrund = letzterGrund || String(ereignis.reason);
}
return originalSocketClose.call(this, ereignis);
};
//: Hoechstens ein zweiter Anlauf. Jeder Versuch ist eine Verbindung, und ein
//: RealVNC Server zaehlt abgebrochene Versuche mit -- wer dreimal anklopft,
//: sperrt sich aus.
const HOECHSTENS = 2;
//: So lange darf "wird aufgebaut" stehen, bevor die Seite selbst nachsieht.
//: Kommt die WebSocket-Verbindung gar nicht erst zustande, wartet noVNC ohne
//: jedes Ereignis, und die Seite stuende stumm bis zum Seitenwechsel.
const AUFBAU_GRENZE = 12000;
//: Verfahren, die im Browser keinen Schluesseltausch kosten.
//:
//: Der Grund steht in der Bibliothek, `core/ra2.js`: fuer RA2ne erzeugt der
//: **Browser** bei jeder Sitzung ein eigenes 2048-Bit-RSA-Schluesselpaar, in
//: JavaScript. Die Primzahlsuche dafuer dauert ein bis drei Minuten -- genau
//: die Zeit, die zwischen Klick und Bild lag. "Ohne Authentifizierung" (1)
//: kostet davon nichts.
//:
//: Deshalb kommt es zuerst, **wenn der Server es anbietet** und wir nicht
//: ohnehin schon wissen, was hier traegt. Das ist nicht dieselbe Vorauswahl
//: wie in 2.8.14: die war geraten, diese steht auf einer Messung. Traegt sie
//: nicht, nimmt der zweite Anlauf die Reihenfolge des Servers -- dann dauert
//: es eben.
const OHNE_SCHLUESSELTAUSCH = new Set([1]);
//: RA2ne. Wird es ausgehandelt, ist die Wartezeit erklaerbar und kein Fehler.
const RA2NE = 6;
//: Tastenkombinationen, die der Arbeitsplatz selbst abfaengt und die deshalb
//: einen Knopf brauchen. X11-Keysyms.
const ALT_LINKS = 0xffe9;
const F4 = 0xffc1;
const F5 = 0xffc2;
const schema = window.location.protocol === "https:" ? "wss:" : "ws:";
/**
* Baut eine Sitzung in diesem Behaelter auf.
*
* ``quelle`` ist die Adresse der JSON-Auskunft; alles Weitere steht dort.
* Rueckgabe: eine Funktion, die die Sitzung wieder abbaut.
*/
export function starteSitzung(wurzel, quelle, { beiEnde = null } = {}) {
const teil = (rolle) => wurzel.querySelector(`[data-rolle="${rolle}"]`);
const leinwand = teil("leinwand");
const zeile = teil("status");
const abfrage = teil("zugangsdaten");
const feldBenutzer = teil("benutzer");
const feldPasswort = teil("passwort");
const knopfSenden = teil("anmelden");
const knopfTrennen = teil("trennen");
const knoepfeSitzung = [...wurzel.querySelectorAll("[data-rolle^='taste-']"), knopfTrennen];
const gescheitert = new Set();
let rfb = null;
let aufbauUhr = 0;
let versuche = 0;
let stand = false;
let daten = null;
let letzterZugang = {};
let beendet = false;
function melden(text, art = "") {
if (!zeile) return;
zeile.textContent = text;
zeile.className = `screen__status${art ? " screen__status--" + art : ""}`;
}
function sitzungsknoepfe(sichtbar) {
knoepfeSitzung.forEach((knopf) => {
if (knopf) knopf.hidden = !sichtbar;
});
}
function grund() {
const teile = [];
if (letzterGrund) {
teile.push(letzterGrund.replace(/^Failed (when|while) \w+: /, ""));
}
if (rfb && rfb._rfbAuthScheme > 0) {
teile.push(`gewähltes Verfahren: ${rfb._rfbAuthScheme}`);
}
if (gescheitert.size) {
teile.push(`erfolglos: ${[...gescheitert].join(", ")}`);
}
return teile.join(" · ");
}
// Steht die WebSocket-Strecke zu TESM überhaupt? Zwei sehr verschiedene
// Ursachen sehen von aussen gleich aus: der VNC-Server mag nicht, oder die
// Verbindung kommt nie zustande, weil ein Proxy zwischen Browser und TESM
// "Upgrade" nicht durchreicht. Diese Probe trennt sie.
function streckeTesten() {
return new Promise((fertig) => {
let beantwortet = false;
const pruefling = new WebSocket(`${schema}//${window.location.host}/ws/probe`);
const schliessen = (ergebnis) => {
if (beantwortet) return;
beantwortet = true;
try {
pruefling.close();
} catch (fehler) {
/* schon zu */
}
fertig(ergebnis);
};
pruefling.onmessage = () => schliessen(true);
pruefling.onerror = () => schliessen(false);
pruefling.onclose = () => schliessen(false);
window.setTimeout(() => schliessen(false), 4000);
});
}
// Hat das gewählte Verfahren die Verbindung abreissen lassen? Dann ist das
// ein Befund und keine Vermutung -- der naechste Anlauf laesst es aus. Nur
// solange nie ein Bild stand: bricht eine **laufende** Sitzung ab, liegt es
// nicht am Anmeldeverfahren.
function naechsterVersuch() {
const verfahren = rfb && rfb._rfbAuthScheme > 0 ? rfb._rfbAuthScheme : 0;
if (!verfahren || versuche >= HOECHSTENS || gescheitert.has(verfahren)) {
return false;
}
gescheitert.add(verfahren);
melden(
`Verfahren ${verfahren} hat die Verbindung nicht getragen – nächster Anlauf ` +
"mit dem nächsten, das der Server anbietet …",
"warnung",
);
const zugang = letzterZugang;
rfb = null;
window.setTimeout(() => verbinden(zugang), 400);
return true;
}
function verfahrenMelden(gewaehlt) {
if (!gewaehlt || gewaehlt === daten.bewaehrt || !daten.meldeweg) return;
fetch(daten.meldeweg, {
method: "POST",
credentials: "same-origin",
headers: {
"Content-Type": "application/json",
"X-Requested-With": "fetch",
"X-CSRF-Token": document.body?.dataset.csrf || "",
},
body: JSON.stringify({ verfahren: gewaehlt }),
}).catch(() => {
/* Eine Merkhilfe, kein Teil der Sitzung. */
});
}
function verbinden(zugang) {
if (beendet) return;
melden(versuche ? "Nächster Versuch …" : "Verbindung wird aufgebaut …");
letzterGrund = "";
letzterCode = 0;
letzterZugang = zugang;
versuche += 1;
window.clearTimeout(aufbauUhr);
aufbauUhr = window.setTimeout(() => {
// Ein ausgehandeltes RA2ne ist kein Fehler, sondern Rechenzeit: der
// Browser erzeugt dafuer gerade einen 2048-Bit-Schluessel. Das zu
// melden ist das Gegenteil einer Warnung -- es nimmt die Sorge.
if (rfb && rfb._rfbAuthScheme === RA2NE) {
melden(
"Der Server verlangt RA2ne (RSA-AES). Dafür erzeugt der Browser ein " +
"2048-Bit-Schlüsselpaar – das dauert ein bis drei Minuten. Bitte warten.",
"warnung",
);
return;
}
melden("Es kommt keine Antwort. Wird geprüft, woran es liegt …", "warnung");
streckeTesten().then((steht) => {
melden(
steht
? "Die WebSocket-Strecke zu TESM steht, aber die Sitzung kommt nicht " +
"zustande. Antwortet der VNC-Server auf dem Gerät noch? Ein Neustart " +
"des VNC-Dienstes räumt eine Sperre ab."
: "Die WebSocket-Verbindung zu TESM kommt nicht zustande – auch eine " +
"einfache Probe scheitert. Ein Proxy zwischen Browser und TESM muss " +
"die Kopfzeilen Upgrade und Connection durchreichen (nginx: " +
"location /ws/ mit proxy_set_header Upgrade $http_upgrade).",
"fehler",
);
});
}, AUFBAU_GRENZE);
// Drei Regeln, alle aus Messung und keine aus Vermutung:
//
// 1. Was schon einmal getragen hat, kommt zuerst.
// 2. Sonst das Verfahren ohne Schluesseltausch -- aber nur, wenn der
// Server es nachweislich anbietet (sonst faende noVNC gar keines und
// scheiterte sofort). RA2ne kostet sonst ein bis drei Minuten
// Rechenzeit im Browser, jedes Mal.
// 3. Was abgerissen ist, wird nicht noch einmal gewaehlt.
//
// Beides gilt nur fuer den **ersten** Anlauf; danach zaehlt die
// Reihenfolge des Servers, abzueglich dessen, was schon abgerissen ist.
const bewaehrt = daten.bewaehrt || 0;
const angeboten = daten.verfahren || [];
const schnell = angeboten.filter((typ) => OHNE_SCHLUESSELTAUSCH.has(typ));
let bevorzugt = 0;
if (versuche === 1) {
if (bewaehrt > 0 && !gescheitert.has(bewaehrt)) bevorzugt = bewaehrt;
else if (schnell.length && !gescheitert.has(schnell[0])) bevorzugt = schnell[0];
}
RFB.prototype._isSupportedSecurityType = function (typ) {
if (gescheitert.has(typ)) return false;
if (bevorzugt) return typ === bevorzugt;
return istUnterstuetzt.call(this, typ);
};
rfb = new RFB(leinwand, `${schema}//${window.location.host}${daten.socket}`, {
credentials: zugang,
// Eine Bildschirmsitzung soll nicht nebenbei die Zwischenablage des
// Arbeitsplatzes mit dem Geraet teilen -- das ist eine eigene
// Entscheidung, und niemand hat sie hier getroffen.
clipboardTarget: null,
});
rfb.scaleViewport = true;
rfb.resizeSession = false;
rfb.focusOnClick = true;
rfb.addEventListener("connect", () => {
window.clearTimeout(aufbauUhr);
stand = true;
verfahrenMelden(rfb && rfb._rfbAuthScheme > 0 ? rfb._rfbAuthScheme : 0);
melden(`Verbunden mit ${daten.ziel}`, "ok");
if (abfrage) abfrage.hidden = true;
leinwand.hidden = false;
sitzungsknoepfe(true);
});
rfb.addEventListener("disconnect", (ereignis) => {
window.clearTimeout(aufbauUhr);
leinwand.hidden = true;
sitzungsknoepfe(false);
if (ereignis.detail && ereignis.detail.clean) {
// Beim sauberen Schluss steht der Grund im Schliessrahmen -- etwa,
// dass TESM wegen Untätigkeit getrennt hat.
melden(letzterGrund ? `Sitzung beendet – ${letzterGrund}` : "Sitzung beendet.");
// Im Fenster gibt es nach dem Trennen nichts mehr zu sehen: ein leerer
// Rahmen, den man wegklicken muss. Bei einem **Abbruch** bleibt es
// stehen -- dort steht der Grund, und der ist der ganze Zweck.
beiEnde?.();
} else if (!stand && naechsterVersuch()) {
return;
} else {
const erklaerung = grund();
melden(
erklaerung
? `Verbindung abgebrochen – ${erklaerung}`
: "Verbindung abgebrochen. Wird geprüft, woran es liegt …",
"fehler",
);
if (letzterCode === 1006 || !erklaerung) {
streckeTesten().then((steht) => {
melden(
steht
? `${erklaerung || "Verbindung abgebrochen"} – die WebSocket-Strecke zu ` +
"TESM selbst steht (Probe erfolgreich), der Abbruch kommt also von " +
"der VNC-Seite."
: "Die WebSocket-Verbindung zu TESM kommt nicht zustande – auch eine " +
"einfache Probe scheitert. Ein Proxy zwischen Browser und TESM muss " +
"die Kopfzeilen Upgrade und Connection durchreichen (nginx: " +
"location /ws/ mit proxy_set_header Upgrade $http_upgrade). Dasselbe " +
"gilt für die Browser-Konsole.",
"fehler",
);
});
}
}
rfb = null;
});
rfb.addEventListener("credentialsrequired", (ereignis) => {
window.clearTimeout(aufbauUhr);
const gefragt = ereignis.detail.types || ["password"];
if (abfrage) abfrage.hidden = false;
if (feldBenutzer) feldBenutzer.closest("label").hidden = !gefragt.includes("username");
melden("Der VNC-Server verlangt Zugangsdaten.", "warnung");
(gefragt.includes("username") ? feldBenutzer : feldPasswort)?.focus();
});
rfb.addEventListener("securityfailure", (ereignis) => {
window.clearTimeout(aufbauUhr);
melden(
`Anmeldung abgelehnt: ${ereignis.detail.reason || "kein Grund genannt"}`,
"fehler",
);
if (abfrage) abfrage.hidden = false;
});
}
knopfSenden?.addEventListener("click", () => {
const zugang = {};
if (feldBenutzer?.value) zugang.username = feldBenutzer.value;
if (feldPasswort?.value) zugang.password = feldPasswort.value;
if (rfb) rfb.sendCredentials(zugang);
else verbinden(zugang);
});
[feldBenutzer, feldPasswort].forEach((feld) =>
feld?.addEventListener("keydown", (ereignis) => {
if (ereignis.key === "Enter") {
ereignis.preventDefault();
knopfSenden?.click();
}
}),
);
knopfTrennen?.addEventListener("click", () => rfb && rfb.disconnect());
teil("taste-cad")?.addEventListener("click", () => rfb && rfb.sendCtrlAltDel());
// Alt+F4 kennt noVNC nicht als fertigen Griff: der Arbeitsplatz faengt die
// Kombination selbst ab, bevor sie in der Leinwand ankommt. Also einzeln --
// Alt halten, F4 tippen, Alt loslassen -- wie es auf der Tastatur auch
// geschieht.
teil("taste-altf4")?.addEventListener("click", () => {
if (!rfb) return;
rfb.sendKey(ALT_LINKS, "AltLeft", true);
rfb.sendKey(F4, "F4", true);
rfb.sendKey(F4, "F4", false);
rfb.sendKey(ALT_LINKS, "AltLeft", false);
});
// F5 faengt der Browser des Arbeitsplatzes ab -- er wuerde diese Seite neu
// laden statt das Bild auf dem Geraet.
teil("taste-f5")?.addEventListener("click", () => {
if (!rfb) return;
rfb.sendKey(F5, "F5", true);
rfb.sendKey(F5, "F5", false);
});
sitzungsknoepfe(false);
melden("Wird vorbereitet …");
fetch(quelle, { credentials: "same-origin", headers: { "X-Requested-With": "fetch" } })
.then((antwort) => antwort.json().then((inhalt) => ({ status: antwort.status, inhalt })))
.then(({ status, inhalt }) => {
if (beendet) return;
if (status === 409 && inhalt.reauth) {
melden(
"Für den Bildschirm ist eine frische Passwortbestätigung nötig – bitte über " +
"die Geräteseite öffnen.",
"warnung",
);
return;
}
if (status !== 200) {
melden(inhalt.fehler || "Diese Sitzung lässt sich nicht öffnen.", "fehler");
return;
}
daten = inhalt;
if (daten.hinderungsgrund) {
melden(daten.hinderungsgrund, "fehler");
return;
}
const zugang = {};
if (daten.vorgaben?.username) zugang.username = daten.vorgaben.username;
if (daten.vorgaben?.password) zugang.password = daten.vorgaben.password;
verbinden(zugang);
})
.catch(() => melden("Die Sitzungsdaten sind nicht abrufbar.", "fehler"));
return function beenden() {
beendet = true;
window.clearTimeout(aufbauUhr);
try {
rfb?.disconnect();
} catch (fehler) {
/* schon zu */
}
rfb = null;
};
}
// -- Die beiden Orte -------------------------------------------------------- #
// 1. Die Bildschirmseite: ein Behaelter, der seine Quelle selbst mitbringt.
const seite = document.getElementById("screen");
if (seite?.dataset.sitzung) {
starteSitzung(seite, seite.dataset.sitzung);
}
// 2. Die Statusuebersicht: ein Fenster, das jede Kachel oeffnen kann. Ein
// Dialog fuer alle -- er bekommt beim Oeffnen, welches Geraet gemeint ist.
const fenster = document.getElementById("vnc-fenster");
if (fenster) {
let beenden = null;
const behaelter = fenster.querySelector("[data-rolle='behaelter']");
const titel = fenster.querySelector("[data-rolle='titel']");
document.addEventListener("click", (ereignis) => {
const knopf = ereignis.target.closest("[data-vnc-oeffnen]");
if (!knopf) return;
ereignis.preventDefault();
beenden?.();
titel.textContent = knopf.dataset.vncName || "Bildschirm";
if (typeof fenster.showModal === "function") fenster.showModal();
else fenster.setAttribute("open", "");
beenden = starteSitzung(behaelter, knopf.dataset.vncOeffnen, {
beiEnde: () => fenster.close(),
});
});
// Zu heisst zu: eine Sitzung, die im Hintergrund weiterlaeuft, haelt die
// Verbindung zum Geraet und bei RealVNC den einen Sitzungsplatz.
fenster.addEventListener("close", () => {
beenden?.();
beenden = null;
});
}
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,13 @@
maintainers:
- Samuel Mannehed for Cendio AB (@samhed)
- Pierre Ossman for Cendio AB (@CendioOssman)
maintainersEmeritus:
- Joel Martin (@kanaka)
- Solly Ross (@directxman12)
- @astrand
contributors:
# There are a bunch of people that should be here.
# If you want to be on this list, feel free send a PR
# to add yourself.
- jalf <git@jalf.dk>
- NTT corp.
@@ -0,0 +1,52 @@
# noVNC 1.6.0 -- mitgeliefert, nicht nachgeladen
Quelle: <https://github.com/novnc/noVNC>, Fassung **1.6.0**, uebernommen am
13.09.2026. Enthalten sind `core/` (die Bibliothek) und `vendor/pako` (die
Entpackroutine, die `core/inflator.js` braucht) -- ohne `app/`, `tests/` und
die mitgelieferte Oberflaeche: die Bedienung stellt TESM selbst
(`apps/tesm/src/tesm/templates/tesm/screen.html`).
**Lizenz:** MPL-2.0 fuer die Kernbibliothek, siehe `LICENSE.txt` und `AUTHORS`
(beide unveraendert uebernommen). pako steht unter MIT, siehe
`vendor/pako/LICENSE`.
**Warum mitgeliefert und nicht von einem CDN:** dieselbe Regel wie bei pdf.js
und pdf-lib nebenan. Die Anwendung laeuft in Netzen ohne Internetzugang, die
Inhaltsrichtlinie (CSP) erlaubt ausschliesslich `'self'`, und eine
Bibliothek, die sich zur Laufzeit aendern kann, gehoert nicht in einen Pfad,
der einen Bildschirm fernsteuert.
**Unveraendert.** Wird eine Anpassung noetig, gehoert sie hierher in diese
Datei -- mit Grund und Fundstelle, damit ein Wechsel auf eine neuere Fassung
nicht stillschweigend darueberlaeuft.
**Was TESM von aussen umbiegt** (also *nicht* in diesen Dateien steht, sondern
in `js/screen.js`):
* `RFB.prototype._fail` und `_socketClose` -- nur, um den Grund eines
Abbruchs zu erfahren. noVNC meldet ihn sonst ausschliesslich in die
Entwicklerkonsole, und `console.error` mitzulesen traegt nicht: die
Bibliothek bindet es beim Laden.
* `RFB.prototype._isSupportedSecurityType` -- **erst nachdem** ein Verfahren
die Verbindung nachweislich hat abreissen lassen. Dann wird es fuer den
zweiten Anlauf ausgelassen, damit noVNC nicht wieder dasselbe zuerst findet.
Hoechstens zwei Anlaeufe, denn jeder ist eine Verbindung, und ein RealVNC
Server zaehlt abgebrochene mit.
Hier stand einmal das Gegenteil: eine **Vorauswahl**, die ohne hinterlegte
Zugangsdaten ein Verfahren ohne Anmeldung erzwang. Das war geraten und hat
eine laufende Sitzung gebrochen. Der Unterschied ist nicht der Code, sondern
der Anlass: eine Vermutung vorher gegen einen Befund hinterher.
Faellt eine dieser Methoden in einer neueren Fassung weg, entfaellt nur die
Auskunft bzw. der zweite Anlauf -- die Sitzung laeuft wie ohne sie.
**Was noVNC an Sicherheitsverfahren spricht** (`core/rfb.js`,
`_isSupportedSecurityType`): `1` None, `2` VNC-Passwort, `6` RA2ne (RSA-AES
ohne Streckenverschluesselung), `16` Tight, `19` VeNCrypt, `22` xvp, `30` ARD,
`113` MS-Logon II. **Nicht** dabei: `5` (RA2 mit Streckenverschluesselung) und
alles aus dem RealVNC-eigenen Bereich `7`-`15`. Ein RealVNC Server, der nur
`13` und `5` anbietet, kommt mit dieser Bibliothek also nicht zusammen -- er
muss `Encryption=AlwaysOff` (dann `6`) oder `Authentication=VncAuth` (dann
`2`) angeboten bekommen. TESM misst das und schreibt es auf die Seite, statt
den Benutzer vor eine schwarze Flaeche zu setzen.
@@ -0,0 +1,62 @@
noVNC is Copyright (C) 2022 The noVNC authors
(./AUTHORS)
The noVNC core library files are licensed under the MPL 2.0 (Mozilla
Public License 2.0). The noVNC core library is composed of the
Javascript code necessary for full noVNC operation. This includes (but
is not limited to):
core/**/*.js
app/*.js
test/playback.js
The HTML, CSS, font and images files that included with the noVNC
source distibution (or repository) are not considered part of the
noVNC core library and are licensed under more permissive licenses.
The intent is to allow easy integration of noVNC into existing web
sites and web applications.
The HTML, CSS, font and image files are licensed as follows:
*.html : 2-Clause BSD license
app/styles/*.css : 2-Clause BSD license
app/styles/Orbitron* : SIL Open Font License 1.1
(Copyright 2009 Matt McInerney)
app/images/ : Creative Commons Attribution-ShareAlike
http://creativecommons.org/licenses/by-sa/3.0/
Some portions of noVNC are copyright to their individual authors.
Please refer to the individual source files and/or to the noVNC commit
history: https://github.com/novnc/noVNC/commits/master
The are several files and projects that have been incorporated into
the noVNC core library. Here is a list of those files and the original
licenses (all MPL 2.0 compatible):
core/base64.js : MPL 2.0
core/des.js : Various BSD style licenses
vendor/pako/ : MIT
Any other files not mentioned above are typically marked with
a copyright/license header at the top of the file. The default noVNC
license is MPL-2.0.
The following license texts are included:
docs/LICENSE.MPL-2.0
docs/LICENSE.OFL-1.1
docs/LICENSE.BSD-3-Clause (New BSD)
docs/LICENSE.BSD-2-Clause (Simplified BSD / FreeBSD)
vendor/pako/LICENSE (MIT)
Or alternatively the license texts may be found here:
http://www.mozilla.org/MPL/2.0/
http://scripts.sil.org/OFL
http://en.wikipedia.org/wiki/BSD_licenses
https://opensource.org/licenses/MIT
@@ -0,0 +1,104 @@
/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
// From: http://hg.mozilla.org/mozilla-central/raw-file/ec10630b1a54/js/src/devtools/jint/sunspider/string-base64.js
import * as Log from './util/logging.js';
export default {
/* Convert data (an array of integers) to a Base64 string. */
toBase64Table: 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/='.split(''),
base64Pad: '=',
encode(data) {
"use strict";
let result = '';
const length = data.length;
const lengthpad = (length % 3);
// Convert every three bytes to 4 ascii characters.
for (let i = 0; i < (length - 2); i += 3) {
result += this.toBase64Table[data[i] >> 2];
result += this.toBase64Table[((data[i] & 0x03) << 4) + (data[i + 1] >> 4)];
result += this.toBase64Table[((data[i + 1] & 0x0f) << 2) + (data[i + 2] >> 6)];
result += this.toBase64Table[data[i + 2] & 0x3f];
}
// Convert the remaining 1 or 2 bytes, pad out to 4 characters.
const j = length - lengthpad;
if (lengthpad === 2) {
result += this.toBase64Table[data[j] >> 2];
result += this.toBase64Table[((data[j] & 0x03) << 4) + (data[j + 1] >> 4)];
result += this.toBase64Table[(data[j + 1] & 0x0f) << 2];
result += this.toBase64Table[64];
} else if (lengthpad === 1) {
result += this.toBase64Table[data[j] >> 2];
result += this.toBase64Table[(data[j] & 0x03) << 4];
result += this.toBase64Table[64];
result += this.toBase64Table[64];
}
return result;
},
/* Convert Base64 data to a string */
/* eslint-disable comma-spacing */
toBinaryTable: [
-1,-1,-1,-1, -1,-1,-1,-1, -1,-1,-1,-1, -1,-1,-1,-1,
-1,-1,-1,-1, -1,-1,-1,-1, -1,-1,-1,-1, -1,-1,-1,-1,
-1,-1,-1,-1, -1,-1,-1,-1, -1,-1,-1,62, -1,-1,-1,63,
52,53,54,55, 56,57,58,59, 60,61,-1,-1, -1, 0,-1,-1,
-1, 0, 1, 2, 3, 4, 5, 6, 7, 8, 9,10, 11,12,13,14,
15,16,17,18, 19,20,21,22, 23,24,25,-1, -1,-1,-1,-1,
-1,26,27,28, 29,30,31,32, 33,34,35,36, 37,38,39,40,
41,42,43,44, 45,46,47,48, 49,50,51,-1, -1,-1,-1,-1
],
/* eslint-enable comma-spacing */
decode(data, offset = 0) {
let dataLength = data.indexOf('=') - offset;
if (dataLength < 0) { dataLength = data.length - offset; }
/* Every four characters is 3 resulting numbers */
const resultLength = (dataLength >> 2) * 3 + Math.floor((dataLength % 4) / 1.5);
const result = new Array(resultLength);
// Convert one by one.
let leftbits = 0; // number of bits decoded, but yet to be appended
let leftdata = 0; // bits decoded, but yet to be appended
for (let idx = 0, i = offset; i < data.length; i++) {
const c = this.toBinaryTable[data.charCodeAt(i) & 0x7f];
const padding = (data.charAt(i) === this.base64Pad);
// Skip illegal characters and whitespace
if (c === -1) {
Log.Error("Illegal character code " + data.charCodeAt(i) + " at position " + i);
continue;
}
// Collect data into leftdata, update bitcount
leftdata = (leftdata << 6) | c;
leftbits += 6;
// If we have 8 or more bits, append 8 bits to the result
if (leftbits >= 8) {
leftbits -= 8;
// Append if not padding.
if (!padding) {
result[idx++] = (leftdata >> leftbits) & 0xff;
}
leftdata &= (1 << leftbits) - 1;
}
}
// If there are any bits left, the base64 string was corrupted
if (leftbits) {
const err = new Error('Corrupted base64 string');
err.name = 'Base64-Error';
throw err;
}
return result;
}
}; /* End of Base64 namespace */
@@ -0,0 +1,178 @@
export class AESECBCipher {
constructor() {
this._key = null;
}
get algorithm() {
return { name: "AES-ECB" };
}
static async importKey(key, _algorithm, extractable, keyUsages) {
const cipher = new AESECBCipher;
await cipher._importKey(key, extractable, keyUsages);
return cipher;
}
async _importKey(key, extractable, keyUsages) {
this._key = await window.crypto.subtle.importKey(
"raw", key, {name: "AES-CBC"}, extractable, keyUsages);
}
async encrypt(_algorithm, plaintext) {
const x = new Uint8Array(plaintext);
if (x.length % 16 !== 0 || this._key === null) {
return null;
}
const n = x.length / 16;
for (let i = 0; i < n; i++) {
const y = new Uint8Array(await window.crypto.subtle.encrypt({
name: "AES-CBC",
iv: new Uint8Array(16),
}, this._key, x.slice(i * 16, i * 16 + 16))).slice(0, 16);
x.set(y, i * 16);
}
return x;
}
}
export class AESEAXCipher {
constructor() {
this._rawKey = null;
this._ctrKey = null;
this._cbcKey = null;
this._zeroBlock = new Uint8Array(16);
this._prefixBlock0 = this._zeroBlock;
this._prefixBlock1 = new Uint8Array([0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1]);
this._prefixBlock2 = new Uint8Array([0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 2]);
}
get algorithm() {
return { name: "AES-EAX" };
}
async _encryptBlock(block) {
const encrypted = await window.crypto.subtle.encrypt({
name: "AES-CBC",
iv: this._zeroBlock,
}, this._cbcKey, block);
return new Uint8Array(encrypted).slice(0, 16);
}
async _initCMAC() {
const k1 = await this._encryptBlock(this._zeroBlock);
const k2 = new Uint8Array(16);
const v = k1[0] >>> 6;
for (let i = 0; i < 15; i++) {
k2[i] = (k1[i + 1] >> 6) | (k1[i] << 2);
k1[i] = (k1[i + 1] >> 7) | (k1[i] << 1);
}
const lut = [0x0, 0x87, 0x0e, 0x89];
k2[14] ^= v >>> 1;
k2[15] = (k1[15] << 2) ^ lut[v];
k1[15] = (k1[15] << 1) ^ lut[v >> 1];
this._k1 = k1;
this._k2 = k2;
}
async _encryptCTR(data, counter) {
const encrypted = await window.crypto.subtle.encrypt({
name: "AES-CTR",
counter: counter,
length: 128
}, this._ctrKey, data);
return new Uint8Array(encrypted);
}
async _decryptCTR(data, counter) {
const decrypted = await window.crypto.subtle.decrypt({
name: "AES-CTR",
counter: counter,
length: 128
}, this._ctrKey, data);
return new Uint8Array(decrypted);
}
async _computeCMAC(data, prefixBlock) {
if (prefixBlock.length !== 16) {
return null;
}
const n = Math.floor(data.length / 16);
const m = Math.ceil(data.length / 16);
const r = data.length - n * 16;
const cbcData = new Uint8Array((m + 1) * 16);
cbcData.set(prefixBlock);
cbcData.set(data, 16);
if (r === 0) {
for (let i = 0; i < 16; i++) {
cbcData[n * 16 + i] ^= this._k1[i];
}
} else {
cbcData[(n + 1) * 16 + r] = 0x80;
for (let i = 0; i < 16; i++) {
cbcData[(n + 1) * 16 + i] ^= this._k2[i];
}
}
let cbcEncrypted = await window.crypto.subtle.encrypt({
name: "AES-CBC",
iv: this._zeroBlock,
}, this._cbcKey, cbcData);
cbcEncrypted = new Uint8Array(cbcEncrypted);
const mac = cbcEncrypted.slice(cbcEncrypted.length - 32, cbcEncrypted.length - 16);
return mac;
}
static async importKey(key, _algorithm, _extractable, _keyUsages) {
const cipher = new AESEAXCipher;
await cipher._importKey(key);
return cipher;
}
async _importKey(key) {
this._rawKey = key;
this._ctrKey = await window.crypto.subtle.importKey(
"raw", key, {name: "AES-CTR"}, false, ["encrypt", "decrypt"]);
this._cbcKey = await window.crypto.subtle.importKey(
"raw", key, {name: "AES-CBC"}, false, ["encrypt"]);
await this._initCMAC();
}
async encrypt(algorithm, message) {
const ad = algorithm.additionalData;
const nonce = algorithm.iv;
const nCMAC = await this._computeCMAC(nonce, this._prefixBlock0);
const encrypted = await this._encryptCTR(message, nCMAC);
const adCMAC = await this._computeCMAC(ad, this._prefixBlock1);
const mac = await this._computeCMAC(encrypted, this._prefixBlock2);
for (let i = 0; i < 16; i++) {
mac[i] ^= nCMAC[i] ^ adCMAC[i];
}
const res = new Uint8Array(16 + encrypted.length);
res.set(encrypted);
res.set(mac, encrypted.length);
return res;
}
async decrypt(algorithm, data) {
const encrypted = data.slice(0, data.length - 16);
const ad = algorithm.additionalData;
const nonce = algorithm.iv;
const mac = data.slice(data.length - 16);
const nCMAC = await this._computeCMAC(nonce, this._prefixBlock0);
const adCMAC = await this._computeCMAC(ad, this._prefixBlock1);
const computedMac = await this._computeCMAC(encrypted, this._prefixBlock2);
for (let i = 0; i < 16; i++) {
computedMac[i] ^= nCMAC[i] ^ adCMAC[i];
}
if (computedMac.length !== mac.length) {
return null;
}
for (let i = 0; i < mac.length; i++) {
if (computedMac[i] !== mac[i]) {
return null;
}
}
const res = await this._decryptCTR(encrypted, nCMAC);
return res;
}
}
@@ -0,0 +1,34 @@
export function modPow(b, e, m) {
let r = 1n;
b = b % m;
while (e > 0n) {
if ((e & 1n) === 1n) {
r = (r * b) % m;
}
e = e >> 1n;
b = (b * b) % m;
}
return r;
}
export function bigIntToU8Array(bigint, padLength=0) {
let hex = bigint.toString(16);
if (padLength === 0) {
padLength = Math.ceil(hex.length / 2);
}
hex = hex.padStart(padLength * 2, '0');
const length = hex.length / 2;
const arr = new Uint8Array(length);
for (let i = 0; i < length; i++) {
arr[i] = parseInt(hex.slice(i * 2, i * 2 + 2), 16);
}
return arr;
}
export function u8ArrayToBigInt(arr) {
let hex = '0x';
for (let i = 0; i < arr.length; i++) {
hex += arr[i].toString(16).padStart(2, '0');
}
return BigInt(hex);
}
@@ -0,0 +1,90 @@
import { AESECBCipher, AESEAXCipher } from "./aes.js";
import { DESCBCCipher, DESECBCipher } from "./des.js";
import { RSACipher } from "./rsa.js";
import { DHCipher } from "./dh.js";
import { MD5 } from "./md5.js";
// A single interface for the cryptographic algorithms not supported by SubtleCrypto.
// Both synchronous and asynchronous implmentations are allowed.
class LegacyCrypto {
constructor() {
this._algorithms = {
"AES-ECB": AESECBCipher,
"AES-EAX": AESEAXCipher,
"DES-ECB": DESECBCipher,
"DES-CBC": DESCBCCipher,
"RSA-PKCS1-v1_5": RSACipher,
"DH": DHCipher,
"MD5": MD5,
};
}
encrypt(algorithm, key, data) {
if (key.algorithm.name !== algorithm.name) {
throw new Error("algorithm does not match");
}
if (typeof key.encrypt !== "function") {
throw new Error("key does not support encryption");
}
return key.encrypt(algorithm, data);
}
decrypt(algorithm, key, data) {
if (key.algorithm.name !== algorithm.name) {
throw new Error("algorithm does not match");
}
if (typeof key.decrypt !== "function") {
throw new Error("key does not support encryption");
}
return key.decrypt(algorithm, data);
}
importKey(format, keyData, algorithm, extractable, keyUsages) {
if (format !== "raw") {
throw new Error("key format is not supported");
}
const alg = this._algorithms[algorithm.name];
if (typeof alg === "undefined" || typeof alg.importKey !== "function") {
throw new Error("algorithm is not supported");
}
return alg.importKey(keyData, algorithm, extractable, keyUsages);
}
generateKey(algorithm, extractable, keyUsages) {
const alg = this._algorithms[algorithm.name];
if (typeof alg === "undefined" || typeof alg.generateKey !== "function") {
throw new Error("algorithm is not supported");
}
return alg.generateKey(algorithm, extractable, keyUsages);
}
exportKey(format, key) {
if (format !== "raw") {
throw new Error("key format is not supported");
}
if (typeof key.exportKey !== "function") {
throw new Error("key does not support exportKey");
}
return key.exportKey();
}
digest(algorithm, data) {
const alg = this._algorithms[algorithm];
if (typeof alg !== "function") {
throw new Error("algorithm is not supported");
}
return alg(data);
}
deriveBits(algorithm, key, length) {
if (key.algorithm.name !== algorithm.name) {
throw new Error("algorithm does not match");
}
if (typeof key.deriveBits !== "function") {
throw new Error("key does not support deriveBits");
}
return key.deriveBits(algorithm, length);
}
}
export default new LegacyCrypto;
@@ -0,0 +1,330 @@
/*
* Ported from Flashlight VNC ActionScript implementation:
* http://www.wizhelp.com/flashlight-vnc/
*
* Full attribution follows:
*
* -------------------------------------------------------------------------
*
* This DES class has been extracted from package Acme.Crypto for use in VNC.
* The unnecessary odd parity code has been removed.
*
* These changes are:
* Copyright (C) 1999 AT&T Laboratories Cambridge. All Rights Reserved.
*
* This software is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
*
* DesCipher - the DES encryption method
*
* The meat of this code is by Dave Zimmerman <dzimm@widget.com>, and is:
*
* Copyright (c) 1996 Widget Workshop, Inc. All Rights Reserved.
*
* Permission to use, copy, modify, and distribute this software
* and its documentation for NON-COMMERCIAL or COMMERCIAL purposes and
* without fee is hereby granted, provided that this copyright notice is kept
* intact.
*
* WIDGET WORKSHOP MAKES NO REPRESENTATIONS OR WARRANTIES ABOUT THE SUITABILITY
* OF THE SOFTWARE, EITHER EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED
* TO THE IMPLIED WARRANTIES OF MERCHANTABILITY, FITNESS FOR A
* PARTICULAR PURPOSE, OR NON-INFRINGEMENT. WIDGET WORKSHOP SHALL NOT BE LIABLE
* FOR ANY DAMAGES SUFFERED BY LICENSEE AS A RESULT OF USING, MODIFYING OR
* DISTRIBUTING THIS SOFTWARE OR ITS DERIVATIVES.
*
* THIS SOFTWARE IS NOT DESIGNED OR INTENDED FOR USE OR RESALE AS ON-LINE
* CONTROL EQUIPMENT IN HAZARDOUS ENVIRONMENTS REQUIRING FAIL-SAFE
* PERFORMANCE, SUCH AS IN THE OPERATION OF NUCLEAR FACILITIES, AIRCRAFT
* NAVIGATION OR COMMUNICATION SYSTEMS, AIR TRAFFIC CONTROL, DIRECT LIFE
* SUPPORT MACHINES, OR WEAPONS SYSTEMS, IN WHICH THE FAILURE OF THE
* SOFTWARE COULD LEAD DIRECTLY TO DEATH, PERSONAL INJURY, OR SEVERE
* PHYSICAL OR ENVIRONMENTAL DAMAGE ("HIGH RISK ACTIVITIES"). WIDGET WORKSHOP
* SPECIFICALLY DISCLAIMS ANY EXPRESS OR IMPLIED WARRANTY OF FITNESS FOR
* HIGH RISK ACTIVITIES.
*
*
* The rest is:
*
* Copyright (C) 1996 by Jef Poskanzer <jef@acme.com>. All rights reserved.
*
* Redistribution and use in source and binary forms, with or without
* modification, are permitted provided that the following conditions
* are met:
* 1. Redistributions of source code must retain the above copyright
* notice, this list of conditions and the following disclaimer.
* 2. Redistributions in binary form must reproduce the above copyright
* notice, this list of conditions and the following disclaimer in the
* documentation and/or other materials provided with the distribution.
*
* THIS SOFTWARE IS PROVIDED BY THE AUTHOR AND CONTRIBUTORS ``AS IS'' AND
* ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
* IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
* ARE DISCLAIMED. IN NO EVENT SHALL THE AUTHOR OR CONTRIBUTORS BE LIABLE
* FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
* DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS
* OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION)
* HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT
* LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY
* OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF
* SUCH DAMAGE.
*
* Visit the ACME Labs Java page for up-to-date versions of this and other
* fine Java utilities: http://www.acme.com/java/
*/
/* eslint-disable comma-spacing */
// Tables, permutations, S-boxes, etc.
const PC2 = [13,16,10,23, 0, 4, 2,27,14, 5,20, 9,22,18,11, 3,
25, 7,15, 6,26,19,12, 1,40,51,30,36,46,54,29,39,
50,44,32,47,43,48,38,55,33,52,45,41,49,35,28,31 ],
totrot = [ 1, 2, 4, 6, 8,10,12,14,15,17,19,21,23,25,27,28];
const z = 0x0;
let a,b,c,d,e,f;
a=1<<16; b=1<<24; c=a|b; d=1<<2; e=1<<10; f=d|e;
const SP1 = [c|e,z|z,a|z,c|f,c|d,a|f,z|d,a|z,z|e,c|e,c|f,z|e,b|f,c|d,b|z,z|d,
z|f,b|e,b|e,a|e,a|e,c|z,c|z,b|f,a|d,b|d,b|d,a|d,z|z,z|f,a|f,b|z,
a|z,c|f,z|d,c|z,c|e,b|z,b|z,z|e,c|d,a|z,a|e,b|d,z|e,z|d,b|f,a|f,
c|f,a|d,c|z,b|f,b|d,z|f,a|f,c|e,z|f,b|e,b|e,z|z,a|d,a|e,z|z,c|d];
a=1<<20; b=1<<31; c=a|b; d=1<<5; e=1<<15; f=d|e;
const SP2 = [c|f,b|e,z|e,a|f,a|z,z|d,c|d,b|f,b|d,c|f,c|e,b|z,b|e,a|z,z|d,c|d,
a|e,a|d,b|f,z|z,b|z,z|e,a|f,c|z,a|d,b|d,z|z,a|e,z|f,c|e,c|z,z|f,
z|z,a|f,c|d,a|z,b|f,c|z,c|e,z|e,c|z,b|e,z|d,c|f,a|f,z|d,z|e,b|z,
z|f,c|e,a|z,b|d,a|d,b|f,b|d,a|d,a|e,z|z,b|e,z|f,b|z,c|d,c|f,a|e];
a=1<<17; b=1<<27; c=a|b; d=1<<3; e=1<<9; f=d|e;
const SP3 = [z|f,c|e,z|z,c|d,b|e,z|z,a|f,b|e,a|d,b|d,b|d,a|z,c|f,a|d,c|z,z|f,
b|z,z|d,c|e,z|e,a|e,c|z,c|d,a|f,b|f,a|e,a|z,b|f,z|d,c|f,z|e,b|z,
c|e,b|z,a|d,z|f,a|z,c|e,b|e,z|z,z|e,a|d,c|f,b|e,b|d,z|e,z|z,c|d,
b|f,a|z,b|z,c|f,z|d,a|f,a|e,b|d,c|z,b|f,z|f,c|z,a|f,z|d,c|d,a|e];
a=1<<13; b=1<<23; c=a|b; d=1<<0; e=1<<7; f=d|e;
const SP4 = [c|d,a|f,a|f,z|e,c|e,b|f,b|d,a|d,z|z,c|z,c|z,c|f,z|f,z|z,b|e,b|d,
z|d,a|z,b|z,c|d,z|e,b|z,a|d,a|e,b|f,z|d,a|e,b|e,a|z,c|e,c|f,z|f,
b|e,b|d,c|z,c|f,z|f,z|z,z|z,c|z,a|e,b|e,b|f,z|d,c|d,a|f,a|f,z|e,
c|f,z|f,z|d,a|z,b|d,a|d,c|e,b|f,a|d,a|e,b|z,c|d,z|e,b|z,a|z,c|e];
a=1<<25; b=1<<30; c=a|b; d=1<<8; e=1<<19; f=d|e;
const SP5 = [z|d,a|f,a|e,c|d,z|e,z|d,b|z,a|e,b|f,z|e,a|d,b|f,c|d,c|e,z|f,b|z,
a|z,b|e,b|e,z|z,b|d,c|f,c|f,a|d,c|e,b|d,z|z,c|z,a|f,a|z,c|z,z|f,
z|e,c|d,z|d,a|z,b|z,a|e,c|d,b|f,a|d,b|z,c|e,a|f,b|f,z|d,a|z,c|e,
c|f,z|f,c|z,c|f,a|e,z|z,b|e,c|z,z|f,a|d,b|d,z|e,z|z,b|e,a|f,b|d];
a=1<<22; b=1<<29; c=a|b; d=1<<4; e=1<<14; f=d|e;
const SP6 = [b|d,c|z,z|e,c|f,c|z,z|d,c|f,a|z,b|e,a|f,a|z,b|d,a|d,b|e,b|z,z|f,
z|z,a|d,b|f,z|e,a|e,b|f,z|d,c|d,c|d,z|z,a|f,c|e,z|f,a|e,c|e,b|z,
b|e,z|d,c|d,a|e,c|f,a|z,z|f,b|d,a|z,b|e,b|z,z|f,b|d,c|f,a|e,c|z,
a|f,c|e,z|z,c|d,z|d,z|e,c|z,a|f,z|e,a|d,b|f,z|z,c|e,b|z,a|d,b|f];
a=1<<21; b=1<<26; c=a|b; d=1<<1; e=1<<11; f=d|e;
const SP7 = [a|z,c|d,b|f,z|z,z|e,b|f,a|f,c|e,c|f,a|z,z|z,b|d,z|d,b|z,c|d,z|f,
b|e,a|f,a|d,b|e,b|d,c|z,c|e,a|d,c|z,z|e,z|f,c|f,a|e,z|d,b|z,a|e,
b|z,a|e,a|z,b|f,b|f,c|d,c|d,z|d,a|d,b|z,b|e,a|z,c|e,z|f,a|f,c|e,
z|f,b|d,c|f,c|z,a|e,z|z,z|d,c|f,z|z,a|f,c|z,z|e,b|d,b|e,z|e,a|d];
a=1<<18; b=1<<28; c=a|b; d=1<<6; e=1<<12; f=d|e;
const SP8 = [b|f,z|e,a|z,c|f,b|z,b|f,z|d,b|z,a|d,c|z,c|f,a|e,c|e,a|f,z|e,z|d,
c|z,b|d,b|e,z|f,a|e,a|d,c|d,c|e,z|f,z|z,z|z,c|d,b|d,b|e,a|f,a|z,
a|f,a|z,c|e,z|e,z|d,c|d,z|e,a|f,b|e,z|d,b|d,c|z,c|d,b|z,a|z,b|f,
z|z,c|f,a|d,b|d,c|z,b|e,b|f,z|z,c|f,a|e,a|e,z|f,z|f,a|d,b|z,c|e];
/* eslint-enable comma-spacing */
class DES {
constructor(password) {
this.keys = [];
// Set the key.
const pc1m = [], pcr = [], kn = [];
for (let j = 0, l = 56; j < 56; ++j, l -= 8) {
l += l < -5 ? 65 : l < -3 ? 31 : l < -1 ? 63 : l === 27 ? 35 : 0; // PC1
const m = l & 0x7;
pc1m[j] = ((password[l >>> 3] & (1<<m)) !== 0) ? 1: 0;
}
for (let i = 0; i < 16; ++i) {
const m = i << 1;
const n = m + 1;
kn[m] = kn[n] = 0;
for (let o = 28; o < 59; o += 28) {
for (let j = o - 28; j < o; ++j) {
const l = j + totrot[i];
pcr[j] = l < o ? pc1m[l] : pc1m[l - 28];
}
}
for (let j = 0; j < 24; ++j) {
if (pcr[PC2[j]] !== 0) {
kn[m] |= 1 << (23 - j);
}
if (pcr[PC2[j + 24]] !== 0) {
kn[n] |= 1 << (23 - j);
}
}
}
// cookey
for (let i = 0, rawi = 0, KnLi = 0; i < 16; ++i) {
const raw0 = kn[rawi++];
const raw1 = kn[rawi++];
this.keys[KnLi] = (raw0 & 0x00fc0000) << 6;
this.keys[KnLi] |= (raw0 & 0x00000fc0) << 10;
this.keys[KnLi] |= (raw1 & 0x00fc0000) >>> 10;
this.keys[KnLi] |= (raw1 & 0x00000fc0) >>> 6;
++KnLi;
this.keys[KnLi] = (raw0 & 0x0003f000) << 12;
this.keys[KnLi] |= (raw0 & 0x0000003f) << 16;
this.keys[KnLi] |= (raw1 & 0x0003f000) >>> 4;
this.keys[KnLi] |= (raw1 & 0x0000003f);
++KnLi;
}
}
// Encrypt 8 bytes of text
enc8(text) {
const b = text.slice();
let i = 0, l, r, x; // left, right, accumulator
// Squash 8 bytes to 2 ints
l = b[i++]<<24 | b[i++]<<16 | b[i++]<<8 | b[i++];
r = b[i++]<<24 | b[i++]<<16 | b[i++]<<8 | b[i++];
x = ((l >>> 4) ^ r) & 0x0f0f0f0f;
r ^= x;
l ^= (x << 4);
x = ((l >>> 16) ^ r) & 0x0000ffff;
r ^= x;
l ^= (x << 16);
x = ((r >>> 2) ^ l) & 0x33333333;
l ^= x;
r ^= (x << 2);
x = ((r >>> 8) ^ l) & 0x00ff00ff;
l ^= x;
r ^= (x << 8);
r = (r << 1) | ((r >>> 31) & 1);
x = (l ^ r) & 0xaaaaaaaa;
l ^= x;
r ^= x;
l = (l << 1) | ((l >>> 31) & 1);
for (let i = 0, keysi = 0; i < 8; ++i) {
x = (r << 28) | (r >>> 4);
x ^= this.keys[keysi++];
let fval = SP7[x & 0x3f];
fval |= SP5[(x >>> 8) & 0x3f];
fval |= SP3[(x >>> 16) & 0x3f];
fval |= SP1[(x >>> 24) & 0x3f];
x = r ^ this.keys[keysi++];
fval |= SP8[x & 0x3f];
fval |= SP6[(x >>> 8) & 0x3f];
fval |= SP4[(x >>> 16) & 0x3f];
fval |= SP2[(x >>> 24) & 0x3f];
l ^= fval;
x = (l << 28) | (l >>> 4);
x ^= this.keys[keysi++];
fval = SP7[x & 0x3f];
fval |= SP5[(x >>> 8) & 0x3f];
fval |= SP3[(x >>> 16) & 0x3f];
fval |= SP1[(x >>> 24) & 0x3f];
x = l ^ this.keys[keysi++];
fval |= SP8[x & 0x0000003f];
fval |= SP6[(x >>> 8) & 0x3f];
fval |= SP4[(x >>> 16) & 0x3f];
fval |= SP2[(x >>> 24) & 0x3f];
r ^= fval;
}
r = (r << 31) | (r >>> 1);
x = (l ^ r) & 0xaaaaaaaa;
l ^= x;
r ^= x;
l = (l << 31) | (l >>> 1);
x = ((l >>> 8) ^ r) & 0x00ff00ff;
r ^= x;
l ^= (x << 8);
x = ((l >>> 2) ^ r) & 0x33333333;
r ^= x;
l ^= (x << 2);
x = ((r >>> 16) ^ l) & 0x0000ffff;
l ^= x;
r ^= (x << 16);
x = ((r >>> 4) ^ l) & 0x0f0f0f0f;
l ^= x;
r ^= (x << 4);
// Spread ints to bytes
x = [r, l];
for (i = 0; i < 8; i++) {
b[i] = (x[i>>>2] >>> (8 * (3 - (i % 4)))) % 256;
if (b[i] < 0) { b[i] += 256; } // unsigned
}
return b;
}
}
export class DESECBCipher {
constructor() {
this._cipher = null;
}
get algorithm() {
return { name: "DES-ECB" };
}
static importKey(key, _algorithm, _extractable, _keyUsages) {
const cipher = new DESECBCipher;
cipher._importKey(key);
return cipher;
}
_importKey(key, _extractable, _keyUsages) {
this._cipher = new DES(key);
}
encrypt(_algorithm, plaintext) {
const x = new Uint8Array(plaintext);
if (x.length % 8 !== 0 || this._cipher === null) {
return null;
}
const n = x.length / 8;
for (let i = 0; i < n; i++) {
x.set(this._cipher.enc8(x.slice(i * 8, i * 8 + 8)), i * 8);
}
return x;
}
}
export class DESCBCCipher {
constructor() {
this._cipher = null;
}
get algorithm() {
return { name: "DES-CBC" };
}
static importKey(key, _algorithm, _extractable, _keyUsages) {
const cipher = new DESCBCCipher;
cipher._importKey(key);
return cipher;
}
_importKey(key) {
this._cipher = new DES(key);
}
encrypt(algorithm, plaintext) {
const x = new Uint8Array(plaintext);
let y = new Uint8Array(algorithm.iv);
if (x.length % 8 !== 0 || this._cipher === null) {
return null;
}
const n = x.length / 8;
for (let i = 0; i < n; i++) {
for (let j = 0; j < 8; j++) {
y[j] ^= plaintext[i * 8 + j];
}
y = this._cipher.enc8(y);
x.set(y, i * 8);
}
return x;
}
}
@@ -0,0 +1,55 @@
import { modPow, bigIntToU8Array, u8ArrayToBigInt } from "./bigint.js";
class DHPublicKey {
constructor(key) {
this._key = key;
}
get algorithm() {
return { name: "DH" };
}
exportKey() {
return this._key;
}
}
export class DHCipher {
constructor() {
this._g = null;
this._p = null;
this._gBigInt = null;
this._pBigInt = null;
this._privateKey = null;
}
get algorithm() {
return { name: "DH" };
}
static generateKey(algorithm, _extractable) {
const cipher = new DHCipher;
cipher._generateKey(algorithm);
return { privateKey: cipher, publicKey: new DHPublicKey(cipher._publicKey) };
}
_generateKey(algorithm) {
const g = algorithm.g;
const p = algorithm.p;
this._keyBytes = p.length;
this._gBigInt = u8ArrayToBigInt(g);
this._pBigInt = u8ArrayToBigInt(p);
this._privateKey = window.crypto.getRandomValues(new Uint8Array(this._keyBytes));
this._privateKeyBigInt = u8ArrayToBigInt(this._privateKey);
this._publicKey = bigIntToU8Array(modPow(
this._gBigInt, this._privateKeyBigInt, this._pBigInt), this._keyBytes);
}
deriveBits(algorithm, length) {
const bytes = Math.ceil(length / 8);
const pkey = new Uint8Array(algorithm.public);
const len = bytes > this._keyBytes ? bytes : this._keyBytes;
const secret = modPow(u8ArrayToBigInt(pkey), this._privateKeyBigInt, this._pBigInt);
return bigIntToU8Array(secret, len).slice(0, len);
}
}
@@ -0,0 +1,82 @@
/*
* noVNC: HTML5 VNC client
* Copyright (C) 2021 The noVNC authors
* Licensed under MPL 2.0 (see LICENSE.txt)
*
* See README.md for usage and integration instructions.
*/
/*
* Performs MD5 hashing on an array of bytes, returns an array of bytes
*/
export async function MD5(d) {
let s = "";
for (let i = 0; i < d.length; i++) {
s += String.fromCharCode(d[i]);
}
return M(V(Y(X(s), 8 * s.length)));
}
function M(d) {
let f = new Uint8Array(d.length);
for (let i=0;i<d.length;i++) {
f[i] = d.charCodeAt(i);
}
return f;
}
function X(d) {
let r = Array(d.length >> 2);
for (let m = 0; m < r.length; m++) r[m] = 0;
for (let m = 0; m < 8 * d.length; m += 8) r[m >> 5] |= (255 & d.charCodeAt(m / 8)) << m % 32;
return r;
}
function V(d) {
let r = "";
for (let m = 0; m < 32 * d.length; m += 8) r += String.fromCharCode(d[m >> 5] >>> m % 32 & 255);
return r;
}
function Y(d, g) {
d[g >> 5] |= 128 << g % 32, d[14 + (g + 64 >>> 9 << 4)] = g;
let m = 1732584193, f = -271733879, r = -1732584194, i = 271733878;
for (let n = 0; n < d.length; n += 16) {
let h = m,
t = f,
g = r,
e = i;
f = ii(f = ii(f = ii(f = ii(f = hh(f = hh(f = hh(f = hh(f = gg(f = gg(f = gg(f = gg(f = ff(f = ff(f = ff(f = ff(f, r = ff(r, i = ff(i, m = ff(m, f, r, i, d[n + 0], 7, -680876936), f, r, d[n + 1], 12, -389564586), m, f, d[n + 2], 17, 606105819), i, m, d[n + 3], 22, -1044525330), r = ff(r, i = ff(i, m = ff(m, f, r, i, d[n + 4], 7, -176418897), f, r, d[n + 5], 12, 1200080426), m, f, d[n + 6], 17, -1473231341), i, m, d[n + 7], 22, -45705983), r = ff(r, i = ff(i, m = ff(m, f, r, i, d[n + 8], 7, 1770035416), f, r, d[n + 9], 12, -1958414417), m, f, d[n + 10], 17, -42063), i, m, d[n + 11], 22, -1990404162), r = ff(r, i = ff(i, m = ff(m, f, r, i, d[n + 12], 7, 1804603682), f, r, d[n + 13], 12, -40341101), m, f, d[n + 14], 17, -1502002290), i, m, d[n + 15], 22, 1236535329), r = gg(r, i = gg(i, m = gg(m, f, r, i, d[n + 1], 5, -165796510), f, r, d[n + 6], 9, -1069501632), m, f, d[n + 11], 14, 643717713), i, m, d[n + 0], 20, -373897302), r = gg(r, i = gg(i, m = gg(m, f, r, i, d[n + 5], 5, -701558691), f, r, d[n + 10], 9, 38016083), m, f, d[n + 15], 14, -660478335), i, m, d[n + 4], 20, -405537848), r = gg(r, i = gg(i, m = gg(m, f, r, i, d[n + 9], 5, 568446438), f, r, d[n + 14], 9, -1019803690), m, f, d[n + 3], 14, -187363961), i, m, d[n + 8], 20, 1163531501), r = gg(r, i = gg(i, m = gg(m, f, r, i, d[n + 13], 5, -1444681467), f, r, d[n + 2], 9, -51403784), m, f, d[n + 7], 14, 1735328473), i, m, d[n + 12], 20, -1926607734), r = hh(r, i = hh(i, m = hh(m, f, r, i, d[n + 5], 4, -378558), f, r, d[n + 8], 11, -2022574463), m, f, d[n + 11], 16, 1839030562), i, m, d[n + 14], 23, -35309556), r = hh(r, i = hh(i, m = hh(m, f, r, i, d[n + 1], 4, -1530992060), f, r, d[n + 4], 11, 1272893353), m, f, d[n + 7], 16, -155497632), i, m, d[n + 10], 23, -1094730640), r = hh(r, i = hh(i, m = hh(m, f, r, i, d[n + 13], 4, 681279174), f, r, d[n + 0], 11, -358537222), m, f, d[n + 3], 16, -722521979), i, m, d[n + 6], 23, 76029189), r = hh(r, i = hh(i, m = hh(m, f, r, i, d[n + 9], 4, -640364487), f, r, d[n + 12], 11, -421815835), m, f, d[n + 15], 16, 530742520), i, m, d[n + 2], 23, -995338651), r = ii(r, i = ii(i, m = ii(m, f, r, i, d[n + 0], 6, -198630844), f, r, d[n + 7], 10, 1126891415), m, f, d[n + 14], 15, -1416354905), i, m, d[n + 5], 21, -57434055), r = ii(r, i = ii(i, m = ii(m, f, r, i, d[n + 12], 6, 1700485571), f, r, d[n + 3], 10, -1894986606), m, f, d[n + 10], 15, -1051523), i, m, d[n + 1], 21, -2054922799), r = ii(r, i = ii(i, m = ii(m, f, r, i, d[n + 8], 6, 1873313359), f, r, d[n + 15], 10, -30611744), m, f, d[n + 6], 15, -1560198380), i, m, d[n + 13], 21, 1309151649), r = ii(r, i = ii(i, m = ii(m, f, r, i, d[n + 4], 6, -145523070), f, r, d[n + 11], 10, -1120210379), m, f, d[n + 2], 15, 718787259), i, m, d[n + 9], 21, -343485551), m = add(m, h), f = add(f, t), r = add(r, g), i = add(i, e);
}
return Array(m, f, r, i);
}
function cmn(d, g, m, f, r, i) {
return add(rol(add(add(g, d), add(f, i)), r), m);
}
function ff(d, g, m, f, r, i, n) {
return cmn(g & m | ~g & f, d, g, r, i, n);
}
function gg(d, g, m, f, r, i, n) {
return cmn(g & f | m & ~f, d, g, r, i, n);
}
function hh(d, g, m, f, r, i, n) {
return cmn(g ^ m ^ f, d, g, r, i, n);
}
function ii(d, g, m, f, r, i, n) {
return cmn(m ^ (g | ~f), d, g, r, i, n);
}
function add(d, g) {
let m = (65535 & d) + (65535 & g);
return (d >> 16) + (g >> 16) + (m >> 16) << 16 | 65535 & m;
}
function rol(d, g) {
return d << g | d >>> 32 - g;
}
@@ -0,0 +1,132 @@
import Base64 from "../base64.js";
import { modPow, bigIntToU8Array, u8ArrayToBigInt } from "./bigint.js";
export class RSACipher {
constructor() {
this._keyLength = 0;
this._keyBytes = 0;
this._n = null;
this._e = null;
this._d = null;
this._nBigInt = null;
this._eBigInt = null;
this._dBigInt = null;
this._extractable = false;
}
get algorithm() {
return { name: "RSA-PKCS1-v1_5" };
}
_base64urlDecode(data) {
data = data.replace(/-/g, "+").replace(/_/g, "/");
data = data.padEnd(Math.ceil(data.length / 4) * 4, "=");
return Base64.decode(data);
}
_padArray(arr, length) {
const res = new Uint8Array(length);
res.set(arr, length - arr.length);
return res;
}
static async generateKey(algorithm, extractable, _keyUsages) {
const cipher = new RSACipher;
await cipher._generateKey(algorithm, extractable);
return { privateKey: cipher };
}
async _generateKey(algorithm, extractable) {
this._keyLength = algorithm.modulusLength;
this._keyBytes = Math.ceil(this._keyLength / 8);
const key = await window.crypto.subtle.generateKey(
{
name: "RSA-OAEP",
modulusLength: algorithm.modulusLength,
publicExponent: algorithm.publicExponent,
hash: {name: "SHA-256"},
},
true, ["encrypt", "decrypt"]);
const privateKey = await window.crypto.subtle.exportKey("jwk", key.privateKey);
this._n = this._padArray(this._base64urlDecode(privateKey.n), this._keyBytes);
this._nBigInt = u8ArrayToBigInt(this._n);
this._e = this._padArray(this._base64urlDecode(privateKey.e), this._keyBytes);
this._eBigInt = u8ArrayToBigInt(this._e);
this._d = this._padArray(this._base64urlDecode(privateKey.d), this._keyBytes);
this._dBigInt = u8ArrayToBigInt(this._d);
this._extractable = extractable;
}
static async importKey(key, _algorithm, extractable, keyUsages) {
if (keyUsages.length !== 1 || keyUsages[0] !== "encrypt") {
throw new Error("only support importing RSA public key");
}
const cipher = new RSACipher;
await cipher._importKey(key, extractable);
return cipher;
}
async _importKey(key, extractable) {
const n = key.n;
const e = key.e;
if (n.length !== e.length) {
throw new Error("the sizes of modulus and public exponent do not match");
}
this._keyBytes = n.length;
this._keyLength = this._keyBytes * 8;
this._n = new Uint8Array(this._keyBytes);
this._e = new Uint8Array(this._keyBytes);
this._n.set(n);
this._e.set(e);
this._nBigInt = u8ArrayToBigInt(this._n);
this._eBigInt = u8ArrayToBigInt(this._e);
this._extractable = extractable;
}
async encrypt(_algorithm, message) {
if (message.length > this._keyBytes - 11) {
return null;
}
const ps = new Uint8Array(this._keyBytes - message.length - 3);
window.crypto.getRandomValues(ps);
for (let i = 0; i < ps.length; i++) {
ps[i] = Math.floor(ps[i] * 254 / 255 + 1);
}
const em = new Uint8Array(this._keyBytes);
em[1] = 0x02;
em.set(ps, 2);
em.set(message, ps.length + 3);
const emBigInt = u8ArrayToBigInt(em);
const c = modPow(emBigInt, this._eBigInt, this._nBigInt);
return bigIntToU8Array(c, this._keyBytes);
}
async decrypt(_algorithm, message) {
if (message.length !== this._keyBytes) {
return null;
}
const msgBigInt = u8ArrayToBigInt(message);
const emBigInt = modPow(msgBigInt, this._dBigInt, this._nBigInt);
const em = bigIntToU8Array(emBigInt, this._keyBytes);
if (em[0] !== 0x00 || em[1] !== 0x02) {
return null;
}
let i = 2;
for (; i < em.length; i++) {
if (em[i] === 0x00) {
break;
}
}
if (i === em.length) {
return null;
}
return em.slice(i + 1, em.length);
}
async exportKey() {
if (!this._extractable) {
throw new Error("key is not extractable");
}
return { n: this._n, e: this._e, d: this._d };
}
}
@@ -0,0 +1,27 @@
/*
* noVNC: HTML5 VNC client
* Copyright (C) 2019 The noVNC authors
* Licensed under MPL 2.0 (see LICENSE.txt)
*
* See README.md for usage and integration instructions.
*
*/
export default class CopyRectDecoder {
decodeRect(x, y, width, height, sock, display, depth) {
if (sock.rQwait("COPYRECT", 4)) {
return false;
}
let deltaX = sock.rQshift16();
let deltaY = sock.rQshift16();
if ((width === 0) || (height === 0)) {
return true;
}
display.copyImage(deltaX, deltaY, x, y, width, height);
return true;
}
}
@@ -0,0 +1,321 @@
/*
* noVNC: HTML5 VNC client
* Copyright (C) 2024 The noVNC authors
* Licensed under MPL 2.0 (see LICENSE.txt)
*
* See README.md for usage and integration instructions.
*
*/
import * as Log from '../util/logging.js';
export class H264Parser {
constructor(data) {
this._data = data;
this._index = 0;
this.profileIdc = null;
this.constraintSet = null;
this.levelIdc = null;
}
_getStartSequenceLen(index) {
let data = this._data;
if (data[index + 0] == 0 && data[index + 1] == 0 && data[index + 2] == 0 && data[index + 3] == 1) {
return 4;
}
if (data[index + 0] == 0 && data[index + 1] == 0 && data[index + 2] == 1) {
return 3;
}
return 0;
}
_indexOfNextNalUnit(index) {
let data = this._data;
for (let i = index; i < data.length; ++i) {
if (this._getStartSequenceLen(i) != 0) {
return i;
}
}
return -1;
}
_parseSps(index) {
this.profileIdc = this._data[index];
this.constraintSet = this._data[index + 1];
this.levelIdc = this._data[index + 2];
}
_parseNalUnit(index) {
const firstByte = this._data[index];
if (firstByte & 0x80) {
throw new Error('H264 parsing sanity check failed, forbidden zero bit is set');
}
const unitType = firstByte & 0x1f;
switch (unitType) {
case 1: // coded slice, non-idr
return { slice: true };
case 5: // coded slice, idr
return { slice: true, key: true };
case 6: // sei
return {};
case 7: // sps
this._parseSps(index + 1);
return {};
case 8: // pps
return {};
default:
Log.Warn("Unhandled unit type: ", unitType);
break;
}
return {};
}
parse() {
const startIndex = this._index;
let isKey = false;
while (this._index < this._data.length) {
const startSequenceLen = this._getStartSequenceLen(this._index);
if (startSequenceLen == 0) {
throw new Error('Invalid start sequence in bit stream');
}
const { slice, key } = this._parseNalUnit(this._index + startSequenceLen);
let nextIndex = this._indexOfNextNalUnit(this._index + startSequenceLen);
if (nextIndex == -1) {
this._index = this._data.length;
} else {
this._index = nextIndex;
}
if (key) {
isKey = true;
}
if (slice) {
break;
}
}
if (startIndex === this._index) {
return null;
}
return {
frame: this._data.subarray(startIndex, this._index),
key: isKey,
};
}
}
export class H264Context {
constructor(width, height) {
this.lastUsed = 0;
this._width = width;
this._height = height;
this._profileIdc = null;
this._constraintSet = null;
this._levelIdc = null;
this._decoder = null;
this._pendingFrames = [];
}
_handleFrame(frame) {
let pending = this._pendingFrames.shift();
if (pending === undefined) {
throw new Error("Pending frame queue empty when receiving frame from decoder");
}
if (pending.timestamp != frame.timestamp) {
throw new Error("Video frame timestamp mismatch. Expected " +
frame.timestamp + " but but got " + pending.timestamp);
}
pending.frame = frame;
pending.ready = true;
pending.resolve();
if (!pending.keep) {
frame.close();
}
}
_handleError(e) {
throw new Error("Failed to decode frame: " + e.message);
}
_configureDecoder(profileIdc, constraintSet, levelIdc) {
if (this._decoder === null || this._decoder.state === 'closed') {
this._decoder = new VideoDecoder({
output: frame => this._handleFrame(frame),
error: e => this._handleError(e),
});
}
const codec = 'avc1.' +
profileIdc.toString(16).padStart(2, '0') +
constraintSet.toString(16).padStart(2, '0') +
levelIdc.toString(16).padStart(2, '0');
this._decoder.configure({
codec: codec,
codedWidth: this._width,
codedHeight: this._height,
optimizeForLatency: true,
});
}
_preparePendingFrame(timestamp) {
let pending = {
timestamp: timestamp,
promise: null,
resolve: null,
frame: null,
ready: false,
keep: false,
};
pending.promise = new Promise((resolve) => {
pending.resolve = resolve;
});
this._pendingFrames.push(pending);
return pending;
}
decode(payload) {
let parser = new H264Parser(payload);
let result = null;
// Ideally, this timestamp should come from the server, but we'll just
// approximate it instead.
let timestamp = Math.round(window.performance.now() * 1e3);
while (true) {
let encodedFrame = parser.parse();
if (encodedFrame === null) {
break;
}
if (parser.profileIdc !== null) {
self._profileIdc = parser.profileIdc;
self._constraintSet = parser.constraintSet;
self._levelIdc = parser.levelIdc;
}
if (this._decoder === null || this._decoder.state !== 'configured') {
if (!encodedFrame.key) {
Log.Warn("Missing key frame. Can't decode until one arrives");
continue;
}
if (self._profileIdc === null) {
Log.Warn('Cannot config decoder. Have not received SPS and PPS yet.');
continue;
}
this._configureDecoder(self._profileIdc, self._constraintSet,
self._levelIdc);
}
result = this._preparePendingFrame(timestamp);
const chunk = new EncodedVideoChunk({
timestamp: timestamp,
type: encodedFrame.key ? 'key' : 'delta',
data: encodedFrame.frame,
});
try {
this._decoder.decode(chunk);
} catch (e) {
Log.Warn("Failed to decode:", e);
}
}
// We only keep last frame of each payload
if (result !== null) {
result.keep = true;
}
return result;
}
}
export default class H264Decoder {
constructor() {
this._tick = 0;
this._contexts = {};
}
_contextId(x, y, width, height) {
return [x, y, width, height].join(',');
}
_findOldestContextId() {
let oldestTick = Number.MAX_VALUE;
let oldestKey = undefined;
for (const [key, value] of Object.entries(this._contexts)) {
if (value.lastUsed < oldestTick) {
oldestTick = value.lastUsed;
oldestKey = key;
}
}
return oldestKey;
}
_createContext(x, y, width, height) {
const maxContexts = 64;
if (Object.keys(this._contexts).length >= maxContexts) {
let oldestContextId = this._findOldestContextId();
delete this._contexts[oldestContextId];
}
let context = new H264Context(width, height);
this._contexts[this._contextId(x, y, width, height)] = context;
return context;
}
_getContext(x, y, width, height) {
let context = this._contexts[this._contextId(x, y, width, height)];
return context !== undefined ? context : this._createContext(x, y, width, height);
}
_resetContext(x, y, width, height) {
delete this._contexts[this._contextId(x, y, width, height)];
}
_resetAllContexts() {
this._contexts = {};
}
decodeRect(x, y, width, height, sock, display, depth) {
const resetContextFlag = 1;
const resetAllContextsFlag = 2;
if (sock.rQwait("h264 header", 8)) {
return false;
}
const length = sock.rQshift32();
const flags = sock.rQshift32();
if (sock.rQwait("h264 payload", length, 8)) {
return false;
}
if (flags & resetAllContextsFlag) {
this._resetAllContexts();
} else if (flags & resetContextFlag) {
this._resetContext(x, y, width, height);
}
let context = this._getContext(x, y, width, height);
context.lastUsed = this._tick++;
if (length !== 0) {
let payload = sock.rQshiftBytes(length, false);
let frame = context.decode(payload);
if (frame !== null) {
display.videoFrame(x, y, width, height, frame);
}
}
return true;
}
}
@@ -0,0 +1,181 @@
/*
* noVNC: HTML5 VNC client
* Copyright (C) 2019 The noVNC authors
* Licensed under MPL 2.0 (see LICENSE.txt)
*
* See README.md for usage and integration instructions.
*
*/
import * as Log from '../util/logging.js';
export default class HextileDecoder {
constructor() {
this._tiles = 0;
this._lastsubencoding = 0;
this._tileBuffer = new Uint8Array(16 * 16 * 4);
}
decodeRect(x, y, width, height, sock, display, depth) {
if (this._tiles === 0) {
this._tilesX = Math.ceil(width / 16);
this._tilesY = Math.ceil(height / 16);
this._totalTiles = this._tilesX * this._tilesY;
this._tiles = this._totalTiles;
}
while (this._tiles > 0) {
let bytes = 1;
if (sock.rQwait("HEXTILE", bytes)) {
return false;
}
let subencoding = sock.rQpeek8();
if (subencoding > 30) { // Raw
throw new Error("Illegal hextile subencoding (subencoding: " +
subencoding + ")");
}
const currTile = this._totalTiles - this._tiles;
const tileX = currTile % this._tilesX;
const tileY = Math.floor(currTile / this._tilesX);
const tx = x + tileX * 16;
const ty = y + tileY * 16;
const tw = Math.min(16, (x + width) - tx);
const th = Math.min(16, (y + height) - ty);
// Figure out how much we are expecting
if (subencoding & 0x01) { // Raw
bytes += tw * th * 4;
} else {
if (subencoding & 0x02) { // Background
bytes += 4;
}
if (subencoding & 0x04) { // Foreground
bytes += 4;
}
if (subencoding & 0x08) { // AnySubrects
bytes++; // Since we aren't shifting it off
if (sock.rQwait("HEXTILE", bytes)) {
return false;
}
let subrects = sock.rQpeekBytes(bytes).at(-1);
if (subencoding & 0x10) { // SubrectsColoured
bytes += subrects * (4 + 2);
} else {
bytes += subrects * 2;
}
}
}
if (sock.rQwait("HEXTILE", bytes)) {
return false;
}
// We know the encoding and have a whole tile
sock.rQshift8();
if (subencoding === 0) {
if (this._lastsubencoding & 0x01) {
// Weird: ignore blanks are RAW
Log.Debug(" Ignoring blank after RAW");
} else {
display.fillRect(tx, ty, tw, th, this._background);
}
} else if (subencoding & 0x01) { // Raw
let pixels = tw * th;
let data = sock.rQshiftBytes(pixels * 4, false);
// Max sure the image is fully opaque
for (let i = 0;i < pixels;i++) {
data[i * 4 + 3] = 255;
}
display.blitImage(tx, ty, tw, th, data, 0);
} else {
if (subencoding & 0x02) { // Background
this._background = new Uint8Array(sock.rQshiftBytes(4));
}
if (subencoding & 0x04) { // Foreground
this._foreground = new Uint8Array(sock.rQshiftBytes(4));
}
this._startTile(tx, ty, tw, th, this._background);
if (subencoding & 0x08) { // AnySubrects
let subrects = sock.rQshift8();
for (let s = 0; s < subrects; s++) {
let color;
if (subencoding & 0x10) { // SubrectsColoured
color = sock.rQshiftBytes(4);
} else {
color = this._foreground;
}
const xy = sock.rQshift8();
const sx = (xy >> 4);
const sy = (xy & 0x0f);
const wh = sock.rQshift8();
const sw = (wh >> 4) + 1;
const sh = (wh & 0x0f) + 1;
this._subTile(sx, sy, sw, sh, color);
}
}
this._finishTile(display);
}
this._lastsubencoding = subencoding;
this._tiles--;
}
return true;
}
// start updating a tile
_startTile(x, y, width, height, color) {
this._tileX = x;
this._tileY = y;
this._tileW = width;
this._tileH = height;
const red = color[0];
const green = color[1];
const blue = color[2];
const data = this._tileBuffer;
for (let i = 0; i < width * height * 4; i += 4) {
data[i] = red;
data[i + 1] = green;
data[i + 2] = blue;
data[i + 3] = 255;
}
}
// update sub-rectangle of the current tile
_subTile(x, y, w, h, color) {
const red = color[0];
const green = color[1];
const blue = color[2];
const xend = x + w;
const yend = y + h;
const data = this._tileBuffer;
const width = this._tileW;
for (let j = y; j < yend; j++) {
for (let i = x; i < xend; i++) {
const p = (i + (j * width)) * 4;
data[p] = red;
data[p + 1] = green;
data[p + 2] = blue;
data[p + 3] = 255;
}
}
}
// draw the current tile to the screen
_finishTile(display) {
display.blitImage(this._tileX, this._tileY,
this._tileW, this._tileH,
this._tileBuffer, 0);
}
}
@@ -0,0 +1,146 @@
/*
* noVNC: HTML5 VNC client
* Copyright (C) 2019 The noVNC authors
* Licensed under MPL 2.0 (see LICENSE.txt)
*
* See README.md for usage and integration instructions.
*
*/
export default class JPEGDecoder {
constructor() {
// RealVNC will reuse the quantization tables
// and Huffman tables, so we need to cache them.
this._cachedQuantTables = [];
this._cachedHuffmanTables = [];
this._segments = [];
}
decodeRect(x, y, width, height, sock, display, depth) {
// A rect of JPEG encodings is simply a JPEG file
while (true) {
let segment = this._readSegment(sock);
if (segment === null) {
return false;
}
this._segments.push(segment);
// End of image?
if (segment[1] === 0xD9) {
break;
}
}
let huffmanTables = [];
let quantTables = [];
for (let segment of this._segments) {
let type = segment[1];
if (type === 0xC4) {
// Huffman tables
huffmanTables.push(segment);
} else if (type === 0xDB) {
// Quantization tables
quantTables.push(segment);
}
}
const sofIndex = this._segments.findIndex(
x => x[1] == 0xC0 || x[1] == 0xC2
);
if (sofIndex == -1) {
throw new Error("Illegal JPEG image without SOF");
}
if (quantTables.length === 0) {
this._segments.splice(sofIndex+1, 0,
...this._cachedQuantTables);
}
if (huffmanTables.length === 0) {
this._segments.splice(sofIndex+1, 0,
...this._cachedHuffmanTables);
}
let length = 0;
for (let segment of this._segments) {
length += segment.length;
}
let data = new Uint8Array(length);
length = 0;
for (let segment of this._segments) {
data.set(segment, length);
length += segment.length;
}
display.imageRect(x, y, width, height, "image/jpeg", data);
if (huffmanTables.length !== 0) {
this._cachedHuffmanTables = huffmanTables;
}
if (quantTables.length !== 0) {
this._cachedQuantTables = quantTables;
}
this._segments = [];
return true;
}
_readSegment(sock) {
if (sock.rQwait("JPEG", 2)) {
return null;
}
let marker = sock.rQshift8();
if (marker != 0xFF) {
throw new Error("Illegal JPEG marker received (byte: " +
marker + ")");
}
let type = sock.rQshift8();
if (type >= 0xD0 && type <= 0xD9 || type == 0x01) {
// No length after marker
return new Uint8Array([marker, type]);
}
if (sock.rQwait("JPEG", 2, 2)) {
return null;
}
let length = sock.rQshift16();
if (length < 2) {
throw new Error("Illegal JPEG length received (length: " +
length + ")");
}
if (sock.rQwait("JPEG", length-2, 4)) {
return null;
}
let extra = 0;
if (type === 0xDA) {
// start of scan
extra += 2;
while (true) {
if (sock.rQwait("JPEG", length-2+extra, 4)) {
return null;
}
let data = sock.rQpeekBytes(length-2+extra, false);
if (data.at(-2) === 0xFF && data.at(-1) !== 0x00 &&
!(data.at(-1) >= 0xD0 && data.at(-1) <= 0xD7)) {
extra -= 2;
break;
}
extra++;
}
}
let segment = new Uint8Array(2 + length + extra);
segment[0] = marker;
segment[1] = type;
segment[2] = length >> 8;
segment[3] = length;
segment.set(sock.rQshiftBytes(length-2+extra, false), 4);
return segment;
}
}
@@ -0,0 +1,59 @@
/*
* noVNC: HTML5 VNC client
* Copyright (C) 2019 The noVNC authors
* Licensed under MPL 2.0 (see LICENSE.txt)
*
* See README.md for usage and integration instructions.
*
*/
export default class RawDecoder {
constructor() {
this._lines = 0;
}
decodeRect(x, y, width, height, sock, display, depth) {
if ((width === 0) || (height === 0)) {
return true;
}
if (this._lines === 0) {
this._lines = height;
}
const pixelSize = depth == 8 ? 1 : 4;
const bytesPerLine = width * pixelSize;
while (this._lines > 0) {
if (sock.rQwait("RAW", bytesPerLine)) {
return false;
}
const curY = y + (height - this._lines);
let data = sock.rQshiftBytes(bytesPerLine, false);
// Convert data if needed
if (depth == 8) {
const newdata = new Uint8Array(width * 4);
for (let i = 0; i < width; i++) {
newdata[i * 4 + 0] = ((data[i] >> 0) & 0x3) * 255 / 3;
newdata[i * 4 + 1] = ((data[i] >> 2) & 0x3) * 255 / 3;
newdata[i * 4 + 2] = ((data[i] >> 4) & 0x3) * 255 / 3;
newdata[i * 4 + 3] = 255;
}
data = newdata;
}
// Max sure the image is fully opaque
for (let i = 0; i < width; i++) {
data[i * 4 + 3] = 255;
}
display.blitImage(x, curY, width, 1, data, 0);
this._lines--;
}
return true;
}
}
Loaded 100 of 229 files, more files were not shown because too many files have changed in this diff. Show more