Compare commits

..
12 Commits
86 changed files with 2539 additions and 660 deletions

No files matched your search

+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project] [project]
name = "tesm" name = "tesm"
version = "2.5.1" version = "2.7.6"
description = "TESM -- Verwaltung und Ueberwachung von PoE-Endgeraeten und Aruba-Switchen" description = "TESM -- Verwaltung und Ueberwachung von PoE-Endgeraeten und Aruba-Switchen"
requires-python = ">=3.11" requires-python = ">=3.11"
dependencies = [ dependencies = [
+19 -14
View File
@@ -164,7 +164,7 @@ def _mount_shares_after_login(conn: Any, user: Any, secret: str, group_dns: tupl
group_dns=group_dns, group_dns=group_dns,
) )
for message in messages: for message in messages:
current_app.logger.info("Dateifreigabe fuer %s -- %s", user.username, message) current_app.logger.info("Dateifreigabe für %s | %s", user.username, message)
def _release_shares_on_logout(conn: Any, user: Any) -> None: def _release_shares_on_logout(conn: Any, user: Any) -> None:
@@ -174,14 +174,19 @@ def _release_shares_on_logout(conn: Any, user: Any) -> None:
dem Abmelden bestehen, waere sein Zugriff weiter offen -- bis irgendwann dem Abmelden bestehen, waere sein Zugriff weiter offen -- bis irgendwann
die Zeitgrenze greift. Das ist zu lange. die Zeitgrenze greift. Das ist zu lange.
""" """
from flask import session
from .services import fileshare from .services import fileshare
extension = core() extension = core()
# Nur die Einbindungen dieser Sitzung: wer an zwei Geraeten angemeldet ist,
# soll am zweiten weiterarbeiten koennen.
sitzung = str(getattr(session, "sid", "") or "")
with extension.database.transaction(conn): with extension.database.transaction(conn):
released = fileshare.release_all_for_user(conn, user.id) released = fileshare.release_all_for_user(conn, user.id, sitzung)
if released: if released:
current_app.logger.info( current_app.logger.info(
"Dateifreigaben fuer %s geloest: %d", user.username, released "Dateifreigaben für %s gelöst: %d", user.username, released
) )
@@ -205,19 +210,19 @@ def _status_pills(user: Any) -> list[dict[str, str]]:
faellig = monitor.next_check_at(verbindung, intervall) faellig = monitor.next_check_at(verbindung, intervall)
if faellig is not None: if faellig is not None:
eintrag = { eintrag = {
"label": "Naechste Pruefung in", "label": "Nächste Prüfung in",
"countdown_to": faellig.isoformat(), "countdown_to": faellig.isoformat(),
"countdown_done": "jetzt", "countdown_done": "jetzt",
"css_class": "", "css_class": "",
"dot": "1", "dot": "1",
"dot_class": "", "dot_class": "",
"title": f"Der Ueberwachungsdienst prueft alle {intervall} Sekunden.", "title": f"Der Überwachungsdienst prüft alle {intervall} Sekunden.",
} }
if user.has("settings_system.execute"): if user.has("settings_system.execute"):
from flask import url_for from flask import url_for
eintrag["action"] = url_for("settings.run_check_now") eintrag["action"] = url_for("settings.run_check_now")
eintrag["action_title"] = "Alle Clients sofort pruefen" eintrag["action_title"] = "Alle Clients sofort prüfen"
pills.append(eintrag) pills.append(eintrag)
counts = monitor.dashboard_counts(verbindung) counts = monitor.dashboard_counts(verbindung)
@@ -228,7 +233,7 @@ def _status_pills(user: Any) -> list[dict[str, str]]:
"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",
"title": "Geraete, die beim letzten Durchlauf nicht geantwortet haben.", "title": "Geräte, die beim letzten Durchlauf nicht geantwortet haben.",
} }
) )
@@ -265,7 +270,7 @@ def _register_trash_providers(app: Flask) -> None:
conn, "devices", int(ident), unique_column="mac" conn, "devices", int(ident), unique_column="mac"
), ),
purge_fn=lambda conn, ident: inventory.purge_row(conn, "devices", int(ident)), purge_fn=lambda conn, ident: inventory.purge_row(conn, "devices", int(ident)),
hint="Die MAC-Adresse bleibt reserviert, solange das Geraet hier liegt.", hint="Die MAC-Adresse bleibt reserviert, solange das Gerät hier liegt.",
) )
) )
trash_views.register( trash_views.register(
@@ -294,7 +299,7 @@ def _register_trash_providers(app: Flask) -> None:
conn, "credentials", int(ident), unique_column="name" conn, "credentials", int(ident), unique_column="name"
), ),
purge_fn=lambda conn, ident: inventory.purge_row(conn, "credentials", int(ident)), purge_fn=lambda conn, ident: inventory.purge_row(conn, "credentials", int(ident)),
hint="Endgueltiges Loeschen entfernt auch das gespeicherte Passwort.", hint="Endgültiges Löschen entfernt auch das gespeicherte Passwort.",
) )
) )
@@ -310,7 +315,7 @@ 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( logger.info(
"Startaufgaben uebersprungen -- dieser Prozess ist nicht als Webprozess markiert." "Startaufgaben übersprungen – dieser Prozess ist nicht als Webprozess markiert."
) )
return return
@@ -323,7 +328,7 @@ def _startup_tasks(app: Flask) -> None:
# die Oberflaeche Freigaben, die es nicht gibt. # die Oberflaeche Freigaben, die es nicht gibt.
vergessen = fileshare.forget_all_mounts(conn) vergessen = fileshare.forget_all_mounts(conn)
if orphaned: if orphaned:
logger.warning("%s unterbrochene Wartungsauftraege als unklar markiert", orphaned) logger.warning("%s unterbrochene Wartungsaufträge als unklar markiert", orphaned)
if vergessen: if vergessen:
logger.info( logger.info(
"%s Freigabe-Einbindungen eines vorherigen Prozesses vergessen " "%s Freigabe-Einbindungen eines vorherigen Prozesses vergessen "
@@ -361,7 +366,7 @@ def _start_fileshare_sweep(app: Flask) -> None:
with app.app_context(): with app.app_context():
geloest = fileshare.sweep_expired(core(app).database) geloest = fileshare.sweep_expired(core(app).database)
if geloest: if geloest:
logger.info("%s abgelaufene Freigabe-Einbindungen geloest", geloest) logger.info("%s abgelaufene Freigabe-Einbindungen gelöst", geloest)
def schleife() -> None: def schleife() -> None:
# Zwei Netze: das innere haelt den Faden am Leben, wenn ein einzelner # Zwei Netze: das innere haelt den Faden am Leben, wenn ein einzelner
@@ -375,9 +380,9 @@ def _start_fileshare_sweep(app: Flask) -> None:
try: try:
einmal() einmal()
except Exception: # noqa: BLE001 - der Webprozess darf nie sterben except Exception: # noqa: BLE001 - der Webprozess darf nie sterben
logger.exception("Aufraeumen der Dateifreigaben fehlgeschlagen") logger.exception("Aufräumen der Dateifreigaben fehlgeschlagen")
except BaseException: # noqa: BLE001 - auch ein Abbruch gehoert protokolliert except BaseException: # noqa: BLE001 - auch ein Abbruch gehoert protokolliert
logger.exception("Der Aufraeumfaden der Dateifreigaben ist beendet") logger.exception("Der Aufräumfaden der Dateifreigaben ist beendet")
raise raise
threading.Thread(target=schleife, name="fileshare-sweep", daemon=True).start() threading.Thread(target=schleife, name="fileshare-sweep", daemon=True).start()
+11 -11
View File
@@ -92,7 +92,7 @@ def export():
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"))
include_secrets = ( include_secrets = (
@@ -101,7 +101,7 @@ def export():
passphrase = request.form.get("passphrase") or "" passphrase = request.form.get("passphrase") or ""
repeat = request.form.get("passphrase_repeat") or "" repeat = request.form.get("passphrase_repeat") or ""
if passphrase != repeat: if passphrase != repeat:
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:
@@ -136,11 +136,11 @@ 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:
@@ -154,8 +154,8 @@ def import_preview():
session[PREVIEW_SESSION_KEY] = token session[PREVIEW_SESSION_KEY] = token
_audit("backup.import_previewed", f"Erstellt {header.get('created_at')}", severity="notice") _audit("backup.import_previewed", f"Erstellt {header.get('created_at')}", severity="notice")
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"))
@@ -169,13 +169,13 @@ def import_apply():
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:
@@ -206,7 +206,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(),
@@ -217,11 +217,11 @@ 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"))
+1 -1
View File
@@ -85,7 +85,7 @@ def update(credential_id: int):
"; ".join(changes), "; ".join(changes),
severity="notice", severity="notice",
) )
flash("Aenderungen gespeichert." if changes else "Keine Aenderungen.", "success" if changes else "info") flash("Änderungen gespeichert." if changes else "Keine Änderungen.", "success" if changes else "info")
except inventory.ValidationError as exc: except inventory.ValidationError as exc:
flash(str(exc), "danger") flash(str(exc), "danger")
return redirect(url_for("credentials.overview")) return redirect(url_for("credentials.overview"))
+2 -2
View File
@@ -80,7 +80,7 @@ def _tiles(*, authenticated: bool) -> dict[str, Any]:
# erwartbar weg. Wer die Uebersicht ansieht, will genau das zuerst # erwartbar weg. Wer die Uebersicht ansieht, will genau das zuerst
# wissen -- sonst haelt er einen laufenden Neustart fuer einen Ausfall. # wissen -- sonst haelt er einen laufenden Neustart fuer einen Ausfall.
"key": "restarting", "key": "restarting",
"label": "Neustart laeuft", "label": "Neustart läuft",
"kind": "warning", "kind": "warning",
"tiles": [tile for tile in tiles if tile["state"] == "restarting"], "tiles": [tile for tile in tiles if tile["state"] == "restarting"],
}, },
@@ -98,7 +98,7 @@ def _tiles(*, authenticated: bool) -> dict[str, Any]:
}, },
{ {
"key": "unknown", "key": "unknown",
"label": "Noch nicht geprueft", "label": "Noch nicht geprüft",
"kind": "warning", "kind": "warning",
"tiles": [tile for tile in tiles if tile["state"] == "unknown"], "tiles": [tile for tile in tiles if tile["state"] == "unknown"],
}, },
+15 -15
View File
@@ -100,7 +100,7 @@ def _credential_from_form(conn: Any) -> int | None:
) )
name = (request.form.get("new_credential_name") or "").strip() name = (request.form.get("new_credential_name") or "").strip()
if not name: if not name:
raise inventory.ValidationError("Fuer neue Zugangsdaten wird eine Bezeichnung gebraucht.") raise inventory.ValidationError("Für neue Zugangsdaten wird eine Bezeichnung gebraucht.")
return inventory.create_credential( return inventory.create_credential(
conn, conn,
core().keystore, core().keystore,
@@ -108,7 +108,7 @@ def _credential_from_form(conn: Any) -> int | None:
username=(request.form.get("new_credential_username") or "").strip(), username=(request.form.get("new_credential_username") or "").strip(),
secret=request.form.get("new_credential_secret") or "", secret=request.form.get("new_credential_secret") or "",
category=request.form.get("new_credential_category", "linux"), category=request.form.get("new_credential_category", "linux"),
description="Beim Anlegen eines Geraets erfasst.", description="Beim Anlegen eines Geräts erfasst.",
actor=current_user.username, actor=current_user.username,
) )
@@ -130,7 +130,7 @@ def create():
f"MAC {request.form.get('mac')}, IP {request.form.get('ip')}", f"MAC {request.form.get('mac')}, IP {request.form.get('ip')}",
severity="notice", severity="notice",
) )
flash("Geraet angelegt.", "success") flash("Gerät angelegt.", "success")
return redirect(url_for("devices.overview", highlight=device_id)) return redirect(url_for("devices.overview", highlight=device_id))
except inventory.ValidationError as exc: except inventory.ValidationError as exc:
flash(str(exc), "danger") flash(str(exc), "danger")
@@ -155,7 +155,7 @@ def update(device_id: int):
"; ".join(changes), "; ".join(changes),
severity="notice", severity="notice",
) )
flash("Aenderungen gespeichert." if changes else "Keine Aenderungen.", "success" if changes else "info") flash("Änderungen gespeichert." if changes else "Keine Änderungen.", "success" if changes else "info")
except inventory.ValidationError as exc: except inventory.ValidationError as exc:
flash(str(exc), "danger") flash(str(exc), "danger")
return redirect(url_for("devices.overview")) return redirect(url_for("devices.overview"))
@@ -176,7 +176,7 @@ def toggle_active(device_id: int):
str(device["name"]), str(device["name"]),
severity="notice", severity="notice",
) )
flash("Geraet aktiviert." if active else "Geraet deaktiviert.", "success") flash("Gerät aktiviert." if active else "Gerät deaktiviert.", "success")
return redirect(request.referrer or url_for("devices.overview")) return redirect(request.referrer or url_for("devices.overview"))
@@ -189,7 +189,7 @@ def delete(device_id: int):
abort(404) abort(404)
inventory.delete_device(conn, device_id) inventory.delete_device(conn, device_id)
_audit(conn, "device.deleted", str(device["name"]), severity="warning") _audit(conn, "device.deleted", str(device["name"]), severity="warning")
flash("Geraet in den Papierkorb verschoben.", "success") flash("Gerät in den Papierkorb verschoben.", "success")
return redirect(url_for("devices.overview")) return redirect(url_for("devices.overview"))
@@ -219,14 +219,14 @@ def restart_device(device_id: int):
options = restart.available(dict(device), maintenance_active=maintenance_active) options = restart.available(dict(device), maintenance_active=maintenance_active)
if options: if options:
flash( flash(
"Der gewaehlte Weg steht fuer dieses Geraet nicht zur Verfuegung. " "Der gewählte Weg steht für dieses Gerät nicht zur Verfügung. "
"Moeglich waere: " + ", ".join(entry.label for entry in options), "Möglich wäre: " + ", ".join(entry.label for entry in options),
"warning", "warning",
) )
else: else:
flash( flash(
f"Fuer {device['name']} ist kein Neustart moeglich: es fehlen Switch und Port " f"Für {device['name']} ist kein Neustart möglich: es fehlen Switch und Port "
"fuer den PoE-Weg und passende Zugangsdaten fuer SSH oder RPC.", "für den PoE-Weg und passende Zugangsdaten für SSH oder RPC.",
"warning", "warning",
) )
return redirect(request.referrer or url_for("devices.overview")) return redirect(request.referrer or url_for("devices.overview"))
@@ -262,7 +262,7 @@ def _restart_over_poe(extension: Any, conn: Any, device: Any): # type: ignore[n
return redirect(request.referrer or url_for("devices.overview")) return redirect(request.referrer or url_for("devices.overview"))
flash( flash(
f"PoE-Neustart fuer {device['name']} durchgefuehrt." f"PoE-Neustart für {device['name']} durchgeführt."
if outcome["ok"] if outcome["ok"]
else f"PoE-Neustart fehlgeschlagen: {outcome['message']}", else f"PoE-Neustart fehlgeschlagen: {outcome['message']}",
"success" if outcome["ok"] else "danger", "success" if outcome["ok"] else "danger",
@@ -319,7 +319,7 @@ def _restart_as_job(extension: Any, conn: Any, device: Any, method: Any): # typ
method=method.key, method=method.key,
) )
flash( flash(
f"{method.label} fuer {device['name']} gestartet. Der Verlauf steht unter Wartung.", f"{method.label} für {device['name']} gestartet. Der Verlauf steht unter Wartung.",
"success", "success",
) )
return redirect(request.referrer or url_for("devices.overview")) return redirect(request.referrer or url_for("devices.overview"))
@@ -401,7 +401,7 @@ def trust_host_key(device_id: int):
key_type = (request.form.get("key_type") or "").strip() key_type = (request.form.get("key_type") or "").strip()
fingerprint = (request.form.get("fingerprint") or "").strip() fingerprint = (request.form.get("fingerprint") or "").strip()
if not key_type or not ssh.valid_fingerprint(fingerprint): if not key_type or not ssh.valid_fingerprint(fingerprint):
flash("Es wurde kein gueltiger Fingerprint uebergeben.", "danger") flash("Es wurde kein gültiger Fingerprint übergeben.", "danger")
return redirect(url_for("devices.detail", device_id=device_id)) return redirect(url_for("devices.detail", device_id=device_id))
with transaction(): with transaction():
@@ -420,7 +420,7 @@ def trust_host_key(device_id: int):
f"{key_type} {fingerprint}", f"{key_type} {fingerprint}",
severity="notice", severity="notice",
) )
flash("Host-Schluessel freigegeben.", "success") flash("Host-Schlüssel freigegeben.", "success")
return redirect(url_for("devices.detail", device_id=device_id)) return redirect(url_for("devices.detail", device_id=device_id))
@@ -434,5 +434,5 @@ def forget_host_key(device_id: int):
with transaction(): with transaction():
ssh.forget_key(conn, str(device["ip"]), int(device["ssh_port"] or 22)) ssh.forget_key(conn, str(device["ip"]), int(device["ssh_port"] or 22))
_audit(conn, "device.host_key_forgotten", str(device["name"]), severity="warning") _audit(conn, "device.host_key_forgotten", str(device["name"]), severity="warning")
flash("Gespeicherte Host-Schluessel entfernt.", "info") flash("Gespeicherte Host-Schlüssel entfernt.", "info")
return redirect(url_for("devices.detail", device_id=device_id)) return redirect(url_for("devices.detail", device_id=device_id))
+46 -12
View File
@@ -118,7 +118,7 @@ def save_settings():
with transaction(): with transaction():
changed = extension.settings.set_many(conn, values, actor=current_user.username) changed = extension.settings.set_many(conn, values, actor=current_user.username)
_audit("dhcp.settings_saved", detail=", ".join(changed), severity="notice") _audit("dhcp.settings_saved", detail=", ".join(changed), severity="notice")
flash("Einstellungen gespeichert." if changed else "Keine Aenderungen.", "success" if changed else "info") flash("Einstellungen gespeichert." if changed else "Keine Änderungen.", "success" if changed else "info")
return _write_and_redirect() return _write_and_redirect()
@@ -173,6 +173,9 @@ def update_subnet(subnet_id: int):
flash(problem, "danger") flash(problem, "danger")
return redirect(url_for("dhcp.overview")) return redirect(url_for("dhcp.overview"))
# Vor der Aenderung lesen -- danach ist der alte Stand weg, und "Subnetz
# gespeichert" allein sagt nicht, was vorher eingestellt war.
vorher = Database.one(conn, "SELECT * FROM dhcp_subnets WHERE id=?", (subnet_id,))
with transaction(): with transaction():
conn.execute( conn.execute(
"UPDATE dhcp_subnets SET interface=?, range_start=?, range_end=?, gateway=?, dns=?, " "UPDATE dhcp_subnets SET interface=?, range_start=?, range_end=?, gateway=?, dns=?, "
@@ -189,7 +192,26 @@ def update_subnet(subnet_id: int):
subnet_id, subnet_id,
), ),
) )
_audit("dhcp.subnet_updated", str(subnet_id), severity="notice") nachher = Database.one(conn, "SELECT * FROM dhcp_subnets WHERE id=?", (subnet_id,))
_audit(
"dhcp.subnet_updated",
f"{interface} {start}-{end}",
audit.diff(
vorher,
nachher,
felder=(
("interface", "Schnittstelle"),
("range_start", "Von"),
("range_end", "Bis"),
("gateway", "Gateway"),
("dns", "DNS"),
("comment", "Kommentar"),
("enabled", "Aktiv"),
),
)
or "keine inhaltliche Änderung",
severity="notice",
)
flash("Subnetz gespeichert.", "success") flash("Subnetz gespeichert.", "success")
return _write_and_redirect() return _write_and_redirect()
@@ -289,7 +311,7 @@ def set_device_option_value():
value or "(geleert)", value or "(geleert)",
) )
flash( flash(
f"Option {option['name']} fuer {device['name']} " f"Option {option['name']} für {device['name']} "
f"{'gespeichert' if value else 'entfernt'}.", f"{'gespeichert' if value else 'entfernt'}.",
"success", "success",
) )
@@ -336,7 +358,7 @@ def delete_custom_option(option_id: int):
abort(404) abort(404)
if int(option["is_standard"] or 0): if int(option["is_standard"] or 0):
flash( flash(
"Standardoptionen koennen nicht geloescht werden -- nur ihr Wert lasst sich leeren.", "Standardoptionen können nicht gelöscht werden – nur ihr Wert lässt sich leeren.",
"danger", "danger",
) )
return redirect(url_for("dhcp.overview")) return redirect(url_for("dhcp.overview"))
@@ -359,7 +381,7 @@ def add_reservation():
mac = kea.normalize_mac(request.form.get("mac", "")) mac = kea.normalize_mac(request.form.get("mac", ""))
ip = (request.form.get("ip") or "").strip() ip = (request.form.get("ip") or "").strip()
if not mac: if not mac:
flash("Die MAC-Adresse ist ungueltig.", "danger") flash("Die MAC-Adresse ist ungültig.", "danger")
return redirect(url_for("dhcp.overview")) return redirect(url_for("dhcp.overview"))
# Eine Reservierung ausserhalb aller eingerichteten Bereiche liefert Kea nie # Eine Reservierung ausserhalb aller eingerichteten Bereiche liefert Kea nie
@@ -367,7 +389,7 @@ def add_reservation():
# schlechteste der drei Moeglichkeiten -- deshalb hier ablehnen. # schlechteste der drei Moeglichkeiten -- deshalb hier ablehnen.
if not Database.value(conn, "SELECT COUNT(*) FROM dhcp_subnets WHERE enabled=1"): if not Database.value(conn, "SELECT COUNT(*) FROM dhcp_subnets WHERE enabled=1"):
flash( flash(
"Es ist noch kein Bereich eingerichtet. Legen Sie zuerst ein Subnetz an -- " "Es ist noch kein Bereich eingerichtet. Legen Sie zuerst ein Subnetz an – "
"ohne eines wird keine Reservierung ausgeliefert.", "ohne eines wird keine Reservierung ausgeliefert.",
"danger", "danger",
) )
@@ -380,7 +402,7 @@ def add_reservation():
# keine Reservierung mehr moeglich. Stattdessen benennen, was ungeprueft # keine Reservierung mehr moeglich. Stattdessen benennen, was ungeprueft
# bleibt. # bleibt.
flash( flash(
"Der Bereich konnte nicht geprueft werden -- ohne Systemhelfer ist das Netz " "Der Bereich konnte nicht geprüft werden – ohne Systemhelfer ist das Netz "
"dieses Servers nicht feststellbar.", "dieses Servers nicht feststellbar.",
"warning", "warning",
) )
@@ -390,11 +412,12 @@ def add_reservation():
bereiche = ", ".join(str(netz) for netz in netze) bereiche = ", ".join(str(netz) for netz in netze)
flash( flash(
f"{ip or 'Diese Adresse'} liegt in keinem eingerichteten Bereich " f"{ip or 'Diese Adresse'} liegt in keinem eingerichteten Bereich "
f"({bereiche}). Die Reservierung waere ohne Wirkung.", f"({bereiche}). Die Reservierung wäre ohne Wirkung.",
"danger", "danger",
) )
return redirect(url_for("dhcp.overview")) return redirect(url_for("dhcp.overview"))
vorher = Database.one(conn, "SELECT * FROM dhcp_reservations WHERE mac=?", (mac,))
with transaction(): with transaction():
conn.execute( conn.execute(
"INSERT INTO dhcp_reservations (mac, ip, hostname, comment, created_at) " "INSERT INTO dhcp_reservations (mac, ip, hostname, comment, created_at) "
@@ -407,7 +430,18 @@ def add_reservation():
(request.form.get("comment") or "").strip(), (request.form.get("comment") or "").strip(),
), ),
) )
_audit("dhcp.reservation_saved", f"{mac} -> {ip}", severity="notice") nachher = Database.one(conn, "SELECT * FROM dhcp_reservations WHERE mac=?", (mac,))
_audit(
"dhcp.reservation_saved",
mac,
audit.diff(
vorher,
nachher,
felder=(("ip", "IP"), ("hostname", "Hostname"), ("comment", "Kommentar")),
)
or f"neu angelegt: {ip}",
severity="notice",
)
flash("Reservierung gespeichert.", "success") flash("Reservierung gespeichert.", "success")
return _write_and_redirect() return _write_and_redirect()
@@ -437,14 +471,14 @@ def _write_config() -> dict[str, Any]:
sysops = extension.sysops sysops = extension.sysops
conn = db() conn = db()
if sysops is None or not sysops.available: if sysops is None or not sysops.available:
return {"ok": False, "message": "Systemhelfer nicht verfuegbar."} return {"ok": False, "message": "Systemhelfer nicht verfügbar."}
networks = kea.detect_networks(sysops) networks = kea.detect_networks(sysops)
config, warnings = kea.render_config(conn, networks=networks, settings=_settings_values()) config, warnings = kea.render_config(conn, networks=networks, settings=_settings_values())
if not config["Dhcp4"]["subnet4"]: if not config["Dhcp4"]["subnet4"]:
return { return {
"ok": False, "ok": False,
"message": "Es ist kein gueltiges Subnetz konfiguriert -- die Datei wurde nicht geschrieben.", "message": "Es ist kein gültiges Subnetz konfiguriert – die Datei wurde nicht geschrieben.",
"warnings": warnings, "warnings": warnings,
} }
result = sysops.write_kea_config(kea.config_to_text(config)) result = sysops.write_kea_config(kea.config_to_text(config))
@@ -495,7 +529,7 @@ def service_action(action: str):
extension = core() extension = core()
sysops = extension.sysops sysops = extension.sysops
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("dhcp.overview")) return redirect(url_for("dhcp.overview"))
if action == "install": if action == "install":
+152 -18
View File
@@ -2,7 +2,10 @@
from __future__ import annotations from __future__ import annotations
import hashlib
import io import io
import json
from datetime import datetime, timezone
from flask import ( from flask import (
Blueprint, Blueprint,
@@ -47,8 +50,33 @@ def _audit(action: str, target: str, detail: str = "", severity: str = "info") -
) )
def _unterschriften(roh: str) -> list[str]:
"""Die Namen aus dem Unterschriftsfeld -- Text, keine Struktur.
Was hier ankommt, hat der Browser geschickt und damit der Mensch davor.
Es wird protokolliert, nicht geglaubt: laenge begrenzt, Zeilenumbrueche
raus, mehr passiert damit nicht.
"""
namen: list[str] = []
try:
eintraege = json.loads(roh) if roh else []
except ValueError:
return []
if not isinstance(eintraege, list):
return []
for eintrag in eintraege[:20]:
name = str(eintrag if isinstance(eintrag, str) else eintrag.get("name", ""))
name = " ".join(name.split())[:120]
if name:
namen.append(name)
return namen
def _mount_or_404(token: str) -> service.Mount: def _mount_or_404(token: str) -> service.Mount:
mounts = service.active_mounts(db(), current_user.id) conn = db()
mounts = service.doppelte_zusammenfassen(
conn, service.active_mounts(conn, current_user.id), service.aktuelle_sitzung()
)
for mount in mounts: for mount in mounts:
if mount.token == token: if mount.token == token:
return mount return mount
@@ -59,7 +87,9 @@ def _mount_or_404(token: str) -> service.Mount:
@permission_required("fileshare.view") @permission_required("fileshare.view")
def browse(): def browse():
conn = db() conn = db()
mounts = service.active_mounts(conn, current_user.id) mounts = service.doppelte_zusammenfassen(
conn, service.active_mounts(conn, current_user.id), service.aktuelle_sitzung()
)
token = request.args.get("share") or (mounts[0].token if mounts else "") token = request.args.get("share") or (mounts[0].token if mounts else "")
relative = request.args.get("path") or "" relative = request.args.get("path") or ""
listing = None listing = None
@@ -82,6 +112,10 @@ def browse():
max_upload_mb=uploads.limit_mb(conn), max_upload_mb=uploads.limit_mb(conn),
# Vorschlagsliste fuer "Ausgefuellt speichern": wohin darf es gehen? # Vorschlagsliste fuer "Ausgefuellt speichern": wohin darf es gehen?
verzeichnisse=(service.all_directories(mount) if listing else []), verzeichnisse=(service.all_directories(mount) if listing else []),
signature_witness=bool(core().settings.get(conn, "signature_witness_enabled")),
# Die Uhr des Servers. Das Datum unter einer Unterschrift soll nicht
# davon abhaengen, wie das Tablet gestellt ist.
serverzeit=datetime.now(timezone.utc).astimezone().isoformat(timespec="seconds"),
) )
@@ -159,7 +193,7 @@ def upload(token: str):
relative = request.form.get("path") or "" relative = request.form.get("path") or ""
files = request.files.getlist("files") files = request.files.getlist("files")
if not files: if not files:
flash("Keine Datei ausgewaehlt.", "danger") flash("Keine Datei ausgewählt.", "danger")
return redirect(url_for("fileshare.browse", share=token, path=relative)) return redirect(url_for("fileshare.browse", share=token, path=relative))
limit = uploads.limit_bytes(db()) limit = uploads.limit_bytes(db())
@@ -172,7 +206,7 @@ def upload(token: str):
name = service.safe_filename(upload_file.filename) name = service.safe_filename(upload_file.filename)
data = upload_file.stream.read(limit + 1) data = upload_file.stream.read(limit + 1)
if len(data) > limit: if len(data) > limit:
flash(f"{name}: zu gross (Grenze {limit // 1024 // 1024} MB).", "danger") flash(f"{name}: zu groß (Grenze {limit // 1024 // 1024} MB).", "danger")
continue continue
(target_dir / name).write_bytes(data) (target_dir / name).write_bytes(data)
stored += 1 stored += 1
@@ -186,6 +220,37 @@ def upload(token: str):
return redirect(url_for("fileshare.browse", share=token, path=relative)) return redirect(url_for("fileshare.browse", share=token, path=relative))
@bp.get("/<token>/signature")
@permission_required("fileshare.view")
def signature_state(token: str):
"""Passt die Datei noch zu ihrem Eintrag im Aenderungsprotokoll?
Die Vorschau fragt das beim Oeffnen. Eine unterschriebene Fassung, die
seither veraendert wurde, sieht man einer PDF sonst nicht an -- und genau
das ist die Frage, die man sich vor einer Unterschrift stellt.
"""
mount = _mount_or_404(token)
try:
return service.signaturstand(db(), mount, request.args.get("path") or "")
except service.FileshareError as exc:
return {"error": str(exc)}, 400
@bp.get("/<token>/folders")
@permission_required("fileshare.view")
def folders(token: str):
"""Die Ordner **dieser** Freigabe als Vorschlagsliste.
Gebraucht, seit das Speichern in eine andere Freigabe gehen darf: die
Vorschlaege muessen der gewaehlten Freigabe folgen. Alle Listen beim
Seitenaufbau mitzuschicken waere die Alternative gewesen -- bei vier
eingebundenen Freigaben mit je ein paar hundert Ordnern ist das eine
viertel Megabyte, die fast nie gebraucht wird.
"""
mount = _mount_or_404(token)
return {"folders": service.all_directories(mount)}
@bp.post("/<token>/form") @bp.post("/<token>/form")
@permission_required("fileshare_pdf.create") @permission_required("fileshare_pdf.create")
def save_form(token: str): def save_form(token: str):
@@ -196,15 +261,34 @@ def save_form(token: str):
die alte Datei danach loeschen -- das ist eine bewusste Handlung. die alte Datei danach loeschen -- das ist eine bewusste Handlung.
""" """
mount = _mount_or_404(token) mount = _mount_or_404(token)
# Das Ziel darf eine andere Freigabe sein: die Vorlage liegt oft in einer
# Vorlagenfreigabe, die ausgefuellte Fassung gehoert in die Ablage des
# Bereichs. Geprueft wird es wie jede andere Einbindung -- nur eine, die
# diesem Benutzer gerade gehoert, kommt in Frage.
ziel_token = (request.form.get("ziel") or "").strip()
ziel_mount = _mount_or_404(ziel_token) if ziel_token else mount
relative = request.form.get("path") or "" relative = request.form.get("path") or ""
upload_file = request.files.get("datei") upload_file = request.files.get("datei")
if upload_file is None or not upload_file.filename: if upload_file is None or not upload_file.filename:
return {"error": "Es kam keine Datei an."}, 400 return {"error": "Es kam keine Datei an."}, 400
# Ein unterschriebenes Dokument ist abgeschlossen. Es als Vorlage fuer eine
# neue Fassung zu benutzen hiesse, eine Unterschrift unter einen anderen
# Text zu stellen -- die Oberflaeche blendet das aus, hier wird es
# abgewiesen.
if service.signaturstand(db(), mount, relative).get("status") in {
"unveraendert",
"veraendert",
}:
return {
"error": "Dieses Dokument ist unterschrieben und damit abgeschlossen. "
"Legen Sie eine neue Fassung aus der ursprünglichen Vorlage an."
}, 409
grenze = uploads.limit_bytes(db()) grenze = uploads.limit_bytes(db())
daten = upload_file.stream.read(grenze + 1) daten = upload_file.stream.read(grenze + 1)
if len(daten) > grenze: if len(daten) > grenze:
return {"error": f"Zu gross (Grenze {grenze // 1024 // 1024} MB)."}, 413 return {"error": f"Zu groß (Grenze {grenze // 1024 // 1024} MB)."}, 413
if not daten.startswith(b"%PDF-"): if not daten.startswith(b"%PDF-"):
# Diese Route nimmt nur, was pdf.js hier ablegt. Ein anderer Inhalt # Diese Route nimmt nur, was pdf.js hier ablegt. Ein anderer Inhalt
# waere ein Umweg, um beliebige Dateien in die Freigabe zu bekommen. # waere ein Umweg, um beliebige Dateien in die Freigabe zu bekommen.
@@ -217,8 +301,8 @@ def save_form(token: str):
# niemandem weiter. # niemandem weiter.
if service.hat_formularfelder(daten) and not current_user.has("fileshare_pdf.edit"): if service.hat_formularfelder(daten) and not current_user.has("fileshare_pdf.edit"):
return { return {
"error": "Sie duerfen ausgefuellte Formulare nur abgeschlossen speichern " "error": "Sie dürfen ausgefüllte Formulare nur abgeschlossen speichern "
"(Felder gesperrt). Bitte im Auswahlfeld 'Abgeschlossen' waehlen." "(Felder gesperrt). Bitte im Auswahlfeld 'Abgeschlossen' wählen."
}, 403 }, 403
# Wohin und unter welchem Namen -- beides darf der Benutzer bestimmen. # Wohin und unter welchem Namen -- beides darf der Benutzer bestimmen.
@@ -228,15 +312,22 @@ def save_form(token: str):
try: try:
original = service.resolve_path(mount, relative) original = service.resolve_path(mount, relative)
# Ohne Namen landet die Datei neben dem Original -- das geht nur in
# derselben Freigabe.
if ziel_mount.token != mount.token and not wunschname:
return {"error": "Für eine andere Freigabe braucht es einen Dateinamen."}, 400
# Mit Namen entscheidet das Ordnerfeld -- leer heisst Wurzelverzeichnis, # Mit Namen entscheidet das Ordnerfeld -- leer heisst Wurzelverzeichnis,
# so wie es im Feld steht. Ohne Namen (Aufruf ohne Oberflaeche) bleibt # so wie es im Feld steht. Ohne Namen (Aufruf ohne Oberflaeche) bleibt
# es beim Ordner des Originals. # es beim Ordner des Originals.
if wunschname: if wunschname:
zielordner = service.resolve_path(mount, ordner) zielordner = service.resolve_path(ziel_mount, ordner)
else: else:
zielordner = service.resolve_path(mount, ordner) if ordner else original.parent zielordner = service.resolve_path(mount, ordner) if ordner else original.parent
if not zielordner.is_dir(): if not zielordner.is_dir():
return {"error": f"Den Ordner '{ordner}' gibt es in dieser Freigabe nicht."}, 400 return {
"error": f"Den Ordner '{ordner}' gibt es in der Freigabe "
f"'{ziel_mount.label}' nicht."
}, 400
if wunschname: if wunschname:
# Eingegeben wird der Name ohne Endung; die kommt von hier. Wer # Eingegeben wird der Name ohne Endung; die kommt von hier. Wer
# sie mittippt, bekommt sie nicht doppelt -- und wer eine falsche # sie mittippt, bekommt sie nicht doppelt -- und wer eine falsche
@@ -247,8 +338,8 @@ def save_form(token: str):
ziel = zielordner / name ziel = zielordner / name
if ziel.exists() and not ersetzen: if ziel.exists() and not ersetzen:
return { return {
"error": f"'{name}' gibt es dort schon. Anderen Namen waehlen oder " "error": f"In '{ziel_mount.label}' gibt es '{name}' schon. Anderen "
"'Vorhandene ersetzen' ankreuzen." "Namen wählen oder 'Vorhandene ersetzen' ankreuzen."
}, 409 }, 409
else: else:
# Ohne Angabe wie bisher: neben das Original, ohne etwas zu ueberschreiben. # Ohne Angabe wie bisher: neben das Original, ohne etwas zu ueberschreiben.
@@ -257,9 +348,52 @@ def save_form(token: str):
except service.FileshareError as exc: except service.FileshareError as exc:
return {"error": str(exc)}, 400 return {"error": str(exc)}, 400
wohin = ziel.relative_to(mount.path.resolve()).as_posix() wohin = ziel.relative_to(ziel_mount.path.resolve()).as_posix()
_audit("fileshare.form_saved", f"{mount.label}:{relative}", wohin, severity="notice") pruefsumme = hashlib.sha256(daten).hexdigest()
return {"message": f"Gespeichert als {wohin}.", "path": wohin} unterschriften = _unterschriften(request.form.get("unterschriften") or "")
# Zwei Angaben, die man nicht verwechseln darf, und deshalb stehen beide
# da: der Name **in** der Unterschrift ist eine Angabe des
# Unterschreibenden -- wie auf Papier. Wer die Datei abgelegt hat, steht
# ohnehin als Handelnder in der Zeile und ist belegt.
# Zwei Zeilen: oben, was ein Mensch liest; unten die Pruefsumme, die
# niemand liest, aber jeder vergleichen koennen muss. In einer einzigen
# Zeile drueckte der Hash den Rest aus der Spalte.
kopf = f"{'Signiert' if unterschriften else 'Gespeichert'}: {wohin}"
if unterschriften:
kopf += f" von: {', '.join(unterschriften)}"
beschreibung = f"{kopf}{chr(10)}sha256={pruefsumme}"
_audit(
"fileshare.form_signed" if unterschriften else "fileshare.form_saved",
f"{mount.label}:{relative}",
beschreibung,
severity="notice",
)
if unterschriften:
# Zusaetzlich ins Signaturverzeichnis: das Protokoll wird irgendwann
# archiviert, die Frage "ist diese Datei noch die unterschriebene?"
# stellt sich aber noch Jahre spaeter. Und mit fsync -- die Datei liegt
# schon auf der Freigabe; ein Stromausfall soll nicht den Nachweis
# dazu verschlucken.
verbindung = db()
with core().database.dauerhaft(verbindung), transaction():
service.signatur_merken(
verbindung,
sha256=pruefsumme,
label=ziel_mount.label,
pfad=wohin,
actor=current_user.username,
namen=unterschriften,
)
# Zweites Exemplar ausserhalb der Datenbank.
service.signatur_journalisieren(
sha256=pruefsumme,
label=ziel_mount.label,
pfad=wohin,
actor=current_user.username,
namen=unterschriften,
)
return {"message": f"Gespeichert als {wohin}.", "path": wohin, "sha256": pruefsumme}
@bp.post("/<token>/folder") @bp.post("/<token>/folder")
@@ -307,7 +441,7 @@ def delete(token: str):
except (service.FileshareError, OSError) as exc: except (service.FileshareError, OSError) as exc:
flash(str(exc), "danger") flash(str(exc), "danger")
if removed: if removed:
flash(f"{removed} Eintrag/Eintraege geloescht.", "success") flash(f"{removed} Eintrag/Einträge gelöscht.", "success")
return redirect(url_for("fileshare.browse", share=token, path=parent)) return redirect(url_for("fileshare.browse", share=token, path=parent))
@@ -318,7 +452,7 @@ def release(token: str):
with transaction() as conn: with transaction() as conn:
service.release_mount(conn, mount.id) service.release_mount(conn, mount.id)
_audit("fileshare.released", mount.label) _audit("fileshare.released", mount.label)
flash("Einbindung geloest.", "success") flash("Einbindung gelöst.", "success")
return redirect(url_for("fileshare.browse")) return redirect(url_for("fileshare.browse"))
@@ -392,10 +526,10 @@ def mapping_update(mapping_id: int):
_audit( _audit(
"fileshare.mapping_updated", "fileshare.mapping_updated",
str((nachher or {}).get("label") or request.form.get("label", "")), str((nachher or {}).get("label") or request.form.get("label", "")),
service.mapping_changes(vorher, nachher) or "keine inhaltliche Aenderung", service.mapping_changes(vorher, nachher) or "keine inhaltliche Änderung",
severity="notice", severity="notice",
) )
flash("Zuordnung geaendert.", "success") flash("Zuordnung geändert.", "success")
except service.FileshareError as exc: except service.FileshareError as exc:
flash(str(exc), "danger") flash(str(exc), "danger")
return redirect(url_for("fileshare.mappings")) return redirect(url_for("fileshare.mappings"))
+6 -6
View File
@@ -181,7 +181,7 @@ def trust(kind: str, target_id: int):
key_type = (request.form.get("key_type") or "").strip() key_type = (request.form.get("key_type") or "").strip()
fingerprint = (request.form.get("fingerprint") or "").strip() fingerprint = (request.form.get("fingerprint") or "").strip()
if not key_type or not ssh.valid_fingerprint(fingerprint): if not key_type or not ssh.valid_fingerprint(fingerprint):
flash("Es wurde kein gueltiger Fingerprint uebergeben.", "danger") flash("Es wurde kein gültiger Fingerprint übergeben.", "danger")
return redirect(url_for("hostkeys.overview")) return redirect(url_for("hostkeys.overview"))
with transaction(): with transaction():
@@ -199,7 +199,7 @@ def trust(kind: str, target_id: int):
f"{key_type} {fingerprint}", f"{key_type} {fingerprint}",
severity="notice", severity="notice",
) )
flash(f"Host-Schluessel von {target['label']} freigegeben.", "success") flash(f"Host-Schlüssel von {target['label']} freigegeben.", "success")
return redirect(url_for("hostkeys.overview")) return redirect(url_for("hostkeys.overview"))
@@ -218,7 +218,7 @@ def forget(kind: str, target_id: int):
target["label"], target["label"],
severity="warning", severity="warning",
) )
flash(f"Gespeicherte Schluessel von {target['label']} entfernt.", "info") flash(f"Gespeicherte Schlüssel von {target['label']} entfernt.", "info")
return redirect(url_for("hostkeys.overview")) return redirect(url_for("hostkeys.overview"))
@@ -232,18 +232,18 @@ def forget_orphan():
except ValueError: except ValueError:
port = 0 port = 0
if not host or not 1 <= port <= 65535: if not host or not 1 <= port <= 65535:
flash("Ungueltiges Ziel.", "danger") flash("Ungültiges Ziel.", "danger")
return redirect(url_for("hostkeys.overview")) return redirect(url_for("hostkeys.overview"))
conn = db() conn = db()
if (host, port) in {(row["host"], row["port"]) for row in _targets(conn)}: if (host, port) in {(row["host"], row["port"]) for row in _targets(conn)}:
# Ein Ziel, das es noch gibt, wird ueber seine eigene Zeile verworfen -- # Ein Ziel, das es noch gibt, wird ueber seine eigene Zeile verworfen --
# sonst umginge dieser Weg die Rechtepruefung je Objektart. # sonst umginge dieser Weg die Rechtepruefung je Objektart.
flash("Zu diesem Eintrag gibt es ein Ziel -- bitte dort verwerfen.", "warning") flash("Zu diesem Eintrag gibt es ein Ziel – bitte dort verwerfen.", "warning")
return redirect(url_for("hostkeys.overview")) return redirect(url_for("hostkeys.overview"))
with transaction(): with transaction():
ssh.forget_key(conn, host, port) ssh.forget_key(conn, host, port)
_audit("host_key.orphan_forgotten", f"{host}:{port}", severity="warning") _audit("host_key.orphan_forgotten", f"{host}:{port}", severity="warning")
flash(f"Verwaiste Freigabe fuer {host}:{port} entfernt.", "info") flash(f"Verwaiste Freigabe für {host}:{port} entfernt.", "info")
return redirect(url_for("hostkeys.overview")) return redirect(url_for("hostkeys.overview"))
+6 -6
View File
@@ -80,11 +80,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("license.overview")) return redirect(url_for("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("license.overview")) return redirect(url_for("license.overview"))
allow_new_issuer = request.form.get("allow_new_issuer") == "1" and current_user.is_admin allow_new_issuer = request.form.get("allow_new_issuer") == "1" and current_user.is_admin
@@ -104,7 +104,7 @@ def upload():
if state.status and state.status.role != "customer": if state.status and state.status.role != "customer":
flash( flash(
f"Achtung: diese Lizenz hat die Rolle {state.status.role!r}. " f"Achtung: diese Lizenz hat die Rolle {state.status.role!r}. "
"In einer TESM-Kundeninstanz ist das ungewoehnlich.", "In einer TESM-Kundeninstanz ist das ungewöhnlich.",
"warning", "warning",
) )
return redirect(url_for("license.overview")) return redirect(url_for("license.overview"))
@@ -214,9 +214,9 @@ def server_certificate():
severity="notice", severity="notice",
) )
flash( flash(
"Zertifikat hinterlegt. Der naechste Versuch laeuft wieder ueber den Online-Weg." "Zertifikat hinterlegt. Der nächste Versuch läuft wieder über den Online-Weg."
if pem if pem
else "Zertifikat entfernt -- es gelten wieder die Systemanker.", else "Zertifikat entfernt – es gelten wieder die Systemanker.",
"success", "success",
) )
return redirect(url_for("license.overview")) return redirect(url_for("license.overview"))
@@ -246,5 +246,5 @@ def remove_trusted_key(key_id: str):
if manager is not None: if manager is not None:
with core().database.session() as conn: with core().database.session() as conn:
manager.reload(conn) manager.reload(conn)
flash("Aussteller-Schluessel entfernt.", "info") flash("Aussteller-Schlüssel entfernt.", "info")
return redirect(url_for("license.overview")) return redirect(url_for("license.overview"))
+1 -1
View File
@@ -34,7 +34,7 @@ def dhcp():
else: else:
error = result.message error = result.message
else: else:
error = "Systemhelfer nicht verfuegbar." error = "Systemhelfer nicht verfügbar."
return render_template("tesm/logs_kea.html", lines=lines, path=kea.LOG_PATH, error=error) return render_template("tesm/logs_kea.html", lines=lines, path=kea.LOG_PATH, error=error)
+5 -5
View File
@@ -116,7 +116,7 @@ def install_windows_tool():
""" """
sysops = core().sysops sysops = core().sysops
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("maintenance.overview")) return redirect(url_for("maintenance.overview"))
result = sysops.apt_install(windowsops.REQUIRED_PACKAGE) result = sysops.apt_install(windowsops.REQUIRED_PACKAGE)
@@ -191,7 +191,7 @@ def _start(kind: str) -> None:
conn = db() conn = db()
device_ids = [int(value) for value in request.form.getlist("device_ids") if value.isdigit()] device_ids = [int(value) for value in request.form.getlist("device_ids") if value.isdigit()]
if not device_ids: if not device_ids:
flash("Es wurde kein Geraet ausgewaehlt.", "warning") flash("Es wurde kein Gerät ausgewählt.", "warning")
return return
eligible = {int(row["id"]): row for row in _eligible_devices(conn)} eligible = {int(row["id"]): row for row in _eligible_devices(conn)}
@@ -204,8 +204,8 @@ def _start(kind: str) -> None:
# zurueck, der nie gestartet wird und ewig auf "wartet" steht. # zurueck, der nie gestartet wird und ewig auf "wartet" steht.
if kind == service.JOB_KIND_UPDATE and str(device["category"]) != "linux": if kind == service.JOB_KIND_UPDATE and str(device["category"]) != "linux":
flash( flash(
f"{device['name']}: Paketaktualisierung gibt es nur fuer Linux-Clients. " f"{device['name']}: Paketaktualisierung gibt es nur für Linux-Clients. "
"Windows-Geraete lassen sich hier nur neu starten.", "Windows-Geräte lassen sich hier nur neu starten.",
"warning", "warning",
) )
continue continue
@@ -273,7 +273,7 @@ def _start(kind: str) -> None:
started += 1 started += 1
if started: if started:
flash(f"{started} Auftrag/Auftraege gestartet.", "success") flash(f"{started} Auftrag/Aufträge gestartet.", "success")
@bp.post("/update") @bp.post("/update")
+7 -5
View File
@@ -82,6 +82,7 @@ def system():
"terminal_switches_enabled", "terminal_switches_enabled",
"terminal_clients_enabled", "terminal_clients_enabled",
"terminal_idle_timeout", "terminal_idle_timeout",
"signature_witness_enabled",
"trash_retention_days", "trash_retention_days",
"session_idle_minutes", "session_idle_minutes",
"session_absolute_hours", "session_absolute_hours",
@@ -116,6 +117,7 @@ def system_save():
"terminal_switches_enabled", "terminal_switches_enabled",
"terminal_clients_enabled", "terminal_clients_enabled",
"terminal_idle_timeout", "terminal_idle_timeout",
"signature_witness_enabled",
"trash_retention_days", "trash_retention_days",
"session_idle_minutes", "session_idle_minutes",
"session_absolute_hours", "session_absolute_hours",
@@ -158,10 +160,10 @@ def system_save():
if changed: if changed:
message = "Einstellungen gespeichert." message = "Einstellungen gespeichert."
if interval_changed: if interval_changed:
message += " Das Pruefintervall greift beim naechsten Durchlauf." message += " Das Prüfintervall greift beim nächsten Durchlauf."
flash(message, "success") flash(message, "success")
else: else:
flash("Keine Aenderungen.", "info") flash("Keine Änderungen.", "info")
return redirect(url_for("settings.system")) return redirect(url_for("settings.system"))
@@ -172,10 +174,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:
@@ -198,7 +200,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:
+6 -6
View File
@@ -71,7 +71,7 @@ def create():
f"IP {request.form.get('ip')}", f"IP {request.form.get('ip')}",
severity="notice", severity="notice",
) )
flash("Switch angelegt. Der Host-Schluessel muss vor dem ersten Zugriff freigegeben werden.", "success") flash("Switch angelegt. Der Host-Schlüssel muss vor dem ersten Zugriff freigegeben werden.", "success")
return redirect(url_for("switches.detail", switch_id=switch_id)) return redirect(url_for("switches.detail", switch_id=switch_id))
except inventory.ValidationError as exc: except inventory.ValidationError as exc:
flash(str(exc), "danger") flash(str(exc), "danger")
@@ -92,7 +92,7 @@ def update(switch_id: int):
"; ".join(changes), "; ".join(changes),
severity="notice", severity="notice",
) )
flash("Aenderungen gespeichert." if changes else "Keine Aenderungen.", "success" if changes else "info") flash("Änderungen gespeichert." if changes else "Keine Änderungen.", "success" if changes else "info")
except inventory.ValidationError as exc: except inventory.ValidationError as exc:
flash(str(exc), "danger") flash(str(exc), "danger")
return redirect(request.referrer or url_for("switches.overview")) return redirect(request.referrer or url_for("switches.overview"))
@@ -192,7 +192,7 @@ def trust_host_key(switch_id: int):
key_type = (request.form.get("key_type") or "").strip() key_type = (request.form.get("key_type") or "").strip()
fingerprint = (request.form.get("fingerprint") or "").strip() fingerprint = (request.form.get("fingerprint") or "").strip()
if not key_type or not ssh.valid_fingerprint(fingerprint): if not key_type or not ssh.valid_fingerprint(fingerprint):
flash("Es wurde kein gueltiger Fingerprint uebergeben.", "danger") flash("Es wurde kein gültiger Fingerprint übergeben.", "danger")
return redirect(url_for("switches.detail", switch_id=switch_id)) return redirect(url_for("switches.detail", switch_id=switch_id))
with transaction(): with transaction():
@@ -211,7 +211,7 @@ def trust_host_key(switch_id: int):
f"{key_type} {fingerprint}", f"{key_type} {fingerprint}",
severity="notice", severity="notice",
) )
flash("Host-Schluessel freigegeben.", "success") flash("Host-Schlüssel freigegeben.", "success")
return redirect(url_for("switches.detail", switch_id=switch_id)) return redirect(url_for("switches.detail", switch_id=switch_id))
@@ -225,7 +225,7 @@ def forget_host_key(switch_id: int):
with transaction(): with transaction():
ssh.forget_key(conn, str(switch["ip"]), int(switch["ssh_port"])) ssh.forget_key(conn, str(switch["ip"]), int(switch["ssh_port"]))
_audit(conn, "switch.host_key_forgotten", str(switch["hostname"]), severity="warning") _audit(conn, "switch.host_key_forgotten", str(switch["hostname"]), severity="warning")
flash("Gespeicherte Host-Schluessel entfernt.", "info") flash("Gespeicherte Host-Schlüssel entfernt.", "info")
return redirect(url_for("switches.detail", switch_id=switch_id)) return redirect(url_for("switches.detail", switch_id=switch_id))
@@ -256,7 +256,7 @@ def run_command(switch_id: int):
flash("Dieses Kommando steht nicht auf der Positivliste.", "danger") flash("Dieses Kommando steht nicht auf der Positivliste.", "danger")
return redirect(url_for("switches.detail", switch_id=switch_id)) return redirect(url_for("switches.detail", switch_id=switch_id))
if not switch["credential_id"]: if not switch["credential_id"]:
flash("Fuer diesen Switch sind keine Zugangsdaten hinterlegt.", "danger") flash("Für diesen Switch sind keine Zugangsdaten hinterlegt.", "danger")
return redirect(url_for("switches.detail", switch_id=switch_id)) return redirect(url_for("switches.detail", switch_id=switch_id))
try: try:
+10 -10
View File
@@ -74,7 +74,7 @@ CLIENT_COMMANDS: tuple[tuple[str, str], ...] = (
("ip -brief address", "Netzwerkadressen"), ("ip -brief address", "Netzwerkadressen"),
("systemctl --failed", "Fehlgeschlagene Dienste"), ("systemctl --failed", "Fehlgeschlagene Dienste"),
("journalctl -p err -n 50 --no-pager", "Letzte Fehler im Journal"), ("journalctl -p err -n 50 --no-pager", "Letzte Fehler im Journal"),
("apt list --upgradable 2>/dev/null", "Verfuegbare Aktualisierungen"), ("apt list --upgradable 2>/dev/null", "Verfügbare Aktualisierungen"),
("uname -a", "Kernel und Architektur"), ("uname -a", "Kernel und Architektur"),
) )
@@ -154,7 +154,7 @@ def _render_console(target: Target): # type: ignore[no-untyped-def]
if not _konsole_erlaubt(conn, target.kind): if not _konsole_erlaubt(conn, target.kind):
abort(404) abort(404)
if not target.credential_id: if not target.credential_id:
abort(409, description="Fuer dieses Ziel sind keine Zugangsdaten hinterlegt.") abort(409, description="Für dieses Ziel sind keine Zugangsdaten hinterlegt.")
return render_template( return render_template(
"tesm/console.html", "tesm/console.html",
@@ -195,7 +195,7 @@ def init_app(app) -> None: # type: ignore[no-untyped-def]
try: try:
from flask_sock import Sock from flask_sock import Sock
except ImportError: # pragma: no cover - optionale Abhaengigkeit except ImportError: # pragma: no cover - optionale Abhaengigkeit
logger.info("flask-sock ist nicht installiert -- die Konsole bleibt deaktiviert.") logger.info("flask-sock ist nicht installiert – die Konsole bleibt deaktiviert.")
return return
sock = Sock(app) sock = Sock(app)
@@ -227,16 +227,16 @@ def _handle_socket(ws, kind: str, target_id: int) -> None: # type: ignore[no-un
# WebSocket umgeht Dekoratoren, also stehen sie hier ausdruecklich. # WebSocket umgeht Dekoratoren, also stehen sie hier ausdruecklich.
required = "switches.execute" if kind == KIND_SWITCH else "maintenance.execute" required = "switches.execute" if kind == KIND_SWITCH else "maintenance.execute"
if not current_user.is_authenticated or not current_user.has(required): if not current_user.is_authenticated or not current_user.has(required):
_send(ws, "error", "Keine Berechtigung fuer diese Konsole.") _send(ws, "error", "Keine Berechtigung für diese Konsole.")
return return
if not reauth_is_fresh(): if not reauth_is_fresh():
_send(ws, "error", "Die Passwortbestaetigung ist abgelaufen. Bitte Seite neu laden.") _send(ws, "error", "Die Passwortbestätigung ist abgelaufen. Bitte Seite neu laden.")
return return
if not _konsole_erlaubt(conn, kind): if not _konsole_erlaubt(conn, kind):
_send(ws, "error", "Die Konsole ist in dieser Installation abgeschaltet.") _send(ws, "error", "Die Konsole ist in dieser Installation abgeschaltet.")
return return
if kind == KIND_DEVICE and not _client_module_active(): if kind == KIND_DEVICE and not _client_module_active():
_send(ws, "error", "Die Client-Konsole gehoert zum Modul Wartung -- es ist nicht aktiv.") _send(ws, "error", "Die Client-Konsole gehört zum Modul Wartung – es ist nicht aktiv.")
return return
target = _load_target(conn, kind, target_id) target = _load_target(conn, kind, target_id)
@@ -293,8 +293,8 @@ def _handle_socket(ws, kind: str, target_id: int) -> None: # type: ignore[no-un
_send( _send(
ws, ws,
"error", "error",
f"{exc} Die Konsole akzeptiert keine unbekannten Schluessel -- bitte auf der " f"{exc} Die Konsole akzeptiert keine unbekannten Schlüssel – bitte auf der "
f"{page} ausdruecklich freigeben.", f"{page} ausdrücklich freigeben.",
) )
except ssh.SshError as exc: except ssh.SshError as exc:
_send(ws, "error", str(exc)) _send(ws, "error", str(exc))
@@ -319,7 +319,7 @@ def _read_command(ws, idle_timeout: int) -> str | None: # type: ignore[no-untyp
"""Naechstes Kommando, oder ``None`` wenn die Sitzung enden soll.""" """Naechstes Kommando, oder ``None`` wenn die Sitzung enden soll."""
raw = ws.receive(timeout=idle_timeout) raw = ws.receive(timeout=idle_timeout)
if raw is None: if raw is None:
_send(ws, "info", "Verbindung wegen Untaetigkeit getrennt.") _send(ws, "info", "Verbindung wegen Untätigkeit getrennt.")
return None return None
try: try:
message = json.loads(raw) message = json.loads(raw)
@@ -407,6 +407,6 @@ def _run_client_session( # type: ignore[no-untyped-def]
_send( _send(
ws, ws,
"error", "error",
f"Rueckgabewert {result['status']} nach {result['duration_ms']} ms.", f"Rückgabewert {result['status']} nach {result['duration_ms']} ms.",
) )
return commands return commands
+52 -24
View File
@@ -39,7 +39,7 @@ def cmd_create_admin(args: argparse.Namespace) -> int:
if not password: if not password:
password = getpass.getpass("Passwort: ") password = getpass.getpass("Passwort: ")
if password != getpass.getpass("Passwort wiederholen: "): if 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)
@@ -75,7 +75,7 @@ def cmd_create_admin(args: argparse.Namespace) -> int:
actor_id=user_id, actor_id=user_id,
action="user.admin_created", action="user.admin_created",
target=username, target=username,
detail=f"ueber die Kommandozeile {action}", detail=f"über die Kommandozeile {action}",
severity="warning", severity="warning",
) )
print(f"Administrator {username!r} {action}.") print(f"Administrator {username!r} {action}.")
@@ -133,7 +133,7 @@ def cmd_status(args: argparse.Namespace) -> int:
print( print(
"Audit-Kette : " "Audit-Kette : "
+ ("unversehrt" if chain["ok"] else f"GEBROCHEN bei Eintrag {chain['broken_at']}") + ("unversehrt" if chain["ok"] else f"GEBROCHEN bei Eintrag {chain['broken_at']}")
+ f" ({chain['checked']} geprueft)" + f" ({chain['checked']} geprüft)"
) )
for key, value in counts.items(): for key, value in counts.items():
print(f"{key.capitalize():<15}: {value}") print(f"{key.capitalize():<15}: {value}")
@@ -168,6 +168,27 @@ def cmd_housekeeping(_args: argparse.Namespace) -> int:
return 0 return 0
def cmd_restore_signatures(_args: argparse.Namespace) -> int:
"""Traegt Unterschriften aus dem Journal wieder in die Datenbank ein.
Gebraucht nach einer Wiederherstellung: die Freigabe traegt die Dateien
von gestern, die zurueckgespielte Datenbank ist von vorgestern -- die
Unterschriften dazwischen stehen dann nur noch im Journal neben dem
Protokollarchiv.
"""
app, extension = _app_and_core()
from .services import fileshare
with app.app_context(), extension.database.session() as conn:
with extension.database.transaction(conn):
uebernommen = fileshare.signaturen_aus_journal(conn)
gesamt = int(
conn.execute("SELECT COUNT(*) FROM document_signatures").fetchone()[0]
)
print(f"{uebernommen} Unterschrift(en) übernommen, {gesamt} insgesamt.")
return 0
def cmd_check_now(_args: argparse.Namespace) -> int: def cmd_check_now(_args: argparse.Namespace) -> int:
app, extension = _app_and_core() app, extension = _app_and_core()
from .services import inventory, monitor from .services import inventory, monitor
@@ -183,7 +204,7 @@ def cmd_check_now(_args: argparse.Namespace) -> int:
license_allows_auto_restart=allowed, license_allows_auto_restart=allowed,
) )
print( print(
f"{report.checked} geprueft, {report.online} online, {report.offline} offline, " f"{report.checked} geprüft, {report.online} online, {report.offline} offline, "
f"{report.restarts_triggered} Neustarts ({report.restarts_ok} erfolgreich), " f"{report.restarts_triggered} Neustarts ({report.restarts_ok} erfolgreich), "
f"{report.duration_ms} ms" f"{report.duration_ms} ms"
) )
@@ -214,7 +235,7 @@ def cmd_convert_legacy(args: argparse.Namespace) -> int:
return 2 return 2
ziel = Path(args.ausgabe) if args.ausgabe else quelle.with_name(f"{quelle.stem}-neu.json") ziel = Path(args.ausgabe) if args.ausgabe else quelle.with_name(f"{quelle.stem}-neu.json")
if ziel.exists() and not args.force: if ziel.exists() and not args.force:
print(f"{ziel} existiert bereits. Mit --force ueberschreiben.") print(f"{ziel} existiert bereits. Mit --force überschreiben.")
return 2 return 2
alt_pass = args.passphrase or getpass("Passphrase der alten Datei: ") alt_pass = args.passphrase or getpass("Passphrase der alten Datei: ")
@@ -231,7 +252,7 @@ def cmd_convert_legacy(args: argparse.Namespace) -> int:
print("--dry-run: keine Datei geschrieben.") print("--dry-run: keine Datei geschrieben.")
return 0 return 0
neu_pass = args.neue_passphrase or getpass("Passphrase fuer die neue Datei: ") neu_pass = args.neue_passphrase or getpass("Passphrase für die neue Datei: ")
try: try:
ziel.write_text(transfer.seal(nutzlast, neu_pass), encoding="utf-8", newline="\n") ziel.write_text(transfer.seal(nutzlast, neu_pass), encoding="utf-8", newline="\n")
except Exception as exc: # noqa: BLE001 - Passphrase zu kurz, Pfad nicht schreibbar except Exception as exc: # noqa: BLE001 - Passphrase zu kurz, Pfad nicht schreibbar
@@ -265,10 +286,10 @@ def cmd_legacy_export(args: argparse.Namespace) -> int:
quelle = Path(args.source) quelle = Path(args.source)
ziel = Path(args.out) ziel = Path(args.out)
if ziel.exists() and not args.force: if ziel.exists() and not args.force:
print(f"{ziel} existiert bereits. Mit --force ueberschreiben.") print(f"{ziel} existiert bereits. Mit --force überschreiben.")
return 2 return 2
passphrase = args.passphrase or getpass("Passphrase fuer die Exportdatei: ") passphrase = args.passphrase or getpass("Passphrase für die Exportdatei: ")
try: try:
nutzlast, bericht = legacy.read_installation(quelle) nutzlast, bericht = legacy.read_installation(quelle)
inhalt = legacy.seal_legacy(nutzlast, passphrase) inhalt = legacy.seal_legacy(nutzlast, passphrase)
@@ -354,7 +375,7 @@ def cmd_legacy_import(args: argparse.Namespace) -> int:
action="backup.imported", action="backup.imported",
detail="; ".join( detail="; ".join(
f"{key}: {zahlen['created']} neu, {zahlen['updated']} aktualisiert, " f"{key}: {zahlen['created']} neu, {zahlen['updated']} aktualisiert, "
f"{zahlen['skipped']} uebersprungen" f"{zahlen['skipped']} übersprungen"
for key, zahlen in bericht.items() for key, zahlen in bericht.items()
) )
+ " (Kommandozeile)", + " (Kommandozeile)",
@@ -364,19 +385,20 @@ def cmd_legacy_import(args: argparse.Namespace) -> int:
for key, zahlen in sorted(bericht.items()): for key, zahlen in sorted(bericht.items()):
print( print(
f" {key}: {zahlen['created']} neu, {zahlen['updated']} aktualisiert, " f" {key}: {zahlen['created']} neu, {zahlen['updated']} aktualisiert, "
f"{zahlen['skipped']} uebersprungen" f"{zahlen['skipped']} übersprungen"
) )
return 0 return 0
def build_parser() -> argparse.ArgumentParser: def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser( parser = argparse.ArgumentParser(
prog="tesm-admin", description="Verwaltungswerkzeuge fuer TESM" prog="tesm-admin", description="Verwaltungswerkzeuge für TESM"
) )
sub = parser.add_subparsers(dest="command", required=True) sub = parser.add_subparsers(dest="command", required=True)
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", help="Nur fuer automatisierte Installationen") admin.add_argument("--password", help="Nur für automatisierte Installationen")
admin.add_argument("--force", action="store_true", help="Passwortregeln ignorieren") admin.add_argument("--force", action="store_true", help="Passwortregeln ignorieren")
admin.set_defaults(func=cmd_create_admin) admin.set_defaults(func=cmd_create_admin)
@@ -384,18 +406,24 @@ 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="Sitzungen, Ratenereignisse, Protokolle aufraeumen") house = sub.add_parser("housekeeping", help="Sitzungen, Ratenereignisse, Protokolle aufräumen")
house.set_defaults(func=cmd_housekeeping) house.set_defaults(func=cmd_housekeeping)
check = sub.add_parser("check-now", help="Einen Ueberwachungsdurchlauf sofort ausfuehren") restore = sub.add_parser(
"restore-signatures",
help="Unterschriften aus dem Journal neben dem Protokollarchiv übernehmen",
)
restore.set_defaults(func=cmd_restore_signatures)
check = sub.add_parser("check-now", help="Einen Überwachungsdurchlauf sofort ausführen")
check.set_defaults(func=cmd_check_now) check.set_defaults(func=cmd_check_now)
legacy = sub.add_parser( legacy = sub.add_parser(
"convert-legacy", "convert-legacy",
help="Export des alten TESM in das neue Format uebersetzen", help="Export des alten TESM in das neue Format übersetzen",
description=( description=(
"Liest eine Exportdatei des Vorgaengers und schreibt eine Datei im " "Liest eine Exportdatei des Vorgängers und schreibt eine Datei im "
"Format dieser Anwendung. Uebersetzt werden Clients, Switche, " "Format dieser Anwendung. Übersetzt werden Clients, Switche, "
"Zugangsdaten und die DHCP-Konfiguration." "Zugangsdaten und die DHCP-Konfiguration."
), ),
) )
@@ -410,7 +438,7 @@ def build_parser() -> argparse.ArgumentParser:
legacy.add_argument( legacy.add_argument(
"--dry-run", action="store_true", help="Nur den Bericht zeigen, nichts schreiben" "--dry-run", action="store_true", help="Nur den Bericht zeigen, nichts schreiben"
) )
legacy.add_argument("--force", action="store_true", help="Zieldatei ueberschreiben") legacy.add_argument("--force", action="store_true", help="Zieldatei überschreiben")
legacy.set_defaults(func=cmd_convert_legacy) legacy.set_defaults(func=cmd_convert_legacy)
@@ -418,10 +446,10 @@ def build_parser() -> argparse.ArgumentParser:
"legacy-export", "legacy-export",
help="Daten einer alten Installation als Exportdatei sichern", help="Daten einer alten Installation als Exportdatei sichern",
description=( description=(
"Liest die Datenbank einer Installation der Vorgaengerfassung und schreibt " "Liest die Datenbank einer Installation der Vorgängerfassung und schreibt "
"Clients, Switche, Zugangsdaten und DHCP in eine Datei im alten Format. " "Clients, Switche, Zugangsdaten und DHCP in eine Datei im alten Format. "
"Die alte Installation wird nur gelesen. Ohne Browser und auch dann noch " "Die alte Installation wird nur gelesen. Ohne Browser und auch dann noch "
"moeglich, wenn die alte Anwendung nicht mehr startet." "möglich, wenn die alte Anwendung nicht mehr startet."
), ),
) )
export_alt.add_argument( export_alt.add_argument(
@@ -430,16 +458,16 @@ def build_parser() -> argparse.ArgumentParser:
export_alt.add_argument("--out", required=True, help="Zieldatei") export_alt.add_argument("--out", required=True, help="Zieldatei")
export_alt.add_argument("--passphrase", help="Passphrase der Zieldatei (sonst wird gefragt)") export_alt.add_argument("--passphrase", help="Passphrase der Zieldatei (sonst wird gefragt)")
export_alt.add_argument("--dry-run", action="store_true", help="Nur den Bericht zeigen") export_alt.add_argument("--dry-run", action="store_true", help="Nur den Bericht zeigen")
export_alt.add_argument("--force", action="store_true", help="Zieldatei ueberschreiben") export_alt.add_argument("--force", action="store_true", help="Zieldatei überschreiben")
export_alt.set_defaults(func=cmd_legacy_export) export_alt.set_defaults(func=cmd_legacy_export)
import_neu = sub.add_parser( import_neu = sub.add_parser(
"legacy-import", "legacy-import",
help="Datei im Format dieser Anwendung einlesen", help="Datei im Format dieser Anwendung einlesen",
description=( description=(
"Liest eine Sicherungs- oder uebersetzte Datei ein -- derselbe Weg wie unter " "Liest eine Sicherungs- oder übersetzte Datei ein – derselbe Weg wie unter "
"Verwaltung -> Sicherung, nur ohne Browser. Ohne --categories werden alle " "Verwaltung -> Sicherung, nur ohne Browser. Ohne --categories werden alle "
"Kategorien uebernommen, die in der Datei stehen." "Kategorien übernommen, die in der Datei stehen."
), ),
) )
import_neu.add_argument("datei", help="Datei im Format dieser Anwendung") import_neu.add_argument("datei", help="Datei im Format dieser Anwendung")
+37 -22
View File
@@ -33,9 +33,9 @@ DEVICES = Resource(
actions=(A.VIEW, A.CREATE, A.EDIT, A.DELETE, A.EXECUTE), actions=(A.VIEW, A.CREATE, A.EDIT, A.DELETE, A.EXECUTE),
hint="Überwachte PoE-Endgeräte", hint="Überwachte PoE-Endgeräte",
action_hints={ action_hints={
A.EXECUTE: "PoE-Neustart eines Geraets manuell ausloesen. Der automatische " A.EXECUTE: "PoE-Neustart eines Geräts manuell auslösen. Der automatische "
"Neustart bei Ausfall ist davon unabhaengig und laeuft immer.", "Neustart bei Ausfall ist davon unabhängig und läuft immer.",
A.DELETE: "Geraet in den Papierkorb legen.", A.DELETE: "Gerät in den Papierkorb legen.",
}, },
) )
@@ -45,9 +45,9 @@ SWITCHES = Resource(
actions=(A.VIEW, A.CREATE, A.EDIT, A.DELETE, A.EXECUTE), actions=(A.VIEW, A.CREATE, A.EDIT, A.DELETE, A.EXECUTE),
hint="Aruba-Switche und ihre Erreichbarkeit", hint="Aruba-Switche und ihre Erreichbarkeit",
action_hints={ action_hints={
A.EXECUTE: "Browser-Terminal oeffnen und Kommandos auf dem Switch ausfuehren. " A.EXECUTE: "Browser-Terminal öffnen und Kommandos auf dem Switch ausführen. "
"Verlangt zusaetzlich eine frische Passwortbestaetigung; jede Sitzung wird " "Verlangt zusätzlich eine frische Passwortbestätigung; jede Sitzung wird "
"vollstaendig protokolliert.", "vollständig protokolliert.",
}, },
) )
@@ -57,18 +57,19 @@ CREDENTIALS = Resource(
actions=(A.VIEW, A.CREATE, A.EDIT, A.DELETE, A.SECRETS), actions=(A.VIEW, A.CREATE, A.EDIT, A.DELETE, A.SECRETS),
hint="Wiederverwendbare SSH-Logins", hint="Wiederverwendbare SSH-Logins",
action_hints={ action_hints={
A.VIEW: "Namen und Benutzer sehen -- nicht das Passwort.", A.VIEW: "Namen und Benutzer sehen – nicht das Passwort.",
A.SECRETS: "Passwort im Klartext anzeigen. Verlangt eine frische " A.SECRETS: "Passwort im Klartext anzeigen. Verlangt eine frische "
"Passwortbestaetigung und wird protokolliert.", "Passwortbestätigung und wird protokolliert.",
}, },
) )
MAINTENANCE = Resource( MAINTENANCE = Resource(
key="maintenance", key="maintenance",
label="Wartung", # Nicht "Wartung": so heisst der Bereich. Die Zeile sagt, was sie erlaubt.
label="Aktualisierung und Neustart",
actions=(A.VIEW, A.EXECUTE), actions=(A.VIEW, A.EXECUTE),
hint="Paketaktualisierung und Neustart per SSH (lizenzpflichtiges Modul)", hint="Paketaktualisierung und Neustart per SSH (lizenzpflichtiges Modul)",
action_hints={A.EXECUTE: "Aktualisierung oder Neustart auf ausgewaehlten Geraeten starten."}, action_hints={A.EXECUTE: "Aktualisierung oder Neustart auf ausgewählten Geräten starten."},
) )
DHCP = Resource( DHCP = Resource(
@@ -77,7 +78,7 @@ DHCP = Resource(
actions=(A.VIEW, A.EDIT, A.EXECUTE), actions=(A.VIEW, A.EDIT, A.EXECUTE),
hint="DHCP: Subnetze, Optionen, Reservierungen (lizenzpflichtiges Modul)", hint="DHCP: Subnetze, Optionen, Reservierungen (lizenzpflichtiges Modul)",
action_hints={ action_hints={
A.EDIT: "Konfiguration aendern und schreiben.", A.EDIT: "Konfiguration ändern und schreiben.",
A.EXECUTE: "Dienst installieren, starten oder stoppen.", A.EXECUTE: "Dienst installieren, starten oder stoppen.",
}, },
) )
@@ -110,11 +111,11 @@ FILESHARE_PDF = Resource(
key="fileshare_pdf", key="fileshare_pdf",
label="PDF-Formulare", label="PDF-Formulare",
actions=(A.VIEW, A.CREATE, A.EDIT), actions=(A.VIEW, A.CREATE, A.EDIT),
hint="Formulare in der Vorschau ausfuellen und in der Freigabe ablegen", hint="Formulare in der Vorschau ausfüllen und in der Freigabe ablegen",
action_hints={ action_hints={
A.VIEW: "In der Vorschau ausfuellen (ohne Speichern).", A.VIEW: "In der Vorschau ausfüllen (ohne Speichern).",
A.CREATE: "Ausgefuellt speichern -- abgeschlossen, Felder gesperrt.", A.CREATE: "Ausgefüllt speichern – abgeschlossen, Felder gesperrt.",
A.EDIT: "Ausgefuellt speichern -- weiter ausfuellbar, inklusive Bild.", A.EDIT: "Ausgefüllt speichern – weiter ausfüllbar, inklusive Bild.",
}, },
) )
@@ -128,9 +129,22 @@ MONITORING_AREA = Area(
key="monitoring", key="monitoring",
label="Überwachung", label="Überwachung",
icon="cpu", icon="cpu",
hint="Geraete, Switche, Zugangsdaten und Wartung. Ohne diesen Zugang bleibt der " hint="Geräte, Switche und Zugangsdaten. Ohne diesen Zugang bleibt der "
"gesamte Bereich verborgen.", "gesamte Bereich verborgen.",
resources=(DEVICES, SWITCHES, CREDENTIALS, MAINTENANCE), resources=(DEVICES, SWITCHES, CREDENTIALS),
)
#: Wartung ueberwacht nichts, sie greift ein -- und sie steht seit 2.5.0 als
#: eigener Punkt im Menue. Der Bereichsschluessel heisst ``servicing``, weil
#: ``maintenance`` schon die Ressource ist und Bereiche und Ressourcen sich
#: einen Namensraum teilen. Nach aussen heisst beides "Wartung".
MAINTENANCE_AREA = Area(
key="servicing",
label="Wartung",
icon="wrench",
hint="Paketpflege und Neustarts. Ohne diesen Zugang bleibt der Bereich "
"verborgen – auch wenn einzelne Rechte darunter gesetzt sind.",
resources=(MAINTENANCE,),
) )
NETWORK_AREA = Area( NETWORK_AREA = Area(
@@ -150,6 +164,7 @@ FILES_AREA = Area(
PERMISSIONS = PermissionTree( PERMISSIONS = PermissionTree(
[ [
MONITORING_AREA, MONITORING_AREA,
MAINTENANCE_AREA,
NETWORK_AREA, NETWORK_AREA,
FILES_AREA, FILES_AREA,
observability_area(LOGS_DHCP), observability_area(LOGS_DHCP),
@@ -162,7 +177,7 @@ ROLE_PRESETS: tuple[RolePreset, ...] = (
RolePreset( RolePreset(
key="viewer", key="viewer",
name="Benutzer", name="Benutzer",
description="Standardgruppe: Geraete und Switche ansehen, laufendes Protokoll lesen.", description="Standardgruppe: Geräte und Switche ansehen, laufendes Protokoll lesen.",
grants=( grants=(
("devices", (A.VIEW,)), ("devices", (A.VIEW,)),
("switches", (A.VIEW,)), ("switches", (A.VIEW,)),
@@ -173,7 +188,7 @@ ROLE_PRESETS: tuple[RolePreset, ...] = (
RolePreset( RolePreset(
key="operations", key="operations",
name="Betrieb", name="Betrieb",
description="Geraete und Switche pflegen, Neustarts ausloesen, Wartung fahren.", description="Geräte und Switche pflegen, Neustarts auslösen, Wartung fahren.",
grants=( grants=(
("devices", "*"), ("devices", "*"),
("switches", (A.VIEW, A.CREATE, A.EDIT, A.EXECUTE)), ("switches", (A.VIEW, A.CREATE, A.EDIT, A.EXECUTE)),
@@ -187,7 +202,7 @@ ROLE_PRESETS: tuple[RolePreset, ...] = (
RolePreset( RolePreset(
key="support", key="support",
name="Support", name="Support",
description="Erste Hilfe am Telefon: Status sehen, PoE-Neustart ausloesen.", description="Erste Hilfe am Telefon: Status sehen, PoE-Neustart auslösen.",
grants=( grants=(
("devices", (A.VIEW, A.EXECUTE)), ("devices", (A.VIEW, A.EXECUTE)),
("switches", (A.VIEW,)), ("switches", (A.VIEW,)),
@@ -209,7 +224,7 @@ ROLE_PRESETS: tuple[RolePreset, ...] = (
RolePreset( RolePreset(
key="auditor", key="auditor",
name="Revision", name="Revision",
description="Nur lesen -- inklusive Aenderungsprotokoll und Diagnose.", description="Nur lesen – inklusive Änderungsprotokoll und Diagnose.",
grants=( grants=(
("devices", (A.VIEW,)), ("devices", (A.VIEW,)),
("switches", (A.VIEW,)), ("switches", (A.VIEW,)),
@@ -227,7 +242,7 @@ ROLE_PRESETS: tuple[RolePreset, ...] = (
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", "*"),
+84
View File
@@ -289,10 +289,94 @@ DELETE FROM settings WHERE key = 'terminal_enabled';
""" """
# Wartung ist ein eigener Bereich geworden und hat damit einen eigenen
# Kill-Switch. Ohne diesen Schritt waere sie nach dem Update bei allen weg:
# das neue Bereichsrecht ist bei niemandem gesetzt, und ohne Bereichsrecht
# greift kein Recht darunter.
#
# Bewusst nicht "alle, die ein Wartungsrecht haben": wer den Bereich
# Ueberwachung nicht hatte, kam auch vorher nicht an die Wartung. Ein Update
# darf Zugang erhalten, aber keinen neuen schaffen.
V6 = """
INSERT OR IGNORE INTO group_permissions (group_id, permission)
SELECT DISTINCT group_id, 'servicing.view'
FROM group_permissions
WHERE permission IN ('maintenance.view', 'maintenance.execute')
AND group_id IN (
SELECT group_id FROM group_permissions WHERE permission = 'monitoring.view'
);
"""
# Die Pruefung "unveraendert seit der Unterschrift" hing am Aenderungsprotokoll
# -- und das wird ab 20.000 Zeilen tageweise in Dateien ausgelagert. Mit dem
# ausgelagerten Tag waere die Pruefsumme aus der Datenbank verschwunden und
# jede aeltere unterschriebene Fassung waere still zu "unbekannt" geworden.
# Schlimmer noch: auch eine **veraenderte** Fassung, denn ohne Vergleichswert
# gibt es keinen Widerspruch.
#
# Deshalb eine eigene, winzige Tabelle: eine Zeile je Unterschrift, nie
# archiviert. Sie ist ein Verzeichnis, keine Wahrheit -- die Wahrheit bleibt
# das Aenderungsprotokoll mit seiner Hash-Kette, auch im Archiv.
V7 = """
CREATE TABLE IF NOT EXISTS document_signatures (
id INTEGER PRIMARY KEY AUTOINCREMENT,
sha256 TEXT NOT NULL,
share_label TEXT NOT NULL DEFAULT '',
path TEXT NOT NULL DEFAULT '',
signed_at TEXT NOT NULL,
actor TEXT NOT NULL DEFAULT '',
names TEXT NOT NULL DEFAULT '',
audit_id INTEGER
);
CREATE INDEX IF NOT EXISTS idx_document_signatures_sha
ON document_signatures(sha256);
CREATE INDEX IF NOT EXISTS idx_document_signatures_ort
ON document_signatures(share_label, path);
"""
def uebernehme_signaturen(conn) -> None: # type: ignore[no-untyped-def]
"""Traegt die bereits protokollierten Unterschriften ins Verzeichnis ein.
Ohne diesen Schritt waeren alle vor dem Update unterschriebenen Fassungen
ab sofort "unbekannt" -- richtig waere das nicht, die Eintraege stehen ja
noch da.
"""
from .services import fileshare as service
zeilen = conn.execute(
"SELECT id, ts, actor, detail FROM audit_log WHERE action='fileshare.form_signed' "
"ORDER BY id"
).fetchall()
for zeile in zeilen:
detail = str(zeile["detail"] or "")
gelesen = service.signaturdetail_lesen(detail)
if not gelesen["sha256"]:
continue
conn.execute(
"INSERT INTO document_signatures (sha256, share_label, path, signed_at, actor, "
"names, audit_id) VALUES (?,?,?,?,?,?,?)",
(
gelesen["sha256"],
gelesen["label"],
gelesen["pfad"],
str(zeile["ts"]),
str(zeile["actor"] or ""),
", ".join(gelesen["namen"]),
int(zeile["id"]),
),
)
TESM_MIGRATIONS: tuple[Migration, ...] = ( TESM_MIGRATIONS: tuple[Migration, ...] = (
Migration(version=1, name="baseline", sql=V1), Migration(version=1, name="baseline", sql=V1),
Migration(version=2, name="seed-dhcp-options", fn=seed_dhcp_options), Migration(version=2, name="seed-dhcp-options", fn=seed_dhcp_options),
Migration(version=3, name="restart-events", sql=V3), Migration(version=3, name="restart-events", sql=V3),
Migration(version=4, name="fileshare-pdf-rechte", sql=V4), Migration(version=4, name="fileshare-pdf-rechte", sql=V4),
Migration(version=5, name="terminal-getrennt", sql=V5), Migration(version=5, name="terminal-getrennt", sql=V5),
Migration(version=6, name="wartung-eigener-bereich", sql=V6),
Migration(version=7, name="signaturverzeichnis", sql=V7, fn=uebernehme_signaturen),
) )
+339 -22
View File
@@ -21,7 +21,9 @@ Sicherheitsrelevante Aenderungen gegenueber dem Vorgaenger:
from __future__ import annotations from __future__ import annotations
import hashlib
import zlib import zlib
import json
import logging import logging
import mimetypes import mimetypes
import os import os
@@ -41,7 +43,7 @@ from tesm_core.extension import core
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
_UNC_HINT = ( _UNC_HINT = (
"Der Freigabepfad muss die Form \\\\server\\freigabe haben. Schraegstriche " "Der Freigabepfad muss die Form \\\\server\\freigabe haben. Schrägstriche "
"gehen auch: //server/freigabe." "gehen auch: //server/freigabe."
) )
@@ -194,11 +196,11 @@ def resolve_unc_host(unc: str, suffixes: Iterable[str]) -> tuple[str, str]:
kandidat = f"{host}.{suffix.strip('.')}" kandidat = f"{host}.{suffix.strip('.')}"
if _resolvable(kandidat): if _resolvable(kandidat):
neu = chr(92) * 2 + kandidat + (chr(92) + rest if rest else "") neu = chr(92) * 2 + kandidat + (chr(92) + rest if rest else "")
return neu, f"Name {host} ueber {kandidat} aufgeloest" return neu, f"Name {host} über {kandidat} aufgelöst"
return unc, ( return unc, (
f"Der Name {host} ist auf diesem Server nicht aufloesbar. Bitte den " f"Der Name {host} ist auf diesem Server nicht auflösbar. Bitte den "
"vollqualifizierten Namen oder die IP-Adresse eintragen -- oder unter " "vollqualifizierten Namen oder die IP-Adresse eintragen – oder unter "
"Netzwerk eine Suchdomaene setzen." "Netzwerk eine Suchdomäne setzen."
) )
@@ -236,7 +238,7 @@ def describe_mapping(row: Mapping[str, Any] | None) -> str:
gruppe = str(row["ad_group_name"] or row["ad_group_dn"] or "") gruppe = str(row["ad_group_name"] or row["ad_group_dn"] or "")
if gruppe: if gruppe:
teile.append(f"Gruppe: {gruppe}") teile.append(f"Gruppe: {gruppe}")
return " -- ".join(teil for teil in teile if teil) return " – ".join(teil for teil in teile if teil)
def mapping_changes(vorher: Mapping[str, Any] | None, nachher: Mapping[str, Any] | None) -> str: def mapping_changes(vorher: Mapping[str, Any] | None, nachher: Mapping[str, Any] | None) -> str:
@@ -260,7 +262,7 @@ def add_mapping(
if not clean_label: if not clean_label:
raise FileshareError("Der Anzeigename darf nicht leer sein.") raise FileshareError("Der Anzeigename darf nicht leer sein.")
if not (ad_group_dn or "").strip(): if not (ad_group_dn or "").strip():
raise FileshareError("Es ist keine Verzeichnisgruppe ausgewaehlt.") raise FileshareError("Es ist keine Verzeichnisgruppe ausgewählt.")
cursor = conn.execute( cursor = conn.execute(
"INSERT INTO fileshare_mappings (ad_group_dn, ad_group_name, label, unc, created_at) " "INSERT INTO fileshare_mappings (ad_group_dn, ad_group_name, label, unc, created_at) "
"VALUES (?,?,?,?,?) ON CONFLICT(ad_group_dn, unc) DO NOTHING", "VALUES (?,?,?,?,?) ON CONFLICT(ad_group_dn, unc) DO NOTHING",
@@ -292,7 +294,7 @@ def update_mapping(
if not clean_label: if not clean_label:
raise FileshareError("Der Anzeigename darf nicht leer sein.") raise FileshareError("Der Anzeigename darf nicht leer sein.")
if not clean_dn: if not clean_dn:
raise FileshareError("Es ist keine Verzeichnisgruppe ausgewaehlt.") raise FileshareError("Es ist keine Verzeichnisgruppe ausgewählt.")
vorhanden = Database.one( vorhanden = Database.one(
conn, "SELECT id FROM fileshare_mappings WHERE id=?", (mapping_id,) conn, "SELECT id FROM fileshare_mappings WHERE id=?", (mapping_id,)
@@ -363,12 +365,32 @@ def mount_root() -> Path:
return Path(str(core().settings.get(db(), "fileshare_mount_root") or "/mnt/tesm-shares")) return Path(str(core().settings.get(db(), "fileshare_mount_root") or "/mnt/tesm-shares"))
def active_mounts(conn: sqlite3.Connection, user_id: int) -> list[Mount]: def aktuelle_sitzung() -> str:
"""Die ID der laufenden Sitzung -- oder leer ausserhalb einer Anfrage."""
from flask import session
return str(getattr(session, "sid", "") or "")
def active_mounts(
conn: sqlite3.Connection, user_id: int, session_id: str | None = None
) -> list[Mount]:
"""Die offenen Einbindungen dieses Kontos.
Mit ``session_id`` nur die dieser Sitzung. Das braucht das Abmelden: wer
an zwei Geraeten angemeldet ist, hat je Geraet eine eigene Einbindung mit
eigenem Einhaengepunkt, und das Abmelden am einen darf das andere nicht
aushaengen.
"""
bedingung = "AND session_id = ? " if session_id else ""
werte: list[Any] = [user_id, _iso(_now())]
if session_id:
werte.insert(1, session_id)
rows = Database.all( rows = Database.all(
conn, conn,
"SELECT * FROM fileshare_mounts WHERE user_id=? AND released_at IS NULL " "SELECT * FROM fileshare_mounts WHERE user_id=? " + bedingung
"AND expires_at > ? ORDER BY label COLLATE NOCASE", + "AND released_at IS NULL AND expires_at > ? ORDER BY label COLLATE NOCASE, id DESC",
(user_id, _iso(_now())), tuple(werte),
) )
return [ return [
Mount( Mount(
@@ -389,6 +411,38 @@ def has_mounts(user_id: int) -> bool:
return bool(active_mounts(db(), user_id)) return bool(active_mounts(db(), user_id))
def doppelte_zusammenfassen(
conn: sqlite3.Connection, mounts: list[Mount], session_id: str = ""
) -> list[Mount]:
"""Je Freigabe eine Karteikarte -- vorzugsweise die dieser Sitzung.
Wer an zwei Geraeten angemeldet ist, hat zwei Einbindungen derselben
Freigabe: eine je Sitzung, jede mit eigenem Einhaengepunkt. In der Leiste
stand sie deshalb doppelt, einmal "Share" und einmal wieder "Share" -- ohne
jeden Unterschied fuer den Betrachter.
Genommen wird die Einbindung der eigenen Sitzung. Nur wenn es die nicht
gibt (alte Zeile, Hintergrundprozess), bleibt eine fremde stehen.
"""
eigene: set[int] = set()
if session_id:
eigene = {
int(zeile["id"])
for zeile in Database.all(
conn,
"SELECT id FROM fileshare_mounts WHERE session_id=? AND released_at IS NULL",
(session_id,),
)
}
gewaehlt: dict[str, Mount] = {}
for mount in mounts:
schluessel = mount.unc.lower()
vorhanden = gewaehlt.get(schluessel)
if vorhanden is None or (mount.id in eigene and vorhanden.id not in eigene):
gewaehlt[schluessel] = mount
return sorted(gewaehlt.values(), key=lambda m: m.label.lower())
def mount_for_login( def mount_for_login(
conn: sqlite3.Connection, conn: sqlite3.Connection,
*, *,
@@ -408,7 +462,7 @@ def mount_for_login(
extension = core() extension = core()
sysops = extension.sysops sysops = extension.sysops
if sysops is None or not sysops.available: if sysops is None or not sysops.available:
return ["Systemhelfer nicht verfuegbar -- keine Einbindungen."] return ["Systemhelfer nicht verfügbar – keine Einbindungen."]
dns = [dn for dn in group_dns if dn and dn.strip()] dns = [dn for dn in group_dns if dn and dn.strip()]
shares = shares_for_groups(conn, dns) shares = shares_for_groups(conn, dns)
@@ -431,7 +485,7 @@ def mount_for_login(
# eingebunden, aber leer. Das betrifft die Anmeldung mit zweitem Faktor, bei # eingebunden, aber leer. Das betrifft die Anmeldung mit zweitem Faktor, bei
# der das Passwort im zweiten Schritt nicht mehr vorliegt. # der das Passwort im zweiten Schritt nicht mehr vorliegt.
if not password: if not password:
return ["Kein Passwort fuer den Fileserver verfuegbar -- keine Einbindungen."] return ["Kein Passwort für den Fileserver verfügbar – keine Einbindungen."]
# Was zuerst kommt: das Ende der Sitzung oder die eigene Obergrenze. Die # Was zuerst kommt: das Ende der Sitzung oder die eigene Obergrenze. Die
# Sitzung ist die eigentliche Klammer -- mit ihr endet die Einbindung; die # Sitzung ist die eigentliche Klammer -- mit ihr endet die Einbindung; die
@@ -532,14 +586,30 @@ def forget_all_mounts(conn: sqlite3.Connection) -> int:
return int(cursor.rowcount or 0) return int(cursor.rowcount or 0)
def release_all_for_user(conn: sqlite3.Connection, user_id: int) -> int: def release_all_for_user(conn: sqlite3.Connection, user_id: int, session_id: str = "") -> int:
"""Loest die Einbindungen -- ohne Sitzung die des ganzen Kontos.
Beim Abmelden wird die Sitzung uebergeben: das andere Geraet desselben
Benutzers arbeitet weiter, seine Einbindung bleibt. Ohne Sitzung (etwa
beim Loeschen eines Kontos) faellt alles.
"""
count = 0 count = 0
for mount in active_mounts(conn, user_id): for mount in active_mounts(conn, user_id, session_id):
if session_id and not _gehoert_zur_sitzung(conn, mount.id, session_id):
continue
release_mount(conn, mount.id) release_mount(conn, mount.id)
count += 1 count += 1
return count return count
def _gehoert_zur_sitzung(conn: sqlite3.Connection, mount_id: int, session_id: str) -> bool:
"""Zeilen ohne Sitzung werden beim Abmelden nicht mitgerissen."""
zeile = Database.one(
conn, "SELECT session_id FROM fileshare_mounts WHERE id=?", (mount_id,)
)
return zeile is not None and str(zeile["session_id"] or "") == session_id
def sweep_expired(database: Any) -> int: def sweep_expired(database: Any) -> int:
"""Loest abgelaufene Einbindungen -- laeuft im Hintergrundprozess. """Loest abgelaufene Einbindungen -- laeuft im Hintergrundprozess.
@@ -596,14 +666,260 @@ def resolve_path(mount: Mount, relative: str) -> Path:
""" """
candidate = PurePosixPath((relative or "").strip().lstrip("/")) candidate = PurePosixPath((relative or "").strip().lstrip("/"))
if any(part == ".." for part in candidate.parts): if any(part == ".." for part in candidate.parts):
raise FileshareError("Ungueltiger Pfad.") raise FileshareError("Ungültiger Pfad.")
root = mount.path.resolve() root = mount.path.resolve()
target = (root / str(candidate)).resolve() target = (root / str(candidate)).resolve()
if target != root and root not in target.parents: if target != root and root not in target.parents:
raise FileshareError("Der Pfad liegt ausserhalb der Freigabe.") raise FileshareError("Der Pfad liegt außerhalb der Freigabe.")
return target return target
#: Wie eine unterschriebene Fassung im Aenderungsprotokoll steht. Die
#: Pruefsumme ist der Anker -- sie steht im selben Eintrag wie der Name, den
#: der Unterschreibende angegeben hat.
SIGNATUR_AKTION = "fileshare.form_signed"
def pruefsumme(pfad: Path) -> str:
"""SHA-256 der Datei, blockweise gelesen."""
kessel = hashlib.sha256()
with pfad.open("rb") as strom:
for block in iter(lambda: strom.read(1024 * 1024), b""):
kessel.update(block)
return kessel.hexdigest()
def signaturdetail_lesen(detail: str) -> dict[str, Any]:
"""Zerlegt die Protokollzeile einer Unterschrift.
Aufbau: ``<Freigabe>:<Pfad> sha256=<Summe> -- angegeben unterschrieben
von: <Namen>``. Gelesen wird sie an zwei Stellen -- beim Uebernehmen
alter Eintraege und wenn das Verzeichnis nichts weiss.
"""
# Der Trenner ist heute ein Zeilenumbruch, frueher ein Leerzeichen -- die
# Zerlegung muss beides vertragen, sonst sind alte Eintraege stumm.
ort, _, rest = detail.partition("sha256=")
ort = ort.strip()
label, _, pfad = ort.partition(":")
summe = rest.split(" ", 1)[0].strip() if rest else ""
return {
"sha256": summe,
"label": label.strip(),
"pfad": pfad.strip(),
"namen": _namen_aus_detail(detail),
}
def signatur_merken(
conn: sqlite3.Connection,
*,
sha256: str,
label: str,
pfad: str,
actor: str,
namen: Iterable[str],
audit_id: int | None = None,
) -> None:
"""Haelt eine Unterschrift im Verzeichnis fest.
Das Aenderungsprotokoll bleibt der Beleg -- mit Hash-Kette und, nach der
Archivierung, in einer Datei. Dieses Verzeichnis ist nur der schnelle Weg
dorthin und wird nie ausgelagert; eine Zeile misst ein paar hundert Byte.
"""
conn.execute(
"INSERT INTO document_signatures (sha256, share_label, path, signed_at, actor, "
"names, audit_id) VALUES (?,?,?,?,?,?,?)",
(
sha256,
label,
pfad,
_iso(_now()),
actor,
", ".join(namen),
audit_id,
),
)
def signaturjournal() -> Path:
"""Wo das zweite Exemplar liegt: neben dem Archiv des Protokolls."""
return core().audit_archive_dir / "signaturen.jsonl"
def signatur_journalisieren(
*, sha256: str, label: str, pfad: str, actor: str, namen: Iterable[str]
) -> None:
"""Schreibt die Unterschrift zusaetzlich in eine Datei -- nur angehaengt.
Zwei Exemplare an zwei Orten: die Tabelle beantwortet die Frage schnell,
diese Datei ueberlebt auch eine zurueckgespielte aeltere Datenbank. Genau
das ist der Fall, den eine Sicherung erzeugt: die Datei auf der Freigabe
ist von gestern, die Datenbank von vorgestern -- ohne das Journal waere
die Unterschrift dazwischen verloren.
Ein Fehlschlag hier darf das Speichern nicht kippen: die Datei liegt
bereits auf der Freigabe, und Tabelle wie Aenderungsprotokoll haben den
Eintrag. Er wird protokolliert, nicht geworfen.
"""
eintrag = {
"ts": _iso(_now()),
"sha256": sha256,
"share": label,
"path": pfad,
"actor": actor,
"names": list(namen),
}
try:
ziel = signaturjournal()
ziel.parent.mkdir(parents=True, exist_ok=True)
neu = not ziel.exists()
with ziel.open("a", encoding="utf-8", newline=chr(10)) as strom:
strom.write(json.dumps(eintrag, ensure_ascii=False, sort_keys=True) + chr(10))
strom.flush()
# Ohne fsync steht die Zeile im Puffer des Kernels -- genau der
# Zustand, gegen den dieses Journal geschrieben wurde.
os.fsync(strom.fileno())
if neu:
try:
os.chmod(ziel, 0o640)
except OSError: # pragma: no cover - andere Dateisysteme
pass
except OSError as fehler: # pragma: no cover - Plattenfehler
logger.warning("Signaturjournal nicht geschrieben: %s", fehler)
def signaturen_aus_journal(conn: sqlite3.Connection) -> int:
"""Traegt fehlende Unterschriften aus dem Journal in die Tabelle ein.
Der Weg zurueck nach einer Wiederherstellung. Vorhandene bleiben, wie sie
sind -- verglichen wird ueber Pruefsumme und Ablageort.
"""
quelle = signaturjournal()
if not quelle.is_file():
return 0
uebernommen = 0
with quelle.open("r", encoding="utf-8") as strom:
for zeile in strom:
zeile = zeile.strip()
if not zeile:
continue
try:
eintrag = json.loads(zeile)
except ValueError:
continue
vorhanden = Database.value(
conn,
"SELECT 1 FROM document_signatures WHERE sha256=? AND path=? LIMIT 1",
(str(eintrag.get("sha256", "")), str(eintrag.get("path", ""))),
)
if vorhanden:
continue
conn.execute(
"INSERT INTO document_signatures (sha256, share_label, path, signed_at, "
"actor, names, audit_id) VALUES (?,?,?,?,?,?,NULL)",
(
str(eintrag.get("sha256", "")),
str(eintrag.get("share", "")),
str(eintrag.get("path", "")),
str(eintrag.get("ts", "")),
str(eintrag.get("actor", "")),
", ".join(eintrag.get("names", []) or []),
),
)
uebernommen += 1
return uebernommen
def signaturstand(conn: sqlite3.Connection, mount: "Mount", relative: str) -> dict[str, Any]:
"""Ist diese Datei die, die unterschrieben wurde?
Drei moegliche Antworten, und die dritte ist die wichtigste:
* ``unveraendert`` -- es gibt einen Protokolleintrag mit genau dieser
Pruefsumme. Die Datei ist Byte fuer Byte die unterschriebene.
* ``veraendert`` -- es gibt einen Eintrag zu genau diesem Ablageort, aber
mit anderer Pruefsumme. Jemand hat nach der Unterschrift etwas an der
Datei geaendert. Das ist der Fall, wegen dem es diese Pruefung gibt.
* ``unbekannt`` -- kein Eintrag. Kein Verdacht: die meisten Dateien in
einer Freigabe sind nie durch TESM gegangen.
Nachgeschlagen wird im Signaturverzeichnis (``document_signatures``) und
nicht im Aenderungsprotokoll: das wird ab 20.000 Zeilen tageweise in
Dateien ausgelagert, und mit dem ausgelagerten Tag waere die Pruefsumme aus
der Datenbank verschwunden. Eine aeltere unterschriebene Fassung waere
still zu "unbekannt" geworden -- und eine **veraenderte** ebenso, weil ohne
Vergleichswert niemand widerspricht. Das Verzeichnis wird nie archiviert.
"""
ziel = resolve_path(mount, relative)
if not ziel.is_file():
return {"status": "unbekannt"}
hash_ = pruefsumme(ziel)
zeile = Database.one(
conn,
"SELECT signed_at, actor, names FROM document_signatures WHERE sha256=? "
"ORDER BY id DESC LIMIT 1",
(hash_,),
)
if zeile is not None:
return _stand("unveraendert", hash_, zeile)
# Nichts mit dieser Pruefsumme -- aber vielleicht etwas zu diesem Ort.
zeile = Database.one(
conn,
"SELECT signed_at, actor, names FROM document_signatures WHERE share_label=? "
"AND path=? ORDER BY id DESC LIMIT 1",
(mount.label, relative),
)
if zeile is not None:
return _stand("veraendert", hash_, zeile)
return {"status": "unbekannt", "sha256": hash_}
def _stand(status: str, hash_: str, zeile: Any) -> dict[str, Any]:
namen = [teil.strip() for teil in str(zeile["names"] or "").split(",") if teil.strip()]
return {
"status": status,
"sha256": hash_,
"zeit": str(zeile["signed_at"]),
"konto": str(zeile["actor"] or ""),
"namen": namen,
}
def _namen_aus_detail(detail: str) -> list[str]:
"""Die angegebenen Namen aus dem Protokolltext -- ohne sie zu deuten."""
marke = "angegeben unterschrieben von: "
if marke not in detail:
return []
return [teil.strip() for teil in detail.split(marke, 1)[1].split(",") if teil.strip()]
#: Mehr als das zaehlt niemand mehr mit dem Auge -- und auf einer
#: Netzwerkfreigabe kostet jeder weitere Eintrag Zeit.
ZAEHLGRENZE = 500
def _inhalt_zaehlen(verzeichnis: Path) -> int:
"""Wie viele Eintraege liegen im Ordner? Begrenzt gezaehlt.
Gezeigt wird die Zahl an der Stelle, an der bei einer Datei die Groesse
steht -- dort stand bisher ein Gedankenstrich. Bei mehr als
``ZAEHLGRENZE`` bricht das Zaehlen ab: die Oberflaeche schreibt dann
"500+", und niemand wartet auf eine Freigabe mit 40.000 Dateien.
"""
anzahl = 0
try:
with os.scandir(verzeichnis) as eintraege:
for _ in eintraege:
anzahl += 1
if anzahl >= ZAEHLGRENZE:
break
except OSError:
return -1
return anzahl
def list_directory(mount: Mount, relative: str = "") -> dict[str, Any]: def list_directory(mount: Mount, relative: str = "") -> dict[str, Any]:
path = resolve_path(mount, relative) path = resolve_path(mount, relative)
if not path.is_dir(): if not path.is_dir():
@@ -625,6 +941,7 @@ def list_directory(mount: Mount, relative: str = "") -> dict[str, Any]:
), ),
} }
if entry.is_dir(): if entry.is_dir():
record["entries"] = _inhalt_zaehlen(entry)
directories.append(record) directories.append(record)
else: else:
record["previewable"] = entry.suffix.lower() in PREVIEW_MIME record["previewable"] = entry.suffix.lower() in PREVIEW_MIME
@@ -655,7 +972,7 @@ def preview_mime(name: str) -> str:
suffix = Path(name).suffix.lower() suffix = Path(name).suffix.lower()
if suffix in PREVIEW_MIME: if suffix in PREVIEW_MIME:
return PREVIEW_MIME[suffix] return PREVIEW_MIME[suffix]
raise FileshareError("Fuer diesen Dateityp gibt es keine Vorschau -- bitte herunterladen.") raise FileshareError("Für diesen Dateityp gibt es keine Vorschau – bitte herunterladen.")
def download_mime(name: str) -> str: def download_mime(name: str) -> str:
@@ -667,7 +984,7 @@ def safe_filename(name: str) -> str:
"""Verhindert Pfadanteile und versteckte Dateien in Uploads/Neuanlagen.""" """Verhindert Pfadanteile und versteckte Dateien in Uploads/Neuanlagen."""
cleaned = os.path.basename((name or "").strip().replace("\\", "/")) cleaned = os.path.basename((name or "").strip().replace("\\", "/"))
if not cleaned or cleaned in {".", ".."} or cleaned.startswith("."): if not cleaned or cleaned in {".", ".."} or cleaned.startswith("."):
raise FileshareError("Ungueltiger Dateiname.") raise FileshareError("Ungültiger Dateiname.")
if len(cleaned) > 200: if len(cleaned) > 200:
raise FileshareError("Der Dateiname ist zu lang.") raise FileshareError("Der Dateiname ist zu lang.")
return cleaned return cleaned
@@ -739,14 +1056,14 @@ def free_name(parent: Path, stem: str, zusatz: str, suffix: str) -> Path:
kandidat = parent / f"{stem}{zusatz}-{zaehler}{suffix}" kandidat = parent / f"{stem}{zusatz}-{zaehler}{suffix}"
zaehler += 1 zaehler += 1
if zaehler > 200: if zaehler > 200:
raise FileshareError("Zu viele Dateien dieses Namens -- bitte aufraeumen.") raise FileshareError("Zu viele Dateien dieses Namens – bitte aufräumen.")
return kandidat return kandidat
def delete_entry(mount: Mount, relative: str) -> str: def delete_entry(mount: Mount, relative: str) -> str:
path = resolve_path(mount, relative) path = resolve_path(mount, relative)
if path == mount.path.resolve(): if path == mount.path.resolve():
raise FileshareError("Das Wurzelverzeichnis der Freigabe kann nicht geloescht werden.") raise FileshareError("Das Wurzelverzeichnis der Freigabe kann nicht gelöscht werden.")
if path.is_dir(): if path.is_dir():
shutil.rmtree(path) shutil.rmtree(path)
return "Verzeichnis" return "Verzeichnis"
+29 -23
View File
@@ -54,13 +54,13 @@ def is_ssh_managed(category: str | None) -> bool:
#: war "Windows-Client" vorher -- waehlbar, aber ohne jede Wirkung. #: war "Windows-Client" vorher -- waehlbar, aber ohne jede Wirkung.
CATEGORY_EFFECTS: dict[str, str] = { CATEGORY_EFFECTS: dict[str, str] = {
"switch": "Anmeldung an Switchen: PoE schalten, Konsole, Statusabfragen.", "switch": "Anmeldung an Switchen: PoE schalten, Konsole, Statusabfragen.",
"linux": "Wartung ueber SSH: Paketaktualisierung (apt) und Neustart. Konsole.", "linux": "Wartung über SSH: Paketaktualisierung (apt) und Neustart. Konsole.",
"windows": ( "windows": (
"Neustart ueber RPC (wie 'shutdown /m'). Kein SSH, kein Host-Schluessel. Das " "Neustart über RPC (wie 'shutdown /m'). Kein SSH, kein Host-Schlüssel. Das "
"Geraet braucht offene Datei- und Druckerfreigabe (Port 445); das Konto braucht " "Gerät braucht offene Datei- und Druckerfreigabe (Port 445); das Konto braucht "
"das Recht, aus der Ferne herunterzufahren. Paketaktualisierung gibt es hier nicht." "das Recht, aus der Ferne herunterzufahren. Paketaktualisierung gibt es hier nicht."
), ),
"router": "Nur Dokumentation -- diese Kategorie loest keine Aktion aus.", "router": "Nur Dokumentation – diese Kategorie löst keine Aktion aus.",
} }
DEVICE_CATEGORIES: tuple[tuple[str, str], ...] = ( DEVICE_CATEGORIES: tuple[tuple[str, str], ...] = (
@@ -95,7 +95,7 @@ def normalize_mac(value: str) -> str:
cleaned = re.sub(r"[^0-9a-fA-F]", "", value or "") cleaned = re.sub(r"[^0-9a-fA-F]", "", value or "")
if len(cleaned) != 12: if len(cleaned) != 12:
raise ValidationError( raise ValidationError(
"Die MAC-Adresse muss zwoelf hexadezimale Zeichen enthalten " "Die MAC-Adresse muss zwölf hexadezimale Zeichen enthalten "
"(z. B. aa:bb:cc:dd:ee:ff)." "(z. B. aa:bb:cc:dd:ee:ff)."
) )
return ":".join(cleaned[index : index + 2] for index in range(0, 12, 2)).lower() return ":".join(cleaned[index : index + 2] for index in range(0, 12, 2)).lower()
@@ -106,7 +106,7 @@ def validate_ip(value: str, *, field: str = "IP-Adresse") -> str:
try: try:
ipaddress.ip_address(text) ipaddress.ip_address(text)
except ValueError as exc: except ValueError as exc:
raise ValidationError(f"{field}: {text!r} ist keine gueltige IP-Adresse.") from exc raise ValidationError(f"{field}: {text!r} ist keine gültige IP-Adresse.") from exc
return text return text
@@ -116,7 +116,7 @@ def validate_port_name(value: str) -> str:
return "" return ""
if not PORT_RE.match(text): if not PORT_RE.match(text):
raise ValidationError( raise ValidationError(
"Die Portbezeichnung enthaelt unerlaubte Zeichen (erlaubt: Buchstaben, " "Die Portbezeichnung enthält unerlaubte Zeichen (erlaubt: Buchstaben, "
"Ziffern, / . : -)." "Ziffern, / . : -)."
) )
return text return text
@@ -125,7 +125,7 @@ def validate_port_name(value: str) -> str:
def validate_hostname(value: str) -> str: def validate_hostname(value: str) -> str:
text = (value or "").strip() text = (value or "").strip()
if not HOSTNAME_RE.match(text): if not HOSTNAME_RE.match(text):
raise ValidationError("Der Hostname enthaelt unerlaubte Zeichen oder ist leer.") raise ValidationError("Der Hostname enthält unerlaubte Zeichen oder ist leer.")
return text return text
@@ -208,7 +208,7 @@ def create_credential(
if existing["deleted_at"]: if existing["deleted_at"]:
raise ValidationError( raise ValidationError(
f"Zugangsdaten namens {clean_name!r} liegen im Papierkorb. Stellen Sie sie " f"Zugangsdaten namens {clean_name!r} liegen im Papierkorb. Stellen Sie sie "
"wieder her oder loeschen Sie sie endgueltig." "wieder her oder löschen Sie sie endgültig."
) )
raise ValidationError(f"Zugangsdaten namens {clean_name!r} existieren bereits.") raise ValidationError(f"Zugangsdaten namens {clean_name!r} existieren bereits.")
@@ -258,11 +258,11 @@ def update_credential(
if clean_name != str(row["name"]): if clean_name != str(row["name"]):
changes.append(f"Name: {row['name']} -> {clean_name}") changes.append(f"Name: {row['name']} -> {clean_name}")
if (username or "").strip() != str(row["username"] or ""): if (username or "").strip() != str(row["username"] or ""):
changes.append("Benutzer geaendert") changes.append("Benutzer geändert")
if category != str(row["category"]): if category != str(row["category"]):
changes.append(f"Kategorie: {row['category']} -> {category}") changes.append(f"Kategorie: {row['category']} -> {category}")
if (description or "").strip() != str(row["description"] or ""): if (description or "").strip() != str(row["description"] or ""):
changes.append("Beschreibung geaendert") changes.append("Beschreibung geändert")
conn.execute( conn.execute(
"UPDATE credentials SET name=?, username=?, category=?, description=?, updated_at=?, " "UPDATE credentials SET name=?, username=?, category=?, description=?, updated_at=?, "
@@ -274,7 +274,7 @@ def update_credential(
"UPDATE credentials SET secret_encrypted=? WHERE id=?", "UPDATE credentials SET secret_encrypted=? WHERE id=?",
(keystore.encrypt(secret, aad=aad_for_credential(credential_id)), credential_id), (keystore.encrypt(secret, aad=aad_for_credential(credential_id)), credential_id),
) )
changes.append("Passwort geaendert") changes.append("Passwort geändert")
return changes return changes
@@ -296,8 +296,8 @@ def credential_secret(
secret = keystore.decrypt(encrypted, aad=aad_for_credential(int(credential_id))) secret = keystore.decrypt(encrypted, aad=aad_for_credential(int(credential_id)))
except DecryptionError as exc: except DecryptionError as exc:
raise ValidationError( raise ValidationError(
"Das gespeicherte Passwort konnte nicht entschluesselt werden. Wurde der " "Das gespeicherte Passwort konnte nicht entschlüsselt werden. Wurde der "
"Datenschluessel ausgetauscht? Bitte die Zugangsdaten neu eingeben." "Datenschlüssel ausgetauscht? Bitte die Zugangsdaten neu eingeben."
) from exc ) from exc
return str(row["username"] or ""), secret return str(row["username"] or ""), secret
@@ -315,7 +315,7 @@ def delete_credential(conn: sqlite3.Connection, credential_id: int) -> None:
if int(row.get("switches") or 0) or int(row.get("devices") or 0): if int(row.get("switches") or 0) or int(row.get("devices") or 0):
raise ValidationError( raise ValidationError(
f"Diese Zugangsdaten werden noch von {row.get('switches')} Switch(en) und " f"Diese Zugangsdaten werden noch von {row.get('switches')} Switch(en) und "
f"{row.get('devices')} Geraet(en) verwendet." f"{row.get('devices')} Gerät(en) verwendet."
) )
conn.execute("UPDATE credentials SET deleted_at=? WHERE id=?", (_now(), credential_id)) conn.execute("UPDATE credentials SET deleted_at=? WHERE id=?", (_now(), credential_id))
@@ -509,7 +509,7 @@ def delete_switch(conn: sqlite3.Connection, switch_id: int) -> None:
) )
if int(count or 0): if int(count or 0):
raise ValidationError( raise ValidationError(
f"Diesem Switch sind noch {count} Geraet(e) zugeordnet. Bitte zuerst umhaengen." f"Diesem Switch sind noch {count} Gerät(e) zugeordnet. Bitte zuerst umhängen."
) )
conn.execute("UPDATE switches SET deleted_at=? WHERE id=?", (_now(), switch_id)) conn.execute("UPDATE switches SET deleted_at=? WHERE id=?", (_now(), switch_id))
@@ -590,13 +590,13 @@ def create_device(
) )
if existing is not None: if existing is not None:
if existing["deleted_at"]: if existing["deleted_at"]:
raise ValidationError(f"Ein Geraet mit der MAC {clean_mac} liegt im Papierkorb.") raise ValidationError(f"Ein Gerät mit der MAC {clean_mac} liegt im Papierkorb.")
raise ValidationError(f"Ein Geraet mit der MAC {clean_mac} existiert bereits.") raise ValidationError(f"Ein Gerät mit der MAC {clean_mac} existiert bereits.")
if switch_id and not clean_port: if switch_id and not clean_port:
raise ValidationError( raise ValidationError(
"Zu einem zugeordneten Switch gehoert eine Portbezeichnung -- sonst ist kein " "Zu einem zugeordneten Switch gehört eine Portbezeichnung – sonst ist kein "
"PoE-Neustart moeglich." "PoE-Neustart möglich."
) )
cursor = conn.execute( cursor = conn.execute(
@@ -651,7 +651,7 @@ def update_device(
conn, "SELECT * FROM devices WHERE id=? AND deleted_at IS NULL", (device_id,) conn, "SELECT * FROM devices WHERE id=? AND deleted_at IS NULL", (device_id,)
) )
if row is None: if row is None:
raise ValidationError("Dieses Geraet existiert nicht (mehr).") raise ValidationError("Dieses Gerät existiert nicht (mehr).")
clean_name = (name or "").strip() clean_name = (name or "").strip()
if not clean_name: if not clean_name:
raise ValidationError("Der Name darf nicht leer sein.") raise ValidationError("Der Name darf nicht leer sein.")
@@ -664,9 +664,9 @@ def update_device(
(clean_mac, device_id), (clean_mac, device_id),
) )
if clash is not None: if clash is not None:
raise ValidationError(f"Ein Geraet mit der MAC {clean_mac} existiert bereits.") raise ValidationError(f"Ein Gerät mit der MAC {clean_mac} existiert bereits.")
if switch_id and not clean_port: if switch_id and not clean_port:
raise ValidationError("Zu einem zugeordneten Switch gehoert eine Portbezeichnung.") raise ValidationError("Zu einem zugeordneten Switch gehört eine Portbezeichnung.")
changes: list[str] = [] changes: list[str] = []
for label, before, after in ( for label, before, after in (
@@ -677,6 +677,12 @@ def update_device(
("Port", str(row["port"] or ""), clean_port), ("Port", str(row["port"] or ""), clean_port),
("Aktiv", str(bool(row["is_active"])), str(bool(is_active))), ("Aktiv", str(bool(row["is_active"])), str(bool(is_active))),
("Automatischer Neustart", str(bool(row["auto_restart"])), str(bool(auto_restart))), ("Automatischer Neustart", str(bool(row["auto_restart"])), str(bool(auto_restart))),
# Diese vier fehlten: eine geaenderte Kategorie oder ein anderer
# SSH-Port stand nirgends, obwohl beides das Verhalten aendert.
("SSH-Port", str(row["ssh_port"] or ""), str(validate_ssh_port(ssh_port) or "")),
("Zugangsdaten", str(row["credential_id"] or ""), str(credential_id or "")),
("Kategorie", str(row["category"] or ""), (category or "").strip()),
("Standort", str(row["location"] or ""), (location or "").strip()),
): ):
if before != after: if before != after:
changes.append(f"{label}: {before or '-'} -> {after or '-'}") changes.append(f"{label}: {before or '-'} -> {after or '-'}")
+12 -12
View File
@@ -46,7 +46,7 @@ STANDARD_OPTIONS: tuple[tuple[int, str, str, str], ...] = (
(9, "lpr-servers", "ipv4-address", "LPR-Druckserver"), (9, "lpr-servers", "ipv4-address", "LPR-Druckserver"),
(10, "impress-servers", "ipv4-address", "Impress-Server"), (10, "impress-servers", "ipv4-address", "Impress-Server"),
(11, "resource-location-servers", "ipv4-address", "Resource Location Server"), (11, "resource-location-servers", "ipv4-address", "Resource Location Server"),
(13, "boot-size", "uint16", "Boot-Image-Groesse (Bloecke)"), (13, "boot-size", "uint16", "Boot-Image-Größe (Blöcke)"),
(14, "merit-dump", "string", "Crash-Dump-Datei"), (14, "merit-dump", "string", "Crash-Dump-Datei"),
(16, "swap-server", "ipv4-address", "Swap-Server"), (16, "swap-server", "ipv4-address", "Swap-Server"),
(17, "root-path", "string", "Root-Pfad (NFS)"), (17, "root-path", "string", "Root-Pfad (NFS)"),
@@ -54,8 +54,8 @@ STANDARD_OPTIONS: tuple[tuple[int, str, str, str], ...] = (
(19, "ip-forwarding", "boolean", "IP-Forwarding aktivieren"), (19, "ip-forwarding", "boolean", "IP-Forwarding aktivieren"),
(20, "non-local-source-routing", "boolean", "Non-Local Source Routing"), (20, "non-local-source-routing", "boolean", "Non-Local Source Routing"),
(21, "policy-filter", "string", "Policy Filter (Ziel,Maske,...)"), (21, "policy-filter", "string", "Policy Filter (Ziel,Maske,...)"),
(22, "max-dgram-reassembly", "uint16", "Maximale Datagramm-Reassemblierungsgroesse"), (22, "max-dgram-reassembly", "uint16", "Maximale Datagramm-Reassemblierungsgröße"),
(23, "default-ip-ttl", "uint8", "Standard-TTL fuer IP"), (23, "default-ip-ttl", "uint8", "Standard-TTL für IP"),
(24, "path-mtu-aging-timeout", "uint32", "Path-MTU-Aging-Timeout (Sek.)"), (24, "path-mtu-aging-timeout", "uint32", "Path-MTU-Aging-Timeout (Sek.)"),
(25, "path-mtu-plateau-table", "uint16", "Path-MTU-Plateau-Tabelle"), (25, "path-mtu-plateau-table", "uint16", "Path-MTU-Plateau-Tabelle"),
(26, "interface-mtu", "uint16", "MTU des Interfaces"), (26, "interface-mtu", "uint16", "MTU des Interfaces"),
@@ -69,7 +69,7 @@ STANDARD_OPTIONS: tuple[tuple[int, str, str, str], ...] = (
(34, "trailer-encapsulation", "boolean", "Trailer Encapsulation"), (34, "trailer-encapsulation", "boolean", "Trailer Encapsulation"),
(35, "arp-cache-timeout", "uint32", "ARP-Cache-Timeout (Sek.)"), (35, "arp-cache-timeout", "uint32", "ARP-Cache-Timeout (Sek.)"),
(36, "ieee802-3-encapsulation", "boolean", "Ethernet-Encapsulation (IEEE 802.3)"), (36, "ieee802-3-encapsulation", "boolean", "Ethernet-Encapsulation (IEEE 802.3)"),
(37, "default-tcp-ttl", "uint8", "Standard-TTL fuer TCP"), (37, "default-tcp-ttl", "uint8", "Standard-TTL für TCP"),
(38, "tcp-keepalive-interval", "uint32", "TCP-Keepalive-Intervall (Sek.)"), (38, "tcp-keepalive-interval", "uint32", "TCP-Keepalive-Intervall (Sek.)"),
(40, "nis-domain", "string", "NIS-Domain"), (40, "nis-domain", "string", "NIS-Domain"),
(41, "nis-servers", "ipv4-address", "NIS-Server"), (41, "nis-servers", "ipv4-address", "NIS-Server"),
@@ -190,12 +190,12 @@ def validate_subnet(
) -> str: ) -> str:
"""Rueckgabe: leerer String bei Erfolg, sonst die Fehlermeldung.""" """Rueckgabe: leerer String bei Erfolg, sonst die Fehlermeldung."""
if not interface: if not interface:
return "Es ist kein Interface ausgewaehlt." return "Es ist kein Interface ausgewählt."
try: try:
start = ipaddress.ip_address(range_start) start = ipaddress.ip_address(range_start)
end = ipaddress.ip_address(range_end) end = ipaddress.ip_address(range_end)
except ValueError: except ValueError:
return "Start- und Endadresse muessen gueltige IPv4-Adressen sein." return "Start- und Endadresse müssen gültige IPv4-Adressen sein."
if int(end) < int(start): if int(end) < int(start):
return "Die Endadresse liegt vor der Startadresse." return "Die Endadresse liegt vor der Startadresse."
if matching_network(networks, interface, range_start, range_end) is None: if matching_network(networks, interface, range_start, range_end) is None:
@@ -405,7 +405,7 @@ def render_config(
# fuehren, dass gar keine Konfiguration mehr geschrieben wird. # fuehren, dass gar keine Konfiguration mehr geschrieben wird.
warnings.append( warnings.append(
f"Subnetz {subnet['range_start']}-{subnet['range_end']} auf {interface} " f"Subnetz {subnet['range_start']}-{subnet['range_end']} auf {interface} "
"passt in kein aktuelles Netz und wurde uebersprungen." "passt in kein aktuelles Netz und wurde übersprungen."
) )
continue continue
interfaces.add(interface) interfaces.add(interface)
@@ -444,7 +444,7 @@ def render_config(
) )
if not subnet_entries: if not subnet_entries:
warnings.append("Es ist kein gueltiges Subnetz konfiguriert -- Kea wuerde nicht starten.") warnings.append("Es ist kein gültiges Subnetz konfiguriert | Kea würde nicht starten.")
global_option_data = option_data(global_values) global_option_data = option_data(global_values)
domain = str(settings.get("dhcp_domain") or "").strip() domain = str(settings.get("dhcp_domain") or "").strip()
@@ -560,14 +560,14 @@ class Lease:
#: benutzte, und ein Geraet, das noch auf einer alten Adresse haengt, faellt #: benutzte, und ein Geraet, das noch auf einer alten Adresse haengt, faellt
#: ueberhaupt nicht auf. #: ueberhaupt nicht auf.
RESERVATION_STATES: dict[str, tuple[str, str]] = { RESERVATION_STATES: dict[str, tuple[str, str]] = {
"aktiv": ("online", "Das Geraet benutzt diese Adresse gerade."), "aktiv": ("online", "Das Gerät benutzt diese Adresse gerade."),
"abgelaufen": ("", "Lease auf dieser Adresse ist abgelaufen -- Geraet derzeit nicht da."), "abgelaufen": ("", "Lease auf dieser Adresse ist abgelaufen | Gerät derzeit nicht da."),
"andere_adresse": ( "andere_adresse": (
"warning", "warning",
"Das Geraet haelt noch eine Lease auf einer anderen Adresse. Es uebernimmt die " "Das Gerät hält noch eine Lease auf einer anderen Adresse. Es übernimmt die "
"Reservierung erst nach Ablauf oder Neuanforderung.", "Reservierung erst nach Ablauf oder Neuanforderung.",
), ),
"unbenutzt": ("", "Fuer dieses Geraet liegt keine Lease vor."), "unbenutzt": ("", "Für dieses Gerät liegt keine Lease vor."),
} }
+31 -31
View File
@@ -42,19 +42,19 @@ SUPPORTED = ("credentials", "switches", "devices", "dhcp")
KNOWN_UNSUPPORTED: dict[str, str] = { KNOWN_UNSUPPORTED: dict[str, str] = {
"users": ( "users": (
"Lokale Konten: der alte Passwort-Hash ist bcrypt, diese Anwendung " "Lokale Konten: der alte Passwort-Hash ist bcrypt, diese Anwendung "
"verwendet Argon2id. Ein Hash laesst sich nicht umrechnen -- das ist sein " "verwendet Argon2id. Ein Hash lässt sich nicht umrechnen – das ist sein "
"Zweck. Konten neu anlegen." "Zweck. Konten neu anlegen."
), ),
"groups": "Gruppen und Rechte: die Rechteschluessel wurden neu geschnitten.", "groups": "Gruppen und Rechte: die Rechteschlüssel wurden neu geschnitten.",
"ldap": ( "ldap": (
"Verzeichnisdienst: Einstellungen, Gruppen- und Freigabe-Zuordnungen von " "Verzeichnisdienst: Einstellungen, Gruppen- und Freigabe-Zuordnungen von "
"Hand uebertragen." "Hand übertragen."
), ),
"nginx": "Webserver: Domain, Ports und Zertifikat von Hand uebertragen.", "nginx": "Webserver: Domain, Ports und Zertifikat von Hand übertragen.",
"logs": "Auditlog: bleibt bewusst beim alten System.", "logs": "Auditlog: bleibt bewusst beim alten System.",
"known_hosts": ( "known_hosts": (
"SSH-Host-Schluessel: werden hier je Geraet mit Fingerprint gefuehrt und " "SSH-Host-Schlüssel: werden hier je Gerät mit Fingerprint geführt und "
"ueber die Seite 'Host-Schluessel' freigegeben." "über die Seite 'Host-Schlüssel' freigegeben."
), ),
} }
@@ -119,10 +119,10 @@ def open_legacy(raw: bytes | str, passphrase: str) -> dict[str, Any]:
except ValueError as exc: except ValueError as exc:
raise LegacyTransferError("Die Datei ist kein JSON.") from exc raise LegacyTransferError("Die Datei ist kein JSON.") from exc
if not isinstance(envelope, dict): if not isinstance(envelope, dict):
raise LegacyTransferError("Die Datei enthaelt kein Objekt.") raise LegacyTransferError("Die Datei enthält kein Objekt.")
if str(envelope.get("app") or "") != LEGACY_APP_TAG: if str(envelope.get("app") or "") != LEGACY_APP_TAG:
raise LegacyTransferError( raise LegacyTransferError(
f"Diese Datei gehoert zu {envelope.get('app') or 'einer unbekannten Anwendung'!r}, " f"Diese Datei gehört zu {envelope.get('app') or 'einer unbekannten Anwendung'!r}, "
"nicht zu TESM." "nicht zu TESM."
) )
for schluessel in ("salt", "payload"): for schluessel in ("salt", "payload"):
@@ -142,15 +142,15 @@ def open_legacy(raw: bytes | str, passphrase: str) -> dict[str, Any]:
klartext = Fernet(key).decrypt(str(envelope["payload"]).encode("utf-8")) klartext = Fernet(key).decrypt(str(envelope["payload"]).encode("utf-8"))
except (InvalidToken, ValueError) as exc: except (InvalidToken, ValueError) as exc:
raise LegacyTransferError( raise LegacyTransferError(
"Entschluesselung fehlgeschlagen -- falsche Passphrase oder beschaedigte Datei." "Entschlüsselung fehlgeschlagen – falsche Passphrase oder beschädigte Datei."
) from exc ) from exc
try: try:
payload = json.loads(klartext.decode("utf-8")) payload = json.loads(klartext.decode("utf-8"))
except ValueError as exc: except ValueError as exc:
raise LegacyTransferError("Die entschluesselte Nutzlast ist kein JSON.") from exc raise LegacyTransferError("Die entschlüsselte Nutzlast ist kein JSON.") from exc
if not isinstance(payload, dict): if not isinstance(payload, dict):
raise LegacyTransferError("Die Nutzlast enthaelt kein Objekt.") raise LegacyTransferError("Die Nutzlast enthält kein Objekt.")
return payload return payload
@@ -180,14 +180,14 @@ def _credentials(items: Any, report: Report) -> list[dict[str, Any]]:
for entry in _liste(items): for entry in _liste(items):
name = str(entry.get("name") or "").strip() name = str(entry.get("name") or "").strip()
if not name: if not name:
report.note("Zugangsdaten ohne Namen uebersprungen.") report.note("Zugangsdaten ohne Namen übersprungen.")
continue continue
umgesetzt: dict[str, Any] = { umgesetzt: dict[str, Any] = {
"name": name, "name": name,
"username": str(entry.get("username") or ""), "username": str(entry.get("username") or ""),
"category": _category(entry.get("category"), report, f"Zugangsdaten {name!r}"), "category": _category(entry.get("category"), report, f"Zugangsdaten {name!r}"),
# Der Vorgaenger kennt kein Beschreibungsfeld. # Der Vorgaenger kennt kein Beschreibungsfeld.
"description": "aus altem TESM uebernommen", "description": "aus altem TESM übernommen",
} }
# Der alte Export entschluesselt das Passwort und legt es im Klartext in # Der alte Export entschluesselt das Passwort und legt es im Klartext in
# die -- als Ganzes passphrasenverschluesselte -- Datei. Beim Import # die -- als Ganzes passphrasenverschluesselte -- Datei. Beim Import
@@ -199,8 +199,8 @@ def _credentials(items: Any, report: Report) -> list[dict[str, Any]]:
ergebnis.append(umgesetzt) ergebnis.append(umgesetzt)
if ohne_passwort: if ohne_passwort:
report.note( report.note(
f"{ohne_passwort} Zugangsdaten ohne Passwort in der Datei -- " f"{ohne_passwort} Zugangsdaten ohne Passwort in der Datei – "
"sie muessen nachgetragen werden." "sie müssen nachgetragen werden."
) )
report.took("credentials", len(ergebnis)) report.took("credentials", len(ergebnis))
return ergebnis return ergebnis
@@ -211,7 +211,7 @@ def _switches(items: Any, report: Report) -> list[dict[str, Any]]:
for entry in _liste(items): for entry in _liste(items):
hostname = str(entry.get("hostname") or "").strip() hostname = str(entry.get("hostname") or "").strip()
if not hostname: if not hostname:
report.note("Switch ohne Hostnamen uebersprungen.") report.note("Switch ohne Hostnamen übersprungen.")
continue continue
ergebnis.append( ergebnis.append(
{ {
@@ -240,7 +240,7 @@ def _devices(items: Any, credentials: list[dict[str, Any]], report: Report) -> l
for entry in _liste(items): for entry in _liste(items):
mac = str(entry.get("mac") or "").strip() mac = str(entry.get("mac") or "").strip()
if not mac: if not mac:
report.note("Client ohne MAC-Adresse uebersprungen.") report.note("Client ohne MAC-Adresse übersprungen.")
continue continue
zugang = entry.get("credential_name") zugang = entry.get("credential_name")
kategorie = kategorie_je_zugang.get(str(zugang or ""), "") kategorie = kategorie_je_zugang.get(str(zugang or ""), "")
@@ -266,7 +266,7 @@ def _devices(items: Any, credentials: list[dict[str, Any]], report: Report) -> l
if abgeleitet: if abgeleitet:
report.note( report.note(
f"Kategorie von {abgeleitet} Client(s) aus den zugeordneten Zugangsdaten " f"Kategorie von {abgeleitet} Client(s) aus den zugeordneten Zugangsdaten "
"abgeleitet -- der Vorgaenger kennt kein solches Feld. Bitte pruefen." "abgeleitet – der Vorgänger kennt kein solches Feld. Bitte prüfen."
) )
report.took("devices", len(ergebnis)) report.took("devices", len(ergebnis))
return ergebnis return ergebnis
@@ -298,14 +298,14 @@ def _dhcp(data: Any, report: Report) -> tuple[dict[str, Any], dict[str, str]]:
for entry in _liste(data.get("reservations")): for entry in _liste(data.get("reservations")):
mac = str(entry.get("mac") or "").strip() mac = str(entry.get("mac") or "").strip()
if not mac: if not mac:
report.note("Manuelle Reservierung ohne MAC-Adresse uebersprungen.") report.note("Manuelle Reservierung ohne MAC-Adresse übersprungen.")
continue continue
reservations.append( reservations.append(
{ {
"mac": mac, "mac": mac,
"ip": str(entry.get("ip") or ""), "ip": str(entry.get("ip") or ""),
"hostname": str(entry.get("name") or ""), "hostname": str(entry.get("name") or ""),
"comment": "aus altem TESM uebernommen", "comment": "aus altem TESM übernommen",
} }
) )
@@ -322,7 +322,7 @@ def _dhcp(data: Any, report: Report) -> tuple[dict[str, Any], dict[str, str]]:
"is_standard": int(bool(definition.get("is_standard"))), "is_standard": int(bool(definition.get("is_standard"))),
} }
if not gemeinsam["code"] or not gemeinsam["name"]: if not gemeinsam["code"] or not gemeinsam["name"]:
report.note("DHCP-Option ohne Code oder Namen uebersprungen.") report.note("DHCP-Option ohne Code oder Namen übersprungen.")
continue continue
werte = _liste(definition.get("values")) werte = _liste(definition.get("values"))
if not werte: if not werte:
@@ -339,7 +339,7 @@ def _dhcp(data: Any, report: Report) -> tuple[dict[str, Any], dict[str, str]]:
) )
if ohne_wert: if ohne_wert:
report.note( report.note(
f"{ohne_wert} eigene DHCP-Option(en) ohne Wert uebersprungen -- ohne Wert " f"{ohne_wert} eigene DHCP-Option(en) ohne Wert übersprungen – ohne Wert "
"gibt es nichts zu senden. Bei Bedarf von Hand anlegen." "gibt es nichts zu senden. Bei Bedarf von Hand anlegen."
) )
@@ -405,7 +405,7 @@ def convert(legacy: dict[str, Any]) -> tuple[dict[str, Any], Report]:
report.note( report.note(
"Diese Zugangsdaten sind zugeordnet, fehlen aber in der Datei und bleiben " "Diese Zugangsdaten sind zugeordnet, fehlen aber in der Datei und bleiben "
f"deshalb leer: {', '.join(fehlend)}. Beim Export im alten TESM die " f"deshalb leer: {', '.join(fehlend)}. Beim Export im alten TESM die "
"Kategorie 'Zugangsdaten' mitwaehlen." "Kategorie 'Zugangsdaten' mitwählen."
) )
for schluessel, grund in KNOWN_UNSUPPORTED.items(): for schluessel, grund in KNOWN_UNSUPPORTED.items():
@@ -413,12 +413,12 @@ def convert(legacy: dict[str, Any]) -> tuple[dict[str, Any], Report]:
report.note(grund) report.note(grund)
unbekannt = sorted(set(legacy) - set(SUPPORTED) - set(KNOWN_UNSUPPORTED)) unbekannt = sorted(set(legacy) - set(SUPPORTED) - set(KNOWN_UNSUPPORTED))
if unbekannt: if unbekannt:
report.note(f"Unbekannte Abschnitte uebersprungen: {', '.join(unbekannt)}.") report.note(f"Unbekannte Abschnitte übersprungen: {', '.join(unbekannt)}.")
if not payload: if not payload:
raise LegacyTransferError( raise LegacyTransferError(
"Die Datei enthaelt keine Clients, Switche, Zugangsdaten oder DHCP-Daten. " "Die Datei enthält keine Clients, Switche, Zugangsdaten oder DHCP-Daten. "
"Beim Export im alten TESM diese Kategorien mitwaehlen." "Beim Export im alten TESM diese Kategorien mitwählen."
) )
return payload, report return payload, report
@@ -488,7 +488,7 @@ def _entschluessler(root: Path, report: Report) -> Any:
schluesseldatei = root / LEGACY_FERNET_NAME schluesseldatei = root / LEGACY_FERNET_NAME
if not schluesseldatei.is_file(): if not schluesseldatei.is_file():
report.note( report.note(
f"{LEGACY_FERNET_NAME} nicht gefunden -- die Passwoerter der Zugangsdaten " f"{LEGACY_FERNET_NAME} nicht gefunden – die Passwörter der Zugangsdaten "
"bleiben leer und sind nachzutragen." "bleiben leer und sind nachzutragen."
) )
return lambda _token: "" return lambda _token: ""
@@ -499,7 +499,7 @@ def _entschluessler(root: Path, report: Report) -> Any:
fernet = Fernet(schluesseldatei.read_bytes().strip()) fernet = Fernet(schluesseldatei.read_bytes().strip())
except (ValueError, TypeError): except (ValueError, TypeError):
report.note( report.note(
f"{LEGACY_FERNET_NAME} ist unlesbar -- die Passwoerter der Zugangsdaten " f"{LEGACY_FERNET_NAME} ist unlesbar – die Passwörter der Zugangsdaten "
"bleiben leer und sind nachzutragen." "bleiben leer und sind nachzutragen."
) )
return lambda _token: "" return lambda _token: ""
@@ -515,8 +515,8 @@ def _entschluessler(root: Path, report: Report) -> Any:
gescheitert.append(1) gescheitert.append(1)
if len(gescheitert) == 1: if len(gescheitert) == 1:
report.note( report.note(
"Mindestens ein Passwort liess sich mit dem gefundenen Schluessel " "Mindestens ein Passwort ließ sich mit dem gefundenen Schlüssel "
"nicht entschluesseln -- es bleibt leer." "nicht entschlüsseln – es bleibt leer."
) )
return "" return ""
@@ -643,7 +643,7 @@ def read_installation(root: Path) -> tuple[dict[str, Any], Report]:
payload = {key: value for key, value in payload.items() if value} payload = {key: value for key, value in payload.items() if value}
if not payload: if not payload:
raise LegacyTransferError( raise LegacyTransferError(
f"In {root} stehen keine uebernehmbaren Daten (Clients, Switche, " f"In {root} stehen keine übernehmbaren Daten (Clients, Switche, "
"Zugangsdaten, DHCP)." "Zugangsdaten, DHCP)."
) )
for abschnitt, inhalt in payload.items(): for abschnitt, inhalt in payload.items():
+32 -32
View File
@@ -110,47 +110,47 @@ def is_noise(line: str) -> bool:
HINTS: tuple[tuple[str, str], ...] = ( HINTS: tuple[tuple[str, str], ...] = (
( (
"incorrect password attempt", "incorrect password attempt",
"Das Passwort aus den Zugangsdaten wurde von 'sudo' abgewiesen. Fuer die " "Das Passwort aus den Zugangsdaten wurde von 'sudo' abgewiesen. Für die "
"SSH-Anmeldung hat es gereicht -- 'sudo' auf diesem Geraet verlangt also ein " "SSH-Anmeldung hat es gereicht – 'sudo' auf diesem Gerät verlangt also ein "
"anderes Passwort, oder das Konto steht nicht in der sudoers-Datei.", "anderes Passwort, oder das Konto steht nicht in der sudoers-Datei.",
), ),
( (
"is not in the sudoers file", "is not in the sudoers file",
"Das Wartungskonto darf auf diesem Geraet kein 'sudo' ausfuehren. Auf dem " "Das Wartungskonto darf auf diesem Gerät kein 'sudo' ausführen. Auf dem "
"Zielgeraet eintragen, z. B. in /etc/sudoers.d/tesm-wartung: " "Zielgerät eintragen, z. B. in /etc/sudoers.d/tesm-wartung: "
"'<benutzer> ALL=(root) /usr/bin/apt-get, /sbin/reboot'. Mit vorangestelltem " "'<benutzer> ALL=(root) /usr/bin/apt-get, /sbin/reboot'. Mit vorangestelltem "
"NOPASSWD: entfaellt die Passwortabfrage ganz.", "NOPASSWD: entfällt die Passwortabfrage ganz.",
), ),
( (
"a password is required", "a password is required",
"'sudo' verlangt ein Passwort, und in den Zugangsdaten dieses Geraets ist keines " "'sudo' verlangt ein Passwort, und in den Zugangsdaten dieses Geräts ist keines "
"hinterlegt. Tragen Sie dort das Passwort des Wartungskontos ein -- TESM reicht " "hinterlegt. Tragen Sie dort das Passwort des Wartungskontos ein | TESM reicht "
"es ueber die bereits verschluesselte SSH-Verbindung an 'sudo' weiter.", "es über die bereits verschlüsselte SSH-Verbindung an 'sudo' weiter.",
), ),
( (
"no tty present", "no tty present",
"Das Wartungskonto verlangt fuer 'sudo' ein Terminal. In der sudoers-Datei des " "Das Wartungskonto verlangt für 'sudo' ein Terminal. In der sudoers-Datei des "
"Zielgeraets 'Defaults requiretty' entfernen oder fuer dieses Konto abschalten.", "Zielgeräts 'Defaults requiretty' entfernen oder für dieses Konto abschalten.",
), ),
( (
"command not found", "command not found",
"Das Kommando gibt es auf dem Zielgeraet nicht. Die Wartung erwartet ein " "Das Kommando gibt es auf dem Zielgerät nicht. Die Wartung erwartet ein "
"Debian- oder Ubuntu-System mit apt-get.", "Debian- oder Ubuntu-System mit apt-get.",
), ),
( (
"Access is denied", "Access is denied",
"Das Konto darf auf diesem Windows-Geraet keinen Neustart ausloesen. Es muss " "Das Konto darf auf diesem Windows-Gerät keinen Neustart auslösen. Es muss "
"Mitglied der lokalen Administratoren sein.", "Mitglied der lokalen Administratoren sein.",
), ),
( (
"is not recognized as an internal or external command", "is not recognized as an internal or external command",
"Das Kommando gibt es auf diesem Geraet nicht. Ist bei den Zugangsdaten die " "Das Kommando gibt es auf diesem Gerät nicht. Ist bei den Zugangsdaten die "
"richtige Kategorie gewaehlt (Linux-Client bzw. Windows-Client)?", "richtige Kategorie gewählt (Linux-Client bzw. Windows-Client)?",
), ),
( (
"Could not get lock", "Could not get lock",
"Ein anderer Paketvorgang laeuft bereits auf dem Zielgeraet (etwa " "Ein anderer Paketvorgang läuft bereits auf dem Zielgerät (etwa "
"unattended-upgrades). Spaeter erneut versuchen.", "unattended-upgrades). Später erneut versuchen.",
), ),
) )
@@ -256,8 +256,8 @@ def mark_orphaned_jobs(conn: sqlite3.Connection) -> int:
"UPDATE jobs SET state='unknown', message=?, finished_at=?, updated_at=? " "UPDATE jobs SET state='unknown', message=?, finished_at=?, updated_at=? "
"WHERE state IN ('pending','running')", "WHERE state IN ('pending','running')",
( (
"Der Dienst wurde waehrend des Laufs neu gestartet. Was auf dem Zielgeraet " "Der Dienst wurde während des Laufs neu gestartet. Was auf dem Zielgerät "
"passiert ist, laesst sich von hier nicht mehr feststellen.", "passiert ist, lässt sich von hier nicht mehr feststellen.",
_now(), _now(),
_now(), _now(),
), ),
@@ -293,7 +293,7 @@ def _run_remote_command(
channel.sendall(stdin_text.encode("utf-8")) channel.sendall(stdin_text.encode("utf-8"))
channel.shutdown_write() channel.shutdown_write()
except Exception as exc: # noqa: BLE001 except Exception as exc: # noqa: BLE001
on_line(f"[Eingabe konnte nicht uebergeben werden: {exc}]") on_line(f"[Eingabe konnte nicht übergeben werden: {exc}]")
deadline = time.monotonic() + timeout deadline = time.monotonic() + timeout
pending = b"" pending = b""
while True: while True:
@@ -308,7 +308,7 @@ def _run_remote_command(
elif channel.exit_status_ready(): elif channel.exit_status_ready():
break break
elif time.monotonic() > deadline: elif time.monotonic() > deadline:
on_line("[Zeitlimit ueberschritten -- Verbindung wird getrennt]") on_line("[Zeitlimit überschritten | Verbindung wird getrennt]")
channel.close() channel.close()
return 124 return 124
else: else:
@@ -341,14 +341,14 @@ def _sudo_mode(
except Exception: # noqa: BLE001 - dann eben der Weg mit Passwort except Exception: # noqa: BLE001 - dann eben der Weg mit Passwort
status = 1 status = 1
if status == 0: if status == 0:
on_line("sudo ist ohne Passwort moeglich.") on_line("sudo ist ohne Passwort möglich.")
return SUDO_PASSWORDLESS, "" return SUDO_PASSWORDLESS, ""
if not password: if not password:
on_line( on_line(
"sudo verlangt ein Passwort, in den Zugangsdaten ist keines hinterlegt." "sudo verlangt ein Passwort, in den Zugangsdaten ist keines hinterlegt."
) )
return SUDO_PASSWORDLESS, "" return SUDO_PASSWORDLESS, ""
on_line("sudo verlangt ein Passwort -- es wird aus den Zugangsdaten uebergeben.") on_line("sudo verlangt ein Passwort – es wird aus den Zugangsdaten übergeben.")
return SUDO_WITH_PASSWORD, password + "\n" return SUDO_WITH_PASSWORD, password + "\n"
@@ -411,7 +411,7 @@ def run_update(
) )
if status != 0: if status != 0:
failed = True failed = True
emit(f"Schritt {label!r} endete mit Rueckgabewert {status}.") emit(f"Schritt {label!r} endete mit Rückgabewert {status}.")
problem = hint_for("\n".join(collected)) problem = hint_for("\n".join(collected))
if problem: if problem:
emit("") emit("")
@@ -465,7 +465,7 @@ def _reboot_windows(
Rueckkehr danach ist dasselbe -- und das steht beim Aufrufer. Rueckkehr danach ist dasselbe -- und das steht beim Aufrufer.
""" """
host = str(device["ip"]) host = str(device["ip"])
emit(f"Windows-Geraet -- Neustart ueber RPC an {host} (Port {WINDOWS_PROBE_PORT}).") emit(f"Windows-Gerät | Neustart über RPC an {host} (Port {WINDOWS_PROBE_PORT}).")
result = windowsops.reboot(host, username, password) result = windowsops.reboot(host, username, password)
for line in result.output.splitlines(): for line in result.output.splitlines():
@@ -582,13 +582,13 @@ def run_reboot(
sudo, sudo_stdin = _sudo_mode(transport, password, emit) sudo, sudo_stdin = _sudo_mode(transport, password, emit)
command = REBOOT_COMMAND.format(sudo=sudo) command = REBOOT_COMMAND.format(sudo=sudo)
try: try:
emit(f"Neustart wird ausgeloest ({command})") emit(f"Neustart wird ausgelöst ({command})")
try: try:
_run_remote_command( _run_remote_command(
transport, command, timeout=20, on_line=capture, stdin_text=sudo_stdin transport, command, timeout=20, on_line=capture, stdin_text=sudo_stdin
) )
except Exception as exc: # noqa: BLE001 - Abbruch ist hier normal except Exception as exc: # noqa: BLE001 - Abbruch ist hier normal
emit(f"Verbindung getrennt ({exc}) -- das ist beim Neustart zu erwarten.") emit(f"Verbindung getrennt ({exc}) – das ist beim Neustart zu erwarten.")
finally: finally:
try: try:
transport.close() transport.close()
@@ -621,7 +621,7 @@ def run_reboot(
) )
return return
update_job(database, job_id, progress=40, message="Warte auf Rueckkehr des Geraets") update_job(database, job_id, progress=40, message="Warte auf Rückkehr des Geräts")
time.sleep(REBOOT_GRACE_SECONDS) time.sleep(REBOOT_GRACE_SECONDS)
deadline = time.monotonic() + wait_seconds deadline = time.monotonic() + wait_seconds
ssh_port = probe_port ssh_port = probe_port
@@ -632,7 +632,7 @@ def run_reboot(
# Zeitueberschreitung, obwohl das Geraet zurueck war. Ausserdem ist # Zeitueberschreitung, obwohl das Geraet zurueck war. Ausserdem ist
# "SSH antwortet wieder" die Aussage, auf die es hier ankommt. # "SSH antwortet wieder" die Aussage, auf die es hier ankommt.
if tcp_reachable(host, ssh_port): if tcp_reachable(host, ssh_port):
emit(f"Geraet ist wieder erreichbar (Port {ssh_port} antwortet).") emit(f"Gerät ist wieder erreichbar (Port {ssh_port} antwortet).")
update_job( update_job(
database, database,
job_id, job_id,
@@ -656,7 +656,7 @@ def run_reboot(
result="ok", result="ok",
actor=actor, actor=actor,
duration_ms=elapsed(), duration_ms=elapsed(),
detail="Neustart ueber SSH, Geraet ist zurueckgekehrt.", detail="Neustart über SSH, Gerät ist zurückgekehrt.",
method=method, method=method,
) )
return return
@@ -668,7 +668,7 @@ def run_reboot(
job_id, job_id,
state="failed", state="failed",
progress=100, progress=100,
message=f"Geraet nicht zurueckgekehrt ({host}:{ssh_port} antwortet nicht).", message=f"Gerät nicht zurückgekehrt ({host}:{ssh_port} antwortet nicht).",
finished=True, finished=True,
) )
_log_restart( _log_restart(
@@ -678,7 +678,7 @@ def run_reboot(
actor=actor, actor=actor,
duration_ms=elapsed(), duration_ms=elapsed(),
detail=( detail=(
f"Neustart ausgeloest, aber {host}:{ssh_port} hat innerhalb von " f"Neustart ausgelöst, aber {host}:{ssh_port} hat innerhalb von "
f"{wait_seconds}s nicht wieder geantwortet." f"{wait_seconds}s nicht wieder geantwortet."
), ),
method=method, method=method,
@@ -718,7 +718,7 @@ def _log_restart(
method=method, method=method,
) )
except Exception: # noqa: BLE001 - Protokollieren darf den Auftrag nie kippen except Exception: # noqa: BLE001 - Protokollieren darf den Auftrag nie kippen
logger.exception("Neustart konnte nicht protokolliert werden (Geraet %s)", device_id) logger.exception("Neustart konnte nicht protokolliert werden (Gerät %s)", device_id)
def start_background(target: Callable[..., None], **kwargs: Any) -> None: def start_background(target: Callable[..., None], **kwargs: Any) -> None:
+7 -7
View File
@@ -115,7 +115,7 @@ def ping(host: str, *, timeout: int = PING_TIMEOUT_SECONDS) -> PingResult:
check=False, check=False,
) )
except subprocess.TimeoutExpired: except subprocess.TimeoutExpired:
return PingResult(False, detail="Zeitlimit ueberschritten") return PingResult(False, detail="Zeitlimit überschritten")
except OSError as exc: except OSError as exc:
return PingResult(False, detail=str(exc)) return PingResult(False, detail=str(exc))
@@ -274,8 +274,8 @@ METHOD_SSH = "ssh"
METHOD_RPC = "rpc" METHOD_RPC = "rpc"
METHOD_LABELS = { METHOD_LABELS = {
METHOD_POE: "PoE-Port", METHOD_POE: "PoE-Port",
METHOD_SSH: "Neustart ueber SSH", METHOD_SSH: "Neustart über SSH",
METHOD_RPC: "Neustart ueber RPC", METHOD_RPC: "Neustart über RPC",
} }
#: Zustand zwischen ausgeloestem Neustart und Rueckkehr. #: Zustand zwischen ausgeloestem Neustart und Rueckkehr.
@@ -406,7 +406,7 @@ def restart_device_poe(
if not port or not switch_ip: if not port or not switch_ip:
message = ( message = (
"Kein Switch oder kein Port zugeordnet -- ein PoE-Neustart ist nicht moeglich." "Kein Switch oder kein Port zugeordnet – ein PoE-Neustart ist nicht möglich."
) )
with write(): with write():
record_restart( record_restart(
@@ -422,7 +422,7 @@ def restart_device_poe(
return {"ok": False, "message": message, "transcript": ""} return {"ok": False, "message": message, "transcript": ""}
if not credential_id: if not credential_id:
message = f"Fuer Switch {device.get('switch_hostname') or switch_ip} sind keine Zugangsdaten hinterlegt." message = f"Für Switch {device.get('switch_hostname') or switch_ip} sind keine Zugangsdaten hinterlegt."
with write(): with write():
record_restart( record_restart(
conn, conn,
@@ -486,7 +486,7 @@ def restart_device_poe(
live.error("poe", f"{name}: PoE-Neustart fehlgeschlagen -- {outcome.error}") live.error("poe", f"{name}: PoE-Neustart fehlgeschlagen -- {outcome.error}")
return { return {
"ok": outcome.ok, "ok": outcome.ok,
"message": outcome.error or "PoE-Neustart durchgefuehrt.", "message": outcome.error or "PoE-Neustart durchgeführt.",
"transcript": outcome.transcript, "transcript": outcome.transcript,
} }
@@ -567,7 +567,7 @@ def run_sweep(
report.skipped_unlicensed += 1 report.skipped_unlicensed += 1
live.warning( live.warning(
"poe", "poe",
f"{name}: automatischer PoE-Neustart uebersprungen -- keine gueltige Lizenz.", f"{name}: automatischer PoE-Neustart übersprungen – keine gültige Lizenz.",
) )
continue continue
report.restarts_triggered += 1 report.restarts_triggered += 1
+6 -6
View File
@@ -54,24 +54,24 @@ DEFINITIONS: dict[str, Method] = {
POWER_ON: Method( POWER_ON: Method(
key=METHOD_POE, key=METHOD_POE,
label="Einschalten (PoE)", label="Einschalten (PoE)",
hint="Schaltet den Switch-Port aus und wieder ein. Bei einem Geraet, das nicht " hint="Schaltet den Switch-Port aus und wieder ein. Bei einem Gerät, das nicht "
"antwortet, ist das der einzige Weg, der noch etwas bewirkt.", "antwortet, ist das der einzige Weg, der noch etwas bewirkt.",
), ),
METHOD_POE: Method( METHOD_POE: Method(
key=METHOD_POE, key=METHOD_POE,
label="PoE-Port", label="PoE-Port",
hint="Schaltet den Switch-Port kurz ab. Wirkt auch, wenn das Geraet haengt.", hint="Schaltet den Switch-Port kurz ab. Wirkt auch, wenn das Gerät hängt.",
), ),
METHOD_SSH: Method( METHOD_SSH: Method(
key=METHOD_SSH, key=METHOD_SSH,
label="Neustart ueber SSH", label="Neustart über SSH",
hint="Faehrt das Betriebssystem sauber herunter. Setzt ein antwortendes Geraet " hint="Fährt das Betriebssystem sauber herunter. Setzt ein antwortendes Gerät "
"und einen freigegebenen Host-Schluessel voraus.", "und einen freigegebenen Host-Schlüssel voraus.",
licensed=True, licensed=True,
), ),
METHOD_RPC: Method( METHOD_RPC: Method(
key=METHOD_RPC, key=METHOD_RPC,
label="Neustart ueber RPC", label="Neustart über RPC",
hint="Windows: wie 'shutdown /m'. Setzt offene Datei- und Druckerfreigabe voraus.", hint="Windows: wie 'shutdown /m'. Setzt offene Datei- und Druckerfreigabe voraus.",
licensed=True, licensed=True,
), ),
+1 -1
View File
@@ -128,7 +128,7 @@ class ShellSession:
elif time.monotonic() > deadline: elif time.monotonic() > deadline:
channel.close() channel.close()
raise SshError( raise SshError(
f"Das Kommando hat das Zeitlimit von {timeout}s ueberschritten." f"Das Kommando hat das Zeitlimit von {timeout}s überschritten."
) )
else: else:
time.sleep(0.05) time.sleep(0.05)
+11 -11
View File
@@ -48,8 +48,8 @@ class UnknownHostKey(SshError):
def __init__(self, host: str, port: int, key_type: str, fingerprint: str) -> None: def __init__(self, host: str, port: int, key_type: str, fingerprint: str) -> None:
super().__init__( super().__init__(
f"Der SSH-Host-Schluessel von {host}:{port} ist unbekannt " f"Der SSH-Host-Schlüssel von {host}:{port} ist unbekannt "
f"({key_type}, {fingerprint}). Bitte pruefen und ausdruecklich freigeben." f"({key_type}, {fingerprint}). Bitte prüfen und ausdrücklich freigeben."
) )
self.host = host self.host = host
self.port = port self.port = port
@@ -62,9 +62,9 @@ class HostKeyChanged(SshError):
def __init__(self, host: str, port: int, expected: str, received: str) -> None: def __init__(self, host: str, port: int, expected: str, received: str) -> None:
super().__init__( super().__init__(
f"Der SSH-Host-Schluessel von {host}:{port} hat sich geaendert " f"Der SSH-Host-Schlüssel von {host}:{port} hat sich geändert "
f"(erwartet {expected}, erhalten {received}). Der Aufbau wurde abgebrochen. " f"(erwartet {expected}, erhalten {received}). Der Aufbau wurde abgebrochen. "
"Das kann ein Geraetetausch sein -- oder ein Angriff." "Das kann ein Gerätetausch sein – oder ein Angriff."
) )
self.host = host self.host = host
self.port = port self.port = port
@@ -187,17 +187,17 @@ def describe_probe(
if known is None: if known is None:
return ( return (
"info", "info",
f"Host-Schluessel gelesen: {record.key_type} {record.fingerprint}. " f"Host-Schlüssel gelesen: {record.key_type} {record.fingerprint}. "
"Bitte gegen eine unabhaengige Quelle pruefen und dann freigeben.", "Bitte gegen eine unabhängige Quelle prüfen und dann freigeben.",
) )
if known.fingerprint != record.fingerprint: if known.fingerprint != record.fingerprint:
return ( return (
"danger", "danger",
f"Achtung: der Schluessel weicht vom gespeicherten ab. Gespeichert " f"Achtung: der Schlüssel weicht vom gespeicherten ab. Gespeichert "
f"{known.fingerprint}, gelesen {record.fingerprint}. Das kann ein " f"{known.fingerprint}, gelesen {record.fingerprint}. Das kann ein "
"Geraetetausch sein -- oder ein Angriff.", "Gerätetausch sein – oder ein Angriff.",
) )
return ("success", "Der Host-Schluessel stimmt mit dem gespeicherten ueberein.") return ("success", "Der Host-Schlüssel stimmt mit dem gespeicherten überein.")
def probe_host_key(host: str, port: int = 22, *, timeout: int = CONNECT_TIMEOUT) -> HostKeyRecord: def probe_host_key(host: str, port: int = 22, *, timeout: int = CONNECT_TIMEOUT) -> HostKeyRecord:
@@ -281,8 +281,8 @@ def open_transport(
raise SshError( raise SshError(
f"SSH-Handshake mit {host}:{port} fehlgeschlagen ({exc}). " f"SSH-Handshake mit {host}:{port} fehlgeschlagen ({exc}). "
f"Freigegeben sind nur {', '.join(sorted(set(trusted_types)))} -- " f"Freigegeben sind nur {', '.join(sorted(set(trusted_types)))} -- "
"bietet das Geraet inzwischen einen anderen Schluesseltyp an, muss der " "bietet das Gerät inzwischen einen anderen Schlüsseltyp an, muss der "
"Schluessel neu ausgelesen und freigegeben werden." "Schlüssel neu ausgelesen und freigegeben werden."
) from exc ) from exc
raise SshError(f"SSH-Handshake mit {host}:{port} fehlgeschlagen ({exc}).") from exc raise SshError(f"SSH-Handshake mit {host}:{port} fehlgeschlagen ({exc}).") from exc
+4 -4
View File
@@ -149,7 +149,7 @@ class SwitchCli:
``tolerate_error`` (z. B. ``no page``, das nicht jede Firmware kennt). ``tolerate_error`` (z. B. ``no page``, das nicht jede Firmware kennt).
""" """
if self._channel is None: if self._channel is None:
raise SshError("Die Sitzung ist nicht geoeffnet.") raise SshError("Die Sitzung ist nicht geöffnet.")
self.transcript.append(f"\n$ {command}\n") self.transcript.append(f"\n$ {command}\n")
self._channel.send(command + "\r\n") self._channel.send(command + "\r\n")
output = self._read_until_prompt(timeout=timeout) output = self._read_until_prompt(timeout=timeout)
@@ -207,7 +207,7 @@ class SwitchCli:
off_state = self.poe_state(port) off_state = self.poe_state(port)
if off_state == "enabled": if off_state == "enabled":
result.error = ( result.error = (
"Der Switch meldet den Port nach dem Abschalten weiter als aktiv -- " "Der Switch meldet den Port nach dem Abschalten weiter als aktiv – "
"der Neustart hat nicht stattgefunden." "der Neustart hat nicht stattgefunden."
) )
result.transcript = self.full_transcript result.transcript = self.full_transcript
@@ -245,11 +245,11 @@ class SwitchCli:
#: Kommandos, die die Oberflaeche ohne freies Eingabefeld anbietet. #: Kommandos, die die Oberflaeche ohne freies Eingabefeld anbietet.
SAFE_COMMANDS: tuple[tuple[str, str], ...] = ( SAFE_COMMANDS: tuple[tuple[str, str], ...] = (
("show system information", "Systeminformationen"), ("show system information", "Systeminformationen"),
("show power-over-ethernet brief", "PoE-Uebersicht aller Ports"), ("show power-over-ethernet brief", "PoE-Übersicht aller Ports"),
("show interfaces brief", "Portstatus"), ("show interfaces brief", "Portstatus"),
("show vlans", "VLAN-Uebersicht"), ("show vlans", "VLAN-Uebersicht"),
("show mac-address", "MAC-Adresstabelle"), ("show mac-address", "MAC-Adresstabelle"),
("show lldp info remote-device", "Nachbargeraete (LLDP)"), ("show lldp info remote-device", "Nachbargeräte (LLDP)"),
("show version", "Firmware-Version"), ("show version", "Firmware-Version"),
("show logging", "Switch-Protokoll"), ("show logging", "Switch-Protokoll"),
) )
+53 -4
View File
@@ -50,13 +50,13 @@ def unseal(raw: bytes | str, passphrase: str) -> tuple[dict[str, Any], dict[str,
CATEGORIES: tuple[Category, ...] = ( CATEGORIES: tuple[Category, ...] = (
Category("devices", "Clients", "Geraetestammdaten inklusive Switch- und Portzuordnung"), Category("devices", "Clients", "Gerätestammdaten inklusive Switch- und Portzuordnung"),
Category("switches", "Switche", "Switch-Inventar"), Category("switches", "Switche", "Switch-Inventar"),
Category( Category(
"credentials", "credentials",
"Zugangsdaten", "Zugangsdaten",
"SSH-Logins. Die Passwoerter liegen im Export im Klartext -- geschuetzt " "SSH-Logins. Die Passwörter liegen im Export im Klartext – geschützt "
"ausschliesslich durch die Passphrase.", "ausschließlich durch die Passphrase.",
sensitive=True, sensitive=True,
), ),
Category("dhcp", "DHCP-Konfiguration", "Subnetze, Optionen und manuelle Reservierungen"), Category("dhcp", "DHCP-Konfiguration", "Subnetze, Optionen und manuelle Reservierungen"),
@@ -64,6 +64,13 @@ CATEGORIES: tuple[Category, ...] = (
Category("users", "Benutzer", "Lokale Konten mit Passwort-Hash", admin_only=True), Category("users", "Benutzer", "Lokale Konten mit Passwort-Hash", admin_only=True),
Category("groups", "Gruppen und Rechte", "Rechtematrix", admin_only=True), Category("groups", "Gruppen und Rechte", "Rechtematrix", admin_only=True),
Category("settings", "Einstellungen", "Laufzeiteinstellungen der Anwendung", admin_only=True), Category("settings", "Einstellungen", "Laufzeiteinstellungen der Anwendung", admin_only=True),
Category(
"signatures",
"Unterschriften",
"Pruefsummen unterschriebener Dokumente. Ohne sie sagt die Vorschau nach einer "
"Wiederherstellung zu jeder alten Fassung \"unbekannt\" – auch zu einer "
"veraenderten.",
),
) )
CATEGORY_BY_KEY = {category.key: category for category in CATEGORIES} CATEGORY_BY_KEY = {category.key: category for category in CATEGORIES}
@@ -123,7 +130,7 @@ def build_export(
str(row["secret_encrypted"]), aad=aad_for_credential(int(row["id"])) str(row["secret_encrypted"]), aad=aad_for_credential(int(row["id"]))
) )
except Exception: # noqa: BLE001 except Exception: # noqa: BLE001
entry["secret_error"] = "nicht entschluesselbar" entry["secret_error"] = "nicht entschlüsselbar"
entries.append(entry) entries.append(entry)
payload["credentials"] = entries payload["credentials"] = entries
if "dhcp" in wanted: if "dhcp" in wanted:
@@ -186,6 +193,16 @@ def build_export(
# Sicherung -- sonst wuerde ein Restore eine fremde Aktivierung erben. # Sicherung -- sonst wuerde ein Restore eine fremde Aktivierung erben.
if not str(row["key"]).startswith("license_") if not str(row["key"]).startswith("license_")
} }
if "signatures" in wanted:
# Das Verzeichnis der Unterschriften. Der Beleg selbst bleibt das
# Aenderungsprotokoll (Hash-Kette) und das Journal neben dem Archiv --
# beides wandert nicht in die Sicherung. Ohne diese Zeilen waere nach
# einer Wiederherstellung jede unterschriebene Fassung "unbekannt".
payload["signatures"] = Database.all(
conn,
"SELECT sha256, share_label, path, signed_at, actor, names "
"FROM document_signatures ORDER BY id",
)
return payload return payload
@@ -625,6 +642,38 @@ def apply_import(
(user_id, int(group_id)), (user_id, int(group_id)),
) )
if "signatures" in wanted:
for eintrag in payload.get("signatures") or []:
if not isinstance(eintrag, dict):
continue
summe = str(eintrag.get("sha256") or "").strip()
pfad = str(eintrag.get("path") or "").strip()
if not summe:
continue
# Zweimal einlesen darf nichts verdoppeln: verglichen wird ueber
# Pruefsumme und Ablageort, genau wie beim Journal.
vorhanden = Database.value(
conn,
"SELECT 1 FROM document_signatures WHERE sha256=? AND path=? LIMIT 1",
(summe, pfad),
)
if vorhanden:
bump("signatures", "skipped")
continue
conn.execute(
"INSERT INTO document_signatures (sha256, share_label, path, signed_at, "
"actor, names, audit_id) VALUES (?,?,?,?,?,?,NULL)",
(
summe,
str(eintrag.get("share_label") or ""),
pfad,
str(eintrag.get("signed_at") or stamp),
str(eintrag.get("actor") or ""),
str(eintrag.get("names") or ""),
),
)
bump("signatures", "created")
if "settings" in wanted: if "settings" in wanted:
for key, value in (payload.get("settings") or {}).items(): for key, value in (payload.get("settings") or {}).items():
if str(key).startswith("license_"): if str(key).startswith("license_"):
+20 -20
View File
@@ -51,28 +51,28 @@ def available() -> bool:
HINTS: tuple[tuple[str, str], ...] = ( HINTS: tuple[tuple[str, str], ...] = (
( (
"NT_STATUS_LOGON_FAILURE", "NT_STATUS_LOGON_FAILURE",
"Benutzername oder Passwort wurden vom Windows-Geraet abgewiesen. Bei einem " "Benutzername oder Passwort wurden vom Windows-Gerät abgewiesen. Bei einem "
"Domaenenkonto die Form DOMAENE\\benutzer verwenden -- mit *einem* " "Domänenkonto die Form DOMAENE\\benutzer verwenden – mit *einem* "
"Backslash.", "Backslash.",
), ),
( (
"NT_STATUS_ACCESS_DENIED", "NT_STATUS_ACCESS_DENIED",
"Das Konto darf auf diesem Geraet keinen Neustart ausloesen. Es braucht das Recht " "Das Konto darf auf diesem Gerät keinen Neustart auslösen. Es braucht das Recht "
"'Herunterfahren von einem Remotesystem aus' -- Mitglied der lokalen " "'Herunterfahren von einem Remotesystem aus' | Mitglied der lokalen "
"Administratoren genuegt dafuer in der Regel.", "Administratoren genügt dafür in der Regel.",
), ),
( (
"NT_STATUS_CONNECTION_REFUSED", "NT_STATUS_CONNECTION_REFUSED",
"Das Geraet nimmt keine RPC-Verbindung an. Die Datei- und Druckerfreigabe muss " "Das Gerät nimmt keine RPC-Verbindung an. Die Datei- und Druckerfreigabe muss "
"aktiv und Port 445 in der Windows-Firewall freigegeben sein.", "aktiv und Port 445 in der Windows-Firewall freigegeben sein.",
), ),
( (
"NT_STATUS_IO_TIMEOUT", "NT_STATUS_IO_TIMEOUT",
"Keine Antwort auf Port 445. Ist das Geraet erreichbar und die Firewall offen?", "Keine Antwort auf Port 445. Ist das Gerät erreichbar und die Firewall offen?",
), ),
( (
"NT_STATUS_HOST_UNREACHABLE", "NT_STATUS_HOST_UNREACHABLE",
"Das Geraet ist nicht erreichbar.", "Das Gerät ist nicht erreichbar.",
), ),
# Diese Meldung liest sich wie ein technisches Detail, hat aber fast immer # Diese Meldung liest sich wie ein technisches Detail, hat aber fast immer
# eine einzige Ursache: das Konto ist auf dem Zielgeraet kein Administrator. # eine einzige Ursache: das Konto ist auf dem Zielgeraet kein Administrator.
@@ -81,14 +81,14 @@ HINTS: tuple[tuple[str, str], ...] = (
# bekommt ein Standardkonto die winreg-Pipe gar nicht zu sehen. # bekommt ein Standardkonto die winreg-Pipe gar nicht zu sehen.
( (
"pipe winreg", "pipe winreg",
"Das Konto hat auf diesem Geraet keine Administratorrechte. Ein Neustart ueber " "Das Konto hat auf diesem Gerät keine Administratorrechte. Ein Neustart über "
"RPC braucht sie: die Pipe, ueber die er laeuft, ist fuer Standardkonten nicht " "RPC braucht sie: die Pipe, über die er läuft, ist für Standardkonten nicht "
"sichtbar. Ein Konto der lokalen Administratoren hinterlegen -- pruefen laesst " "sichtbar. Ein Konto der lokalen Administratoren hinterlegen – prüfen lässt "
"sich das mit 'net rpc service status RemoteRegistry'.", "sich das mit 'net rpc service status RemoteRegistry'.",
), ),
( (
"NT_STATUS_OBJECT_NAME_NOT_FOUND", "NT_STATUS_OBJECT_NAME_NOT_FOUND",
"Der benoetigte RPC-Dienst antwortet auf dem Geraet nicht. Meist fehlen dem " "Der benötigte RPC-Dienst antwortet auf dem Gerät nicht. Meist fehlen dem "
"Konto die Administratorrechte; sonst ist der Dienst 'Remoteregistrierung' " "Konto die Administratorrechte; sonst ist der Dienst 'Remoteregistrierung' "
"gestoppt.", "gestoppt.",
), ),
@@ -143,8 +143,8 @@ def _net_with_credentials(
return RemoteResult( return RemoteResult(
False, False,
"", "",
f"Auf diesem Server fehlt das Paket {REQUIRED_PACKAGE}. Ohne 'net' laesst sich " f"Auf diesem Server fehlt das Paket {REQUIRED_PACKAGE}. Ohne 'net' lässt sich "
"ein Windows-Geraet von hier nicht ansprechen.", "ein Windows-Gerät von hier nicht ansprechen.",
) )
if not username: if not username:
return RemoteResult(False, "", "Es sind keine Zugangsdaten hinterlegt.") return RemoteResult(False, "", "Es sind keine Zugangsdaten hinterlegt.")
@@ -176,7 +176,7 @@ def _net_with_credentials(
check=False, check=False,
) )
except subprocess.TimeoutExpired: except subprocess.TimeoutExpired:
return RemoteResult(False, "", f"Zeitlimit von {timeout}s ueberschritten.") return RemoteResult(False, "", f"Zeitlimit von {timeout}s überschritten.")
except OSError as exc: except OSError as exc:
return RemoteResult(False, "", str(exc)) return RemoteResult(False, "", str(exc))
return completed.returncode, (completed.stdout + completed.stderr).strip() return completed.returncode, (completed.stdout + completed.stderr).strip()
@@ -190,7 +190,7 @@ def _net_with_credentials(
stream.flush() stream.flush()
os.fsync(stream.fileno()) os.fsync(stream.fileno())
except OSError: # pragma: no cover - defensiv except OSError: # pragma: no cover - defensiv
logger.warning("Anmeldedatei konnte nicht ueberschrieben werden: %s", path) logger.warning("Anmeldedatei konnte nicht überschrieben werden: %s", path)
try: try:
os.unlink(path) os.unlink(path)
except OSError: # pragma: no cover - defensiv except OSError: # pragma: no cover - defensiv
@@ -236,10 +236,10 @@ def check(
return RemoteResult( return RemoteResult(
False, False,
output, output,
"Anmeldung erfolgreich, aber das Konto hat auf diesem Geraet keine " "Anmeldung erfolgreich, aber das Konto hat auf diesem Gerät keine "
"Administratorrechte. Fuer einen Neustart ueber RPC braucht es sie.", "Administratorrechte. Für einen Neustart über RPC braucht es sie.",
) )
return RemoteResult(False, output, hint_for(output) or "Pruefung fehlgeschlagen.") return RemoteResult(False, output, hint_for(output) or "Prüfung fehlgeschlagen.")
def reboot( def reboot(
@@ -275,5 +275,5 @@ def reboot(
return ergebnis return ergebnis
code, output = ergebnis code, output = ergebnis
if code == 0: if code == 0:
return RemoteResult(True, output or "Neustart ausgeloest.") return RemoteResult(True, output or "Neustart ausgelöst.")
return RemoteResult(False, output, hint_for(output)) return RemoteResult(False, output, hint_for(output))
+29 -16
View File
@@ -12,8 +12,8 @@ TESM_SETTINGS: tuple[SettingSpec, ...] = (
"monitor_interval_seconds", "monitor_interval_seconds",
300, 300,
"int", "int",
"Pruefintervall (Sekunden)", "Prüfintervall (Sekunden)",
hint="Abstand zwischen zwei Erreichbarkeitspruefungen. Lizenzpflichtig.", hint="Abstand zwischen zwei Erreichbarkeitsprüfungen. Lizenzpflichtig.",
section="monitoring", section="monitoring",
minimum=30, minimum=30,
maximum=86400, maximum=86400,
@@ -23,8 +23,8 @@ TESM_SETTINGS: tuple[SettingSpec, ...] = (
2, 2,
"int", "int",
"Fehlversuche bis zum automatischen PoE-Neustart", "Fehlversuche bis zum automatischen PoE-Neustart",
hint="Erst nach so vielen aufeinanderfolgenden Fehlschlaegen wird geschaltet. " hint="Erst nach so vielen aufeinanderfolgenden Fehlschlägen wird geschaltet. "
"Der Vorgaenger schaltete sofort beim ersten Ausfall.", "Der Vorgänger schaltete sofort beim ersten Ausfall.",
section="monitoring", section="monitoring",
minimum=1, minimum=1,
maximum=10, maximum=10,
@@ -40,8 +40,8 @@ TESM_SETTINGS: tuple[SettingSpec, ...] = (
"dashboard_public", "dashboard_public",
True, True,
"bool", "bool",
"Statusuebersicht ohne Anmeldung erreichbar", "Statusübersicht ohne Anmeldung erreichbar",
hint="Zeigt ausschliesslich Name und Erreichbarkeit aktiver Geraete -- keine " hint="Zeigt ausschließlich Name und Erreichbarkeit aktiver Geräte – keine "
"IP-Adressen, keine Switch-Zuordnung, keine Verwaltungsfunktionen.", "IP-Adressen, keine Switch-Zuordnung, keine Verwaltungsfunktionen.",
section="monitoring", section="monitoring",
), ),
@@ -74,7 +74,7 @@ TESM_SETTINGS: tuple[SettingSpec, ...] = (
"fileshare_max_age_hours", "fileshare_max_age_hours",
12, 12,
"int", "int",
"Einbindungen automatisch loesen nach (Stunden)", "Einbindungen automatisch lösen nach (Stunden)",
section="fileshare", section="fileshare",
minimum=1, minimum=1,
maximum=168, maximum=168,
@@ -93,20 +93,33 @@ TESM_SETTINGS: tuple[SettingSpec, ...] = (
"maintenance_reboot_wait", "maintenance_reboot_wait",
300, 300,
"int", "int",
"Wartezeit auf die Rueckkehr nach einem Neustart (Sekunden)", "Wartezeit auf die Rückkehr nach einem Neustart (Sekunden)",
section="maintenance", section="maintenance",
minimum=30, minimum=30,
maximum=1800, maximum=1800,
), ),
# -- Unterschriften ------------------------------------------------------ #
SettingSpec(
"signature_witness_enabled",
True,
"bool",
"Unterschriften mit dem ablegenden Konto bezeugen",
hint="Eine Zeile unter der **ersten** Unterschrift nennt das Konto, das das "
"Dokument in TESM abgelegt hat. In den Dokumenteigenschaften und im "
"Änderungsprotokoll steht es ohnehin, unabhängig von diesem Schalter. Der Name in "
"der Unterschrift ist eine Angabe des Unterschreibenden – wie auf Papier; das "
"Konto ist belegt. Eine Passwortabfrage gibt es dafür nicht.",
section="fileshare",
),
# -- Terminal ----------------------------------------------------------- # # -- Terminal ----------------------------------------------------------- #
SettingSpec( SettingSpec(
"terminal_switches_enabled", "terminal_switches_enabled",
True, True,
"bool", "bool",
"Browser-Konsole fuer Switche zulassen", "Browser-Konsole für Switche zulassen",
hint="Auch wenn aktiv: erfordert das Recht 'Ausfuehren' auf Switche, eine frische " hint="Auch wenn aktiv: erfordert das Recht 'Ausführen' auf Switche, eine frische "
"Passwortbestaetigung und wird vollstaendig protokolliert. Das Freigeben von " "Passwortbestätigung und wird vollständig protokolliert. Das Freigeben von "
"Host-Schluesseln bleibt davon unberuehrt -- es wird fuer den PoE-Neustart " "Host-Schlüsseln bleibt davon unberührt – es wird für den PoE-Neustart "
"gebraucht.", "gebraucht.",
section="monitoring", section="monitoring",
), ),
@@ -114,16 +127,16 @@ TESM_SETTINGS: tuple[SettingSpec, ...] = (
"terminal_clients_enabled", "terminal_clients_enabled",
True, True,
"bool", "bool",
"Browser-Konsole fuer Clients zulassen", "Browser-Konsole für Clients zulassen",
hint="Braucht zusaetzlich das Modul Wartung und das Recht 'Ausfuehren' auf Wartung. " hint="Braucht zusätzlich das Modul Wartung und das Recht 'Ausführen' auf Wartung. "
"Das Freigeben von Host-Schluesseln bleibt davon unberuehrt.", "Das Freigeben von Host-Schlüsseln bleibt davon unberührt.",
section="monitoring", section="monitoring",
), ),
SettingSpec( SettingSpec(
"terminal_idle_timeout", "terminal_idle_timeout",
300, 300,
"int", "int",
"Terminal bei Untaetigkeit trennen nach (Sekunden)", "Terminal bei Untätigkeit trennen nach (Sekunden)",
section="monitoring", section="monitoring",
minimum=60, minimum=60,
maximum=3600, maximum=3600,
@@ -149,10 +149,10 @@ sie getrennt von der Sicherung auf.', 'warning', 'Passphrase') }}
</div> </div>
<div class="card__body"> <div class="card__body">
<ul class="stack stack--tight fs-sm muted"> <ul class="stack stack--tight fs-sm muted">
<li>Verzeichniskonten werden nie exportiert -- sie entstehen am Ziel bei der ersten Anmeldung neu.</li> <li>Verzeichniskonten werden nie exportiert – sie entstehen am Ziel bei der ersten Anmeldung neu.</li>
<li>Lizenz- und Aktivierungszustand bleiben ausgenommen: sie gehören zur Maschine, nicht zur Sicherung.</li> <li>Lizenz- und Aktivierungszustand bleiben ausgenommen: sie gehören zur Maschine, nicht zur Sicherung.</li>
<li>Sitzungen, Ratenbegrenzung und das Änderungsprotokoll sind nicht Teil der Sicherung.</li> <li>Sitzungen, Ratenbegrenzung und das Änderungsprotokoll sind nicht Teil der Sicherung.</li>
<li>Der Zwischenstand des Imports liegt in der Datenbank -- ein Dienst-Neustall verliert ihn nicht.</li> <li>Der Zwischenstand des Imports liegt in der Datenbank – ein Dienst-Neustall verliert ihn nicht.</li>
</ul> </ul>
</div> </div>
</section> </section>
@@ -88,7 +88,7 @@
<div class="flex-1"> <div class="flex-1">
<div class="card__title">{{ icon('power', size=17) }} Neustarts</div> <div class="card__title">{{ icon('power', size=17) }} Neustarts</div>
<div class="card__sub"> <div class="card__sub">
Die letzten {{ events | length }} Neustarts -- über den PoE-Port wie per SSH Die letzten {{ events | length }} Neustarts – über den PoE-Port wie per SSH
</div> </div>
</div> </div>
</div> </div>
+1 -1
View File
@@ -72,7 +72,7 @@
<div class="flex-1"> <div class="flex-1">
<div class="card__title">{{ icon('network', size=17) }} Subnetze</div> <div class="card__title">{{ icon('network', size=17) }} Subnetze</div>
<div class="card__sub"> <div class="card__sub">
Subnetz und Maske kommen vom laufenden System -- eingegeben wird nur der Adressbereich. Subnetz und Maske kommen vom laufenden System – eingegeben wird nur der Adressbereich.
</div> </div>
</div> </div>
</div> </div>
@@ -81,11 +81,24 @@
{% if current_user.has('fileshare.delete') %} {% if current_user.has('fileshare.delete') %}
<td><label class="check"><input type="checkbox" name="paths" value="{{ entry.relative }}"><span class="sr-only">auswählen</span></label></td> <td><label class="check"><input type="checkbox" name="paths" value="{{ entry.relative }}"><span class="sr-only">auswählen</span></label></td>
{% endif %} {% endif %}
<td> {# Symbol und Name in einer Zeile: der Zeilenumbruch dazwischen
liess den Ordner aussehen wie zwei halbe Zeilen. #}
<td class="file-cell">
{{ icon('folder', size=15) }} {{ icon('folder', size=15) }}
<a href="{{ url_for('fileshare.browse', share=token, path=entry.relative) }}">{{ entry.name }}</a> <a href="{{ url_for('fileshare.browse', share=token, path=entry.relative) }}">{{ entry.name }}</a>
</td> </td>
<td class="numeric faint">&ndash;</td> {# An der Stelle der Dateigrösse: wie viel liegt darin. #}
<td class="numeric faint fs-sm">
{%- if entry.entries is not defined or entry.entries < 0 -%}
&ndash;
{%- elif entry.entries >= 500 -%}
500+ Einträge
{%- elif entry.entries == 1 -%}
1 Eintrag
{%- else -%}
{{ entry.entries }} Einträge
{%- endif -%}
</td>
<td class="fs-sm">{{ entry.modified | dt }}</td> <td class="fs-sm">{{ entry.modified | dt }}</td>
<td class="actions"></td> <td class="actions"></td>
</tr> </tr>
@@ -103,6 +116,7 @@
<button class="link-icon file-name" type="button" <button class="link-icon file-name" type="button"
data-dialog-open="file-preview" data-dialog-open="file-preview"
data-fill-Src="{{ url_for('fileshare.preview', token=token, path=entry.relative) }}" data-fill-Src="{{ url_for('fileshare.preview', token=token, path=entry.relative) }}"
data-fill-Pruefung="{{ url_for('fileshare.signature_state', token=token, path=entry.relative) }}"
data-fill-Kind="{{ entry.preview_kind }}" data-fill-Kind="{{ entry.preview_kind }}"
data-fill-Pfad="{{ entry.relative }}" data-fill-Pfad="{{ entry.relative }}"
data-fill-Name="{{ entry.name }}" data-fill-Name="{{ entry.name }}"
@@ -131,6 +145,7 @@
<button class="btn btn--sm btn--ghost" type="button" <button class="btn btn--sm btn--ghost" type="button"
data-dialog-open="file-preview" data-dialog-open="file-preview"
data-fill-Src="{{ url_for('fileshare.preview', token=token, path=entry.relative) }}" data-fill-Src="{{ url_for('fileshare.preview', token=token, path=entry.relative) }}"
data-fill-Pruefung="{{ url_for('fileshare.signature_state', token=token, path=entry.relative) }}"
data-fill-Kind="{{ entry.preview_kind }}" data-fill-Kind="{{ entry.preview_kind }}"
data-fill-Pfad="{{ entry.relative }}" data-fill-Pfad="{{ entry.relative }}"
data-fill-Name="{{ entry.name }}" data-fill-Name="{{ entry.name }}"
@@ -224,7 +239,11 @@
wie es ist -- mit seinen eigenen Marken, ohne Eingabefelder. #} wie es ist -- mit seinen eigenen Marken, ohne Eingabefelder. #}
data-viewer-formulare="{{ '1' if current_user.has('fileshare_pdf.view') else '' }}" data-viewer-formulare="{{ '1' if current_user.has('fileshare_pdf.view') else '' }}"
data-viewer-pdfjs="{{ asset_url('vendor/pdfjs/pdf.min.js') }}" data-viewer-pdfjs="{{ asset_url('vendor/pdfjs/pdf.min.js') }}"
data-viewer-pdflib="{{ asset_url('vendor/pdflib/pdf-lib.min.js') }}"> data-viewer-pdflib="{{ asset_url('vendor/pdflib/pdf-lib.min.js') }}"
data-viewer-konto="{{ current_user.username }}"
data-viewer-zeuge="{{ '1' if signature_witness else '0' }}"
data-viewer-zeit="{{ serverzeit }}"
data-viewer-ordnerliste="{{ verzeichnisse | tojson }}">
<div class="viewer"> <div class="viewer">
<div class="viewer__stage" data-viewer-bildbuehne hidden> <div class="viewer__stage" data-viewer-bildbuehne hidden>
<img class="viewer__img" alt="" data-viewer-bild> <img class="viewer__img" alt="" data-viewer-bild>
@@ -243,11 +262,60 @@
eine Zeile Formular über jeder Bildvorschau. Und nur, wer Formulare eine Zeile Formular über jeder Bildvorschau. Und nur, wer Formulare
ablegen darf, sieht sie überhaupt. #} ablegen darf, sieht sie überhaupt. #}
{% if current_user.has('fileshare_pdf.create') %} {% if current_user.has('fileshare_pdf.create') %}
{# Unterschreiben: Name tippen, mit dem Finger zeichnen, ins Dokument
setzen. Der Name daneben ist eine Angabe des Unterschreibenden – wie
auf Papier; wer abgelegt hat, steht im Änderungsprotokoll. #}
<div class="viewer__sign" data-viewer-unterschriftpanel hidden>
<div class="viewer__signfelder">
<label class="viewer__feld">
<span class="field__label">Vorname</span>
<input class="input" data-viewer-vorname autocomplete="off">
</label>
<label class="viewer__feld">
<span class="field__label">Nachname</span>
<input class="input" data-viewer-nachname autocomplete="off">
</label>
</div>
<canvas class="viewer__signpad" data-viewer-signpad width="700" height="200"
aria-label="Unterschriftsfeld"></canvas>
<div class="viewer__signaktionen">
<span class="fs-xs faint" data-viewer-signhinweis></span>
<button class="btn btn--sm btn--ghost" type="button" data-viewer-signleeren>Leeren</button>
<button class="btn btn--sm btn--ghost" type="button" data-viewer-signabbrechen>Abbrechen</button>
<button class="btn btn--sm btn--primary" type="button" data-viewer-signsetzen>
Unterschrift setzen
</button>
</div>
<p class="fs-xs faint">
Nach „Unterschrift setzen“ auf die Stelle im Dokument tippen. Mit Unterschrift
wird immer abgeschlossen gespeichert – die Felder sind danach gesperrt.
</p>
</div>
<div class="viewer__save" data-viewer-speichernzeile hidden> <div class="viewer__save" data-viewer-speichernzeile hidden>
{# Zwei eingebundene Freigaben, ein Formular: die Vorlage liegt in der
einen, die ausgefüllte Fassung gehört in die andere. #}
<label class="viewer__feld">
<span class="field__label">Freigabe</span>
<select class="select" data-viewer-ziel
data-url-vorlage="{{ url_for('fileshare.folders', token='__token__') }}">
{% for eintrag in mounts %}
<option value="{{ eintrag.token }}" {% if eintrag.token == token %}selected{% endif %}>
{{ eintrag.label }}
</option>
{% endfor %}
</select>
</label>
<label class="viewer__feld"> <label class="viewer__feld">
<span class="field__label">Ordner</span> <span class="field__label">Ordner</span>
<input class="input" list="freigabe-ordner" data-viewer-ordner {# Auswahl statt Eingabe mit Vorschlagsliste: auf einem iPad laesst
placeholder="Wurzelverzeichnis der Freigabe" autocomplete="off"> sich ein <datalist> kaum bedienen -- das Freigabefeld daneben
zeigt, wie es sein soll. #}
<select class="select" data-viewer-ordner>
<option value="">Wurzelverzeichnis der Freigabe</option>
{% for eintrag in verzeichnisse %}
<option value="{{ eintrag }}">{{ eintrag }}</option>
{% endfor %}
</select>
</label> </label>
<label class="viewer__feld"> <label class="viewer__feld">
<span class="field__label">Dateiname (ohne Endung)</span> <span class="field__label">Dateiname (ohne Endung)</span>
@@ -268,14 +336,28 @@
<option value="fertig">Abgeschlossen – Felder gesperrt</option> <option value="fertig">Abgeschlossen – Felder gesperrt</option>
</select> </select>
</label> </label>
<label class="check"> {# Haken über den Knöpfen und beides rechts: gelesen wird die Zeile von
<input type="checkbox" data-viewer-ersetzen> links nach rechts (wohin, wie es heißt, wie gespeichert), und die
<span>Vorhandene ersetzen</span> Entscheidung steht am Ende. #}
</label> <div class="viewer__saveaktionen">
<button class="btn btn--sm btn--primary" type="button" data-viewer-jetzt>Speichern</button> <label class="check" data-viewer-ersetzenfeld>
<button class="btn btn--sm btn--ghost" type="button" data-viewer-abbrechen>Abbrechen</button> <input type="checkbox" data-viewer-ersetzen>
<span>Vorhandene ersetzen</span>
</label>
<div class="viewer__saveknoepfe">
<button class="btn btn--sm btn--ghost" type="button" data-viewer-abbrechen>
Abbrechen
</button>
<button class="btn btn--sm btn--primary" type="button" data-viewer-jetzt>
Speichern
</button>
</div>
</div>
</div> </div>
{% endif %} {% endif %}
{# Das Ergebnis der Pruefung steht über der Leiste – wer eine
unterschriebene Fassung öffnet, soll es sehen, bevor er liest. #}
<p class="viewer__pruefung" data-viewer-pruefung hidden></p>
<div class="viewer__bar" data-viewer-leiste hidden> <div class="viewer__bar" data-viewer-leiste hidden>
<button class="btn btn--sm btn--ghost" type="button" data-viewer-zoom="aus" <button class="btn btn--sm btn--ghost" type="button" data-viewer-zoom="aus"
aria-label="Verkleinern">{{ icon('minus', size=15) }}</button> aria-label="Verkleinern">{{ icon('minus', size=15) }}</button>
@@ -293,18 +375,17 @@
</a> </a>
{% endif %} {% endif %}
{% if current_user.has('fileshare_pdf.create') %} {% if current_user.has('fileshare_pdf.create') %}
<button class="btn btn--sm" type="button" data-viewer-unterschreiben hidden>
{{ icon('pencil', size=15) }} Unterschreiben
</button>
<button class="btn btn--sm" type="button" data-viewer-speichern hidden <button class="btn btn--sm" type="button" data-viewer-speichern hidden
data-url="{{ url_for('fileshare.save_form', token=token) }}"> data-url="{{ url_for('fileshare.save_form', token=token) }}">
{{ icon('save', size=15) }} Ausgefüllt speichern {{ icon('save', size=15) }} Ausgefüllt speichern
</button> </button>
{% endif %} {% endif %}
<button class="btn btn--primary" type="button" data-dialog-close>Schliessen</button> <button class="btn btn--primary" type="button" data-dialog-close>Schließen</button>
</div> </div>
{% endcall %} {% endcall %}
{# Die Ordner der Freigabe als Vorschlag; getippt werden darf trotzdem. #}
<datalist id="freigabe-ordner">
{% for eintrag in verzeichnisse %}<option value="{{ eintrag }}"></option>{% endfor %}
</datalist>
{% endif %} {% endif %}
{% if listing and current_user.has('fileshare.edit') %} {% if listing and current_user.has('fileshare.edit') %}
@@ -37,7 +37,7 @@ wird nie stillschweigend übernommen: das ist der Fall, der bei einem Angriff au
<div class="alert__title">Gelesener Schlüssel von {{ probed_host }}:{{ probed_port }}</div> <div class="alert__title">Gelesener Schlüssel von {{ probed_host }}:{{ probed_port }}</div>
<div class="mono fs-sm break-all">{{ probed_type }} {{ probed_fingerprint }}</div> <div class="mono fs-sm break-all">{{ probed_type }} {{ probed_fingerprint }}</div>
<p class="fs-sm mt-2"> <p class="fs-sm mt-2">
Vergleichen Sie den Fingerprint mit einer unabhängigen Quelle -- Konsolenzugang am Vergleichen Sie den Fingerprint mit einer unabhängigen Quelle | Konsolenzugang am
Gerät, Inventardokumentation --, bevor Sie ihn unten freigeben. Gerät, Inventardokumentation --, bevor Sie ihn unten freigeben.
</p> </p>
</div> </div>
@@ -145,7 +145,7 @@ wird nie stillschweigend übernommen: das ist der Fall, der bei einem Angriff au
<div class="flex-1"> <div class="flex-1">
<div class="card__title">{{ icon('alert', size=17) }} Verwaiste Freigaben</div> <div class="card__title">{{ icon('alert', size=17) }} Verwaiste Freigaben</div>
<div class="card__sub"> <div class="card__sub">
Zu diesen Einträgen gibt es kein Ziel mehr -- gelöscht oder mit neuer IP. Zu diesen Einträgen gibt es kein Ziel mehr – gelöscht oder mit neuer IP.
</div> </div>
</div> </div>
</div> </div>
@@ -125,7 +125,7 @@
{% if pending.code %} {% if pending.code %}
<div class="field"> <div class="field">
<span class="field__label"> <span class="field__label">
Anfragecode -- an den Anbieter übermitteln Anfragecode – an den Anbieter übermitteln
</span> </span>
<pre class="log-view" id="offline-request" style="max-height:14dvh">{{ pending.code }}</pre> <pre class="log-view" id="offline-request" style="max-height:14dvh">{{ pending.code }}</pre>
<div class="row row--end mt-2"> <div class="row row--end mt-2">
@@ -234,7 +234,7 @@
</dl> </dl>
<p class="fs-xs faint mt-3"> <p class="fs-xs faint mt-3">
Der Fingerprint stammt aus der stabilen Maschinenidentität. Eine Umbenennung des Hosts Der Fingerprint stammt aus der stabilen Maschinenidentität. Eine Umbenennung des Hosts
ändert ihn <strong>nicht</strong> mehr -- im Vorgänger führte genau das zu ändert ihn <strong>nicht</strong> mehr – im Vorgänger führte genau das zu
&bdquo;bereits anderweitig gebunden&ldquo;. &bdquo;bereits anderweitig gebunden&ldquo;.
</p> </p>
</div> </div>
@@ -151,7 +151,7 @@ Druckerfreigabe. Der PoE-Neustart ist davon getrennt: er schaltet den Switch-Por
<div class="flex-1"> <div class="flex-1">
<div class="card__title">{{ icon('clock', size=17) }} Aufträge</div> <div class="card__title">{{ icon('clock', size=17) }} Aufträge</div>
<div class="card__sub"> <div class="card__sub">
Zustand liegt in der Datenbank -- ein Dienst-Neustart verliert ihn nicht mehr Zustand liegt in der Datenbank – ein Dienst-Neustart verliert ihn nicht mehr
</div> </div>
</div> </div>
</div> </div>
@@ -194,7 +194,7 @@ Druckerfreigabe. Der PoE-Neustart ist davon getrennt: er schaltet den Switch-Por
{% block scripts %} {% block scripts %}
<script type="module" nonce="{{ csp_nonce }}"> <script type="module" nonce="{{ csp_nonce }}">
// Auswahl beim Laden bewusst zurücksetzen: Browser stellen den Checked-Zustand // Auswahl beim Laden bewusst zurücksetzen: Browser stellen den Checked-Zustand
// sonst aus dem Verlauf wieder her -- und dann wird bei "Neustarten" // sonst aus dem Verlauf wieder her – und dann wird bei "Neustarten"
// versehentlich das falsche Gerät erwischt. // versehentlich das falsche Gerät erwischt.
const boxes = Array.from(document.querySelectorAll(".device-select")); const boxes = Array.from(document.querySelectorAll(".device-select"));
boxes.forEach((box) => (box.checked = false)); boxes.forEach((box) => (box.checked = false));
@@ -211,7 +211,7 @@ Druckerfreigabe. Der PoE-Neustart ist davon getrennt: er schaltet den Switch-Por
const state = await window.tesm.api("{{ url_for('maintenance.status') }}"); const state = await window.tesm.api("{{ url_for('maintenance.status') }}");
if (state.running) window.setTimeout(() => location.reload(), state.poll_ms); if (state.running) window.setTimeout(() => location.reload(), state.poll_ms);
} catch { } catch {
/* stumm bleiben -- die Seite ist auch ohne Aktualisierung nutzbar */ /* stumm bleiben – die Seite ist auch ohne Aktualisierung nutzbar */
} }
} }
poll(); poll();
@@ -78,6 +78,29 @@
</form> </form>
</section> </section>
<section class="card">
<div class="card__header">
<div class="flex-1">
<div class="card__title">{{ icon('pencil', size=17) }} Unterschriften</div>
<div class="card__sub">
Der Name in der Unterschrift ist eine Angabe des Unterschreibenden – wie auf
Papier. Wer das Dokument abgelegt hat, ist dagegen belegt: in den
Dokumenteigenschaften und im Änderungsprotokoll, auf Wunsch zusätzlich
sichtbar unter der ersten Unterschrift.
</div>
</div>
</div>
<form method="post" action="{{ url_for('settings.system_save') }}">
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
<div class="card__body stack">
{{ checkbox('signature_witness_enabled', 'Unterschriften mit dem ablegenden Konto bezeugen',
checked=values.signature_witness_enabled,
hint_text=specs['signature_witness_enabled'].hint) }}
</div>
<div class="card__footer"><button class="btn btn--primary" type="submit">Speichern</button></div>
</form>
</section>
{# Eigene Karte: die Abmeldung stand zwischen Protokollrotation und {# Eigene Karte: die Abmeldung stand zwischen Protokollrotation und
Papierkorb -- drei Dinge ohne Zusammenhang. Wer die Sitzungsdauer sucht, Papierkorb -- drei Dinge ohne Zusammenhang. Wer die Sitzungsdauer sucht,
sucht sie nicht unter "Protokolle". #} sucht sie nicht unter "Protokolle". #}
@@ -87,7 +110,7 @@
<div class="card__title">{{ icon('clock', size=17) }} Sitzungen</div> <div class="card__title">{{ icon('clock', size=17) }} Sitzungen</div>
<div class="card__sub"> <div class="card__sub">
Wie lange eine Anmeldung gilt. Beides bestimmt auch, wie lange eine Wie lange eine Anmeldung gilt. Beides bestimmt auch, wie lange eine
eingebundene Dateifreigabe bestehen bleibt -- sie endet mit der Sitzung. eingebundene Dateifreigabe bestehen bleibt – sie endet mit der Sitzung.
</div> </div>
</div> </div>
</div> </div>
+7 -7
View File
@@ -36,7 +36,7 @@ class MonitorDaemon:
self.running = True self.running = True
def stop(self, signum: int, _frame: FrameType | None) -> None: def stop(self, signum: int, _frame: FrameType | None) -> None:
logger.info("Signal %s empfangen -- beende nach dem laufenden Durchlauf.", signum) logger.info("Signal %s empfangen – beende nach dem laufenden Durchlauf.", signum)
self.running = False self.running = False
# ------------------------------------------------------------------ # # ------------------------------------------------------------------ #
@@ -68,8 +68,8 @@ class MonitorDaemon:
def run(self) -> int: def run(self) -> int:
live = self.extension.registry["live_log"] live = self.extension.registry["live_log"]
live.info("monitor", "Ueberwachungsprozess gestartet") live.info("monitor", "Überwachungsprozess gestartet")
logger.info("Ueberwachung laeuft, Datenbank: %s", self.extension.config.db_path) logger.info("Überwachung läuft, Datenbank: %s", self.extension.config.db_path)
while self.running: while self.running:
interval = self._interval() interval = self._interval()
@@ -84,8 +84,8 @@ class MonitorDaemon:
failures_before_restart=self._failures_before_restart(), failures_before_restart=self._failures_before_restart(),
) )
logger.info( logger.info(
"Durchlauf: %s geprueft, %s online, %s offline, %s Neustarts (%s erfolgreich), " "Durchlauf: %s geprüft, %s online, %s offline, %s Neustarts (%s erfolgreich), "
"%s ohne Lizenz uebersprungen, %sms", "%s ohne Lizenz übersprungen, %sms",
report.checked, report.checked,
report.online, report.online,
report.offline, report.offline,
@@ -96,7 +96,7 @@ class MonitorDaemon:
) )
except Exception: # noqa: BLE001 - der Dienst darf nie sterben except Exception: # noqa: BLE001 - der Dienst darf nie sterben
logger.exception("Durchlauf fehlgeschlagen") logger.exception("Durchlauf fehlgeschlagen")
live.error("monitor", "Durchlauf fehlgeschlagen -- siehe app.log") live.error("monitor", "Durchlauf fehlgeschlagen – siehe app.log")
# Das Aufraeumen abgelaufener Freigabe-Einbindungen laeuft **nicht** # Das Aufraeumen abgelaufener Freigabe-Einbindungen laeuft **nicht**
# hier, sondern im Webprozess: eingebunden wird in dessen # hier, sondern im Webprozess: eingebunden wird in dessen
@@ -112,7 +112,7 @@ class MonitorDaemon:
time.sleep(min(1.0, remaining)) time.sleep(min(1.0, remaining))
remaining -= 1.0 remaining -= 1.0
live.info("monitor", "Ueberwachungsprozess beendet") live.info("monitor", "Überwachungsprozess beendet")
return 0 return 0
+9
View File
@@ -168,6 +168,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_"
@@ -539,6 +541,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"
@@ -611,6 +618,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"
+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
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project] [project]
name = "tesm-core" name = "tesm-core"
version = "2.5.1" version = "2.7.6"
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 = [
+15 -11
View File
@@ -59,7 +59,7 @@ 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,
@@ -68,8 +68,8 @@ CORE_SETTINGS: tuple[SettingSpec, ...] = (
"session_absolute_hours", "session_absolute_hours",
12, 12,
"int", "int",
"Laengstmoegliche Sitzungsdauer (Stunden)", "Längstmögliche Sitzungsdauer (Stunden)",
hint="Danach ist Schluss, auch bei staendiger Arbeit -- eine Sitzung soll nicht " hint="Danach ist Schluss, auch bei ständiger Arbeit – eine Sitzung soll nicht "
"ewig leben. Die eingebundenen Dateifreigaben enden mit ihr.", "ewig leben. Die eingebundenen Dateifreigaben enden mit ihr.",
section="system", section="system",
minimum=1, minimum=1,
@@ -80,8 +80,8 @@ CORE_SETTINGS: tuple[SettingSpec, ...] = (
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,
@@ -114,8 +114,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.
@@ -126,7 +126,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,
@@ -148,7 +148,7 @@ 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.5.1", app_version: str = "2.7.6",
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,
@@ -160,6 +160,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
@@ -374,8 +378,8 @@ def create_base_app(
# sehen. Vorher stand hier nur der nackte Status. # sehen. Vorher stand hier nur der nackte Status.
grenze = uploads.limit_mb() grenze = uploads.limit_mb()
error.description = ( error.description = (
f"Diese Datei ist groesser als erlaubt. Zurzeit sind {grenze} MB je Upload " f"Diese Datei ist größer als erlaubt. Zurzeit sind {grenze} MB je Upload "
"eingestellt; aendern kann das ein Administrator unter " "eingestellt; ändern kann das ein Administrator unter "
'"Einstellungen -> Webserver und TLS".' '"Einstellungen -> Webserver und TLS".'
) )
if _wants_json(): if _wants_json():
+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
@@ -59,20 +59,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 +123,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
@@ -182,7 +182,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(
@@ -249,9 +249,9 @@ 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")
try: try:
service_conn, bound = _connect(config, bind_user, bind_password) service_conn, bound = _connect(config, bind_user, bind_password)
@@ -359,7 +359,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 ()
@@ -245,9 +245,9 @@ def _bitte_bestaetigen(permission: str) -> Any:
if is_automatic(request) or ( if is_automatic(request) or (
request.accept_mimetypes.accept_json and not request.accept_mimetypes.accept_html request.accept_mimetypes.accept_json and not request.accept_mimetypes.accept_html
): ):
abort(403, description="Bitte bestaetigen Sie zur Sicherheit Ihr Passwort.") abort(403, description="Bitte bestätigen Sie zur Sicherheit Ihr Passwort.")
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"))
@@ -268,7 +268,7 @@ 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 -- # Geheimnisse und Exporte verlangen eine frische Bestaetigung --
# das steht im Rechtemodell und wird deshalb hier abgefragt, nicht # das steht im Rechtemodell und wird deshalb hier abgefragt, nicht
# in jeder Route einzeln. Vorher hing es daran, dass jemand an den # in jeder Route einzeln. Vorher hing es daran, dass jemand an den
@@ -318,7 +318,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)
@@ -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)
@@ -308,7 +308,7 @@ def complete_login(
actor_id=int(user_row["id"]), actor_id=int(user_row["id"]),
action="auth.first_login", action="auth.first_login",
target=str(user_row["username"]), target=str(user_row["username"]),
detail=f"Erste Anmeldung -- Quelle: {user_row.get('auth_source') or 'local'}", detail=f"Erste Anmeldung | Quelle: {user_row.get('auth_source') or 'local'}",
ip=ip, ip=ip,
request_id=getattr(g, "request_id", ""), request_id=getattr(g, "request_id", ""),
severity="notice", severity="notice",
@@ -359,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
+12 -12
View File
@@ -83,7 +83,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 +93,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 +118,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 +144,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 +172,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 +194,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 +207,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,
+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")
@@ -68,10 +68,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 +85,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 +94,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():
@@ -257,8 +257,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 +284,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 +294,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 +332,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 +397,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 +442,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 +475,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 +527,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 +597,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 +648,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 +748,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 +773,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 +809,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)
@@ -176,9 +176,9 @@ class MigrationRunner:
if record is not None: if record is not None:
if record["checksum"] != migration.checksum: if record["checksum"] != migration.checksum:
logger.warning( logger.warning(
"Migration %s/%s (%s) wurde nachtraeglich geaendert " "Migration %s/%s (%s) wurde nachträglich geändert "
"(Pruefsumme %s != %s). Bereits angewendete Migrationen werden " "(Prüfsumme %s != %s). Bereits angewendete Migrationen werden "
"nicht erneut ausgefuehrt -- neue Aenderungen brauchen eine neue " "nicht erneut ausgeführt – neue Änderungen brauchen eine neue "
"Versionsnummer.", "Versionsnummer.",
component, component,
migration.version, migration.version,
@@ -210,7 +210,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,
+12 -12
View File
@@ -19,7 +19,7 @@ USERS = Resource(
actions=(A.VIEW, A.CREATE, A.EDIT, A.DELETE), actions=(A.VIEW, A.CREATE, A.EDIT, 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.EDIT: "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).",
}, },
) )
@@ -30,7 +30,7 @@ GROUPS = Resource(
actions=(A.VIEW, A.CREATE, A.EDIT, A.DELETE), actions=(A.VIEW, A.CREATE, A.EDIT, A.DELETE),
hint="Rechtematrix pflegen", hint="Rechtematrix pflegen",
action_hints={ action_hints={
A.EDIT: "Rechte vergeben -- nur Rechte, die man selbst besitzt.", A.EDIT: "Rechte vergeben – nur Rechte, die man selbst besitzt.",
}, },
) )
@@ -39,7 +39,7 @@ SETTINGS_SYSTEM = Resource(
label="Systemeinstellungen", label="Systemeinstellungen",
actions=(A.VIEW, A.EDIT, A.EXECUTE), actions=(A.VIEW, A.EDIT, A.EXECUTE),
hint="Hostname, Zeitzone, Protokollrotation, Navigation", hint="Hostname, Zeitzone, Protokollrotation, Navigation",
action_hints={A.EXECUTE: "Systemaktionen ausloesen (Dienste, Netzwerk uebernehmen)."}, action_hints={A.EXECUTE: "Systemaktionen auslösen (Dienste, Netzwerk übernehmen)."},
) )
SETTINGS_LICENSE = Resource( SETTINGS_LICENSE = Resource(
@@ -55,7 +55,7 @@ SETTINGS_DIRECTORY = Resource(
actions=(A.VIEW, A.EDIT, A.EXECUTE, A.SECRETS), actions=(A.VIEW, A.EDIT, 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.",
}, },
) )
@@ -65,7 +65,7 @@ SETTINGS_WEB = Resource(
label="Webserver und TLS", label="Webserver und TLS",
actions=(A.VIEW, A.EDIT, A.EXECUTE), actions=(A.VIEW, A.EDIT, 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(
@@ -79,11 +79,11 @@ BACKUP = Resource(
key="backup", key="backup",
label="Sicherung und Übertragung", label="Sicherung und Übertragung",
actions=(A.VIEW, A.EXECUTE, A.EXPORT, A.SECRETS), actions=(A.VIEW, A.EXECUTE, A.EXPORT, A.SECRETS),
hint="Verschluesselter Export und Import", hint="Verschlüsselter Export und Import",
action_hints={ action_hints={
A.EXECUTE: "Eine Sicherung einspielen.", 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.",
}, },
) )
@@ -91,10 +91,10 @@ TRASH = Resource(
key="trash", key="trash",
label="Papierkorb", label="Papierkorb",
actions=(A.VIEW, A.EDIT, A.DELETE), actions=(A.VIEW, A.EDIT, 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.EDIT: "Einträge wiederherstellen.",
A.DELETE: "Eintraege endgueltig entfernen.", A.DELETE: "Einträge endgültig entfernen.",
}, },
) )
@@ -102,7 +102,7 @@ DIAGNOSTICS = Resource(
key="diagnostics", key="diagnostics",
label="Diagnose", label="Diagnose",
actions=(A.VIEW,), actions=(A.VIEW,),
hint="Schemastand, Protokollintegritaet, Schluesselrechte", hint="Schemastand, Protokollintegrität, Schlüsselrechte",
) )
LOGS_LIVE = Resource( LOGS_LIVE = Resource(
@@ -123,7 +123,7 @@ LOGS_AUDIT = Resource(
key="logs_audit", key="logs_audit",
label="Änderungsprotokoll", label="Änderungsprotokoll",
actions=(A.VIEW, A.EXPORT), actions=(A.VIEW, A.EXPORT),
hint="Wer hat wann was geaendert", hint="Wer hat wann was geändert",
) )
+10 -10
View File
@@ -42,16 +42,16 @@ class Action(str, Enum):
#: Kuerzel und Erklaerung je Aktion -- Kopfzeile der Rechtematrix. #: Kuerzel und Erklaerung je Aktion -- Kopfzeile der Rechtematrix.
ACTION_META: dict[Action, tuple[str, str, str]] = { ACTION_META: dict[Action, tuple[str, str, str]] = {
Action.VIEW: ("R", "Ansehen", "Datensaetze und Seiten dieses Bereichs lesen."), Action.VIEW: ("R", "Ansehen", "Datensätze und Seiten dieses Bereichs lesen."),
Action.CREATE: ("C", "Anlegen", "Neue Datensaetze erstellen."), Action.CREATE: ("C", "Anlegen", "Neue Datensätze erstellen."),
Action.EDIT: ("U", "Bearbeiten", "Bestehende Datensaetze aendern -- ohne Loeschen."), Action.EDIT: ("U", "Bearbeiten", "Bestehende Datensätze ändern – ohne Löschen."),
Action.DELETE: ("D", "Loeschen", "Datensaetze loeschen bzw. endgueltig entfernen."), Action.DELETE: ("D", "Loeschen", "Datensätze löschen bzw. endgültig entfernen."),
Action.EXECUTE: ("X", "Ausfuehren", "Aktionen ausloesen (Neustart, Dienste, Jobs, Import)."), Action.EXECUTE: ("X", "Ausfuehren", "Aktionen auslösen (Neustart, Dienste, Jobs, Import)."),
Action.EXPORT: ("E", "Exportieren", "Daten aus dem System heraustragen (Datei-Download)."), Action.EXPORT: ("E", "Exportieren", "Daten aus dem System heraustragen (Datei-Download)."),
Action.SECRETS: ( Action.SECRETS: (
"S", "S",
"Geheimnisse", "Geheimnisse",
"Passwoerter und Schluessel im Klartext sehen oder mitnehmen.", "Passwörter und Schlüssel im Klartext sehen oder mitnehmen.",
), ),
} }
@@ -132,18 +132,18 @@ class PermissionTree:
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.view_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.VIEW not in resource.actions:
raise ValueError( raise ValueError(
f"Ressource {resource.key} muss die Aktion 'view' anbieten -- " f"Ressource {resource.key} muss die Aktion 'view' 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():
@@ -132,7 +132,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 +160,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 +205,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 +224,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 +240,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 +252,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))
@@ -410,7 +410,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 +420,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 +482,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 +552,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 +583,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 +601,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 +619,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 +637,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":
@@ -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:
@@ -67,6 +67,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 +179,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:
@@ -847,6 +847,25 @@
flex: 1 1 12rem; flex: 1 1 12rem;
margin: 0; 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 /* Eigener Name: ".field--inline" gibt es schon und steht dort fuer
"waagerecht" -- diese Regel haette sie app-weit umgestellt. */ "waagerecht" -- diese Regel haette sie app-weit umgestellt. */
.viewer__feld { .viewer__feld {
@@ -1384,3 +1403,258 @@
background: transparent; background: transparent;
border-style: dashed; 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. */
.viewer__unterschrift {
position: absolute;
z-index: 3;
cursor: grab;
touch-action: none;
container-type: inline-size;
border: 1px dashed transparent;
}
.viewer__unterschrift:hover,
.viewer__unterschrift.is-fassend {
border-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 {
/* 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;
font-size: 5.6cqw;
line-height: 1.15;
color: #111;
border-top: 1px solid rgba(0, 0, 0, 0.85);
padding-top: 1px;
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"}`;
@@ -497,14 +497,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 +532,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 +720,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");
} }
}); });
} }
@@ -169,7 +169,7 @@ async function fotoAlsJpeg(datei) {
const daten = await new Promise((fertig, schiefgegangen) => { const daten = await new Promise((fertig, schiefgegangen) => {
const leser = new FileReader(); const leser = new FileReader();
leser.onload = () => fertig(String(leser.result)); leser.onload = () => fertig(String(leser.result));
leser.onerror = () => schiefgegangen(new Error("Die Datei liess sich nicht lesen.")); leser.onerror = () => schiefgegangen(new Error("Die Datei ließ sich nicht lesen."));
leser.readAsDataURL(datei); leser.readAsDataURL(datei);
}); });
bild = await new Promise((fertig, schiefgegangen) => { bild = await new Promise((fertig, schiefgegangen) => {
@@ -207,7 +207,7 @@ async function pdflibLaden(adresse) {
skript.onload = () => fertig(window.PDFLib); skript.onload = () => fertig(window.PDFLib);
skript.onerror = () => { skript.onerror = () => {
pdflibGeladen = null; pdflibGeladen = null;
schiefgegangen(new Error("pdf-lib liess sich nicht laden.")); schiefgegangen(new Error("pdf-lib ließ sich nicht laden."));
}; };
document.head.append(skript); document.head.append(skript);
}); });
@@ -228,7 +228,12 @@ async function pdflibLaden(adresse) {
* ``abschliessen`` rechnet danach alle Felder flach in die Seite: heraus kommt * ``abschliessen`` rechnet danach alle Felder flach in die Seite: heraus kommt
* ein PDF ohne Formular -- nichts mehr zu verstellen. * ein PDF ohne Formular -- nichts mehr zu verstellen.
*/ */
async function nachbearbeiten(bytes, bilder, adresse, { abschliessen = false } = {}) { async function nachbearbeiten(
bytes,
bilder,
adresse,
{ abschliessen = false, unterschriften = [], zeuge = "" } = {}
) {
const PDFLib = await pdflibLaden(adresse); const PDFLib = await pdflibLaden(adresse);
const dokument = await PDFLib.PDFDocument.load(bytes); const dokument = await PDFLib.PDFDocument.load(bytes);
const formular = dokument.getForm(); const formular = dokument.getForm();
@@ -258,11 +263,77 @@ async function nachbearbeiten(bytes, bilder, adresse, { abschliessen = false } =
try { try {
formular.removeField(knopf); formular.removeField(knopf);
} catch (fehler) { } catch (fehler) {
console.warn("Bildknopf liess sich nicht entfernen", fehler); console.warn("Bildknopf ließ sich nicht entfernen", fehler);
} }
} }
} }
// Unterschriften: Bild und darunter der angegebene Name mit Zeitpunkt. Das
// Bild allein waere eine Zeichnung ohne Aussage -- auf Papier steht neben
// der Unterschrift auch der Name in Druckbuchstaben.
if (unterschriften.length) {
const schrift = await dokument.embedFont(PDFLib.StandardFonts.Helvetica);
for (const eintrag of unterschriften) {
const seite = dokument.getPage(eintrag.seite - 1);
const png = await dokument.embedPng(eintrag.bytes);
const breite = eintrag.breite;
const hoehe = (png.height / png.width) * breite;
seite.drawImage(png, { x: eintrag.x, y: eintrag.y, width: breite, height: hoehe });
const zeilen = [`${eintrag.name} | ${eintrag.zeit}`];
// Der Zeugenvermerk gehoert zum Dokument, nicht zu jeder Unterschrift:
// unter drei Unterschriften stand dreimal derselbe Satz. Sichtbar
// erscheint er deshalb nur unter der ersten -- fest steht er ohnehin in
// den Dokumenteigenschaften und im Aenderungsprotokoll.
if (zeuge && eintrag === unterschriften[0]) {
zeilen.push(`In TESM abgelegt durch ${zeuge}`);
}
// Die Schrift waechst mit der Unterschrift: wer sie kleiner zieht, will
// den ganzen Block kleiner haben, nicht eine Zeichnung mit einer
// Bildunterschrift in Originalgroesse daneben.
const groesse = schriftgroesse(breite);
// Der Strich ist so lang wie die laengste Zeile darunter -- nicht so
// breit wie die Zeichnung. Eine kurze Unterschrift ueber einem langen
// Namen sah sonst aus wie zwei Dinge, die nicht zusammengehoeren.
const textbreite = Math.max(
...zeilen.map((zeile) => schrift.widthOfTextAtSize(zeile, groesse))
);
let unten = eintrag.y - 2;
for (const zeile of zeilen) {
unten -= groesse * 1.15;
seite.drawText(zeile, {
x: eintrag.x,
y: unten,
size: groesse,
font: schrift,
color: PDFLib.rgb(0.1, 0.1, 0.1),
});
}
// Kraeftig genug, um neben den Linien eines Formulars als eigener Strich
// erkennbar zu bleiben: ein halber Punkt in hellem Grau ging zwischen
// den Tabellenlinien schlicht unter.
seite.drawLine({
start: { x: eintrag.x, y: eintrag.y - 1.5 },
end: { x: eintrag.x + textbreite, y: eintrag.y - 1.5 },
thickness: 0.9,
color: PDFLib.rgb(0.15, 0.15, 0.15),
});
}
}
if (unterschriften.length) {
// In den Dokumenteigenschaften -- dort sucht man so etwas, und dort steht
// es einmal statt unter jeder Unterschrift. Jeder Betrachter zeigt das
// an, auch ausserhalb von TESM.
const namen = unterschriften.map((eintrag) => eintrag.name);
const wann = unterschriften[unterschriften.length - 1].zeit;
dokument.setAuthor(namen.join(", "));
dokument.setSubject(
`Unterschrieben am ${wann}` + (zeuge ? ` | in TESM abgelegt durch ${zeuge}` : "")
);
dokument.setProducer("TESM");
dokument.setModificationDate(new Date());
}
if (abschliessen) { if (abschliessen) {
// Alle Felder werden Teil der Seite. Danach gibt es kein Formular mehr -- // Alle Felder werden Teil der Seite. Danach gibt es kein Formular mehr --
// gewollt: so kann niemand mehr etwas verstellen. // gewollt: so kann niemand mehr etwas verstellen.
@@ -271,6 +342,112 @@ async function nachbearbeiten(bytes, bilder, adresse, { abschliessen = false } =
return dokument.save(); return dokument.save();
} }
/** Schriftgroesse des Namenszugs unter einer Unterschrift dieser Breite.
*
* Ein Achtzehntel: bei den 150 Punkt der vollen Breite sind das gut acht
* Punkt -- die uebliche Groesse einer Unterschriftszeile auf Papier. Dieselbe
* Rechnung benutzt die Vorschau (als ``cqw``), damit auf dem Schirm steht,
* was spaeter im PDF steht.
*/
function schriftgroesse(breite) {
return Math.max(5, Math.min(10, breite / 18));
}
/** Die gezeichnete Unterschrift als PNG -- auf das Gezeichnete beschnitten.
*
* Ungeschnitten waere jede Unterschrift so gross wie die Zeichenflaeche, mit
* viel Luft ringsum. Im Dokument sieht das aus wie ein verrutschter Stempel.
*/
function unterschriftAlsPng(leinwand, kasten) {
const rand = 6;
const links = Math.max(0, Math.floor(kasten.x1 - rand));
const oben = Math.max(0, Math.floor(kasten.y1 - rand));
const breite = Math.min(leinwand.width - links, Math.ceil(kasten.x2 - kasten.x1 + rand * 2));
const hoehe = Math.min(leinwand.height - oben, Math.ceil(kasten.y2 - kasten.y1 + rand * 2));
const ziel = document.createElement("canvas");
ziel.width = Math.max(1, breite);
ziel.height = Math.max(1, hoehe);
ziel
.getContext("2d")
.drawImage(leinwand, links, oben, ziel.width, ziel.height, 0, 0, ziel.width, ziel.height);
// Ueber eine data:-Adresse und nicht ueber blob: -- die Sicherheitsregeln
// der Anwendung erlauben blob: nicht, und ein stilles "img-src" waere ein
// Loch, das man fuer eine Unterschrift nicht aufmacht.
const adresse = ziel.toDataURL("image/png");
const roh = atob(adresse.slice(adresse.indexOf(",") + 1));
const bytes = new Uint8Array(roh.length);
for (let i = 0; i < roh.length; i++) bytes[i] = roh.charCodeAt(i);
return { bytes, vorschau: adresse, breite: ziel.width, hoehe: ziel.height };
}
/** Zeichenflaeche fuer die Unterschrift -- Finger, Stift oder Maus.
*
* ``touch-action: none`` steht im Stylesheet: ohne das scrollt ein iPad die
* Seite, statt einen Strich zu zeichnen.
*/
function unterschriftPad(leinwand) {
const stift = leinwand.getContext("2d");
let kasten = null;
let zeichnet = false;
const punkt = (event) => {
const rechteck = leinwand.getBoundingClientRect();
return {
x: ((event.clientX - rechteck.left) / rechteck.width) * leinwand.width,
y: ((event.clientY - rechteck.top) / rechteck.height) * leinwand.height,
};
};
const merken = ({ x, y }) => {
kasten = kasten
? {
x1: Math.min(kasten.x1, x),
y1: Math.min(kasten.y1, y),
x2: Math.max(kasten.x2, x),
y2: Math.max(kasten.y2, y),
}
: { x1: x, y1: y, x2: x, y2: y };
};
stift.lineWidth = 2.5;
stift.lineCap = "round";
stift.lineJoin = "round";
stift.strokeStyle = "#111";
leinwand.addEventListener("pointerdown", (event) => {
event.preventDefault();
leinwand.setPointerCapture(event.pointerId);
zeichnet = true;
const p = punkt(event);
merken(p);
stift.beginPath();
stift.moveTo(p.x, p.y);
});
leinwand.addEventListener("pointermove", (event) => {
if (!zeichnet) return;
event.preventDefault();
const p = punkt(event);
merken(p);
stift.lineTo(p.x, p.y);
stift.stroke();
});
const beenden = () => {
zeichnet = false;
};
leinwand.addEventListener("pointerup", beenden);
leinwand.addEventListener("pointercancel", beenden);
leinwand.addEventListener("pointerleave", beenden);
return {
leeren: () => {
stift.clearRect(0, 0, leinwand.width, leinwand.height);
kasten = null;
},
leer: () => kasten === null,
alsPng: () => (kasten ? unterschriftAlsPng(leinwand, kasten) : null),
};
}
/* -- PDF ------------------------------------------------------------------- */ /* -- PDF ------------------------------------------------------------------- */
let pdfjsGeladen = null; let pdfjsGeladen = null;
@@ -448,6 +625,159 @@ async function pdfBetrachter(behaelter, seiten, url, basis, { formulare = true }
}); });
await bildknoepfeAufsetzen(); await bildknoepfeAufsetzen();
/* Unterschriften: gezeichnet wird ausserhalb, gesetzt wird hier. Die Stelle
bestimmt ein Tipp ins Dokument -- ein festes Feld gibt es in den wenigsten
Vorlagen, und "unten rechts" ist bei einem Formular mit drei
Unterschriftszeilen die falsche Annahme. */
// Ein menschliches Mass: rund fuenf Zentimeter breit, hoechstens knapp zwei
// hoch. Ohne die Hoehengrenze wird aus einem hohen Gekritzel ein Stempel,
// der die halbe Seite einnimmt -- das Seitenverhaeltnis kommt aus dem
// Zuschnitt der Zeichnung, nicht aus einer Vorlage. Nach dem Setzen laesst
// sich beides mit dem Griff nachstellen, nach oben bis zu dieser Grenze.
const MAX_BREITE = 150;
const MAX_HOEHE = 50;
const unterschriften = [];
let wartendeUnterschrift = null;
let zeugenzeile = () => "";
const platzieren = async (event) => {
if (!wartendeUnterschrift) return;
const seitenEl = event.target.closest(".page");
if (!seitenEl) return;
event.preventDefault();
const nummer = Number(seitenEl.dataset.pageNumber || 0);
if (!nummer) return;
const rechteck = seitenEl.getBoundingClientRect();
const anteilX = (event.clientX - rechteck.left) / rechteck.width;
const anteilY = (event.clientY - rechteck.top) / rechteck.height;
const seite = await dokument.getPage(nummer);
const blick = seite.getViewport({ scale: 1 });
let breite = Math.min(MAX_BREITE, blick.width * 0.3);
let hoehe = (wartendeUnterschrift.hoehe / wartendeUnterschrift.breite) * breite;
if (hoehe > MAX_HOEHE) {
breite *= MAX_HOEHE / hoehe;
hoehe = MAX_HOEHE;
}
const eintrag = {
...wartendeUnterschrift,
seite: nummer,
breite,
// Der Tipp meint die Mitte der Unterschrift, nicht ihre Ecke.
x: Math.max(4, anteilX * blick.width - breite / 2),
y: Math.max(24, (1 - anteilY) * blick.height - hoehe / 2),
};
unterschriften.push(eintrag);
behaelter.classList.remove("viewer__pdfscroll--setzen");
wartendeUnterschrift = null;
// Sichtbar machen, wo sie gelandet ist -- und beweglich: der erste Tipp
// trifft selten genau die Linie, und der Textblock gehoert mit auf die
// richtige Stelle.
marke_setzen(seitenEl, eintrag, blick, zeugenzeile());
behaelter.dispatchEvent(new CustomEvent("unterschrieben", { detail: eintrag }));
};
/** Baut die bewegliche Marke einer gesetzten Unterschrift. */
const marke_setzen = (seitenEl, eintrag, blick, zeuge) => {
const marke = document.createElement("div");
marke.className = "viewer__unterschrift";
const bild = document.createElement("img");
bild.src = eintrag.vorschau;
bild.alt = "";
// Genau der Aufbau der spaeteren Datei: eine Zeile mit Namen und
// Zeitpunkt, darunter -- als eigene Zeile -- der Zeugenvermerk. In der
// Vorschau standen beide hintereinander, getrennt durch einen
// Schraegstrich; beim Positionieren sah der Block dadurch breiter und
// flacher aus als das Ergebnis.
const text = document.createElement("div");
text.className = "viewer__unterschrifttext";
const namenszeile = document.createElement("div");
namenszeile.textContent = `${eintrag.name} | ${eintrag.zeit}`;
text.append(namenszeile);
if (zeuge && eintrag === unterschriften[0]) {
const zeugenzeile = document.createElement("div");
zeugenzeile.textContent = `In TESM abgelegt durch ${zeuge}`;
text.append(zeugenzeile);
}
const griff = document.createElement("button");
griff.type = "button";
griff.className = "viewer__unterschriftgriff";
griff.title = "Größe ändern";
griff.setAttribute("aria-label", "Unterschrift vergroessern oder verkleinern");
const weg = document.createElement("button");
weg.type = "button";
weg.className = "viewer__unterschriftweg";
weg.textContent = "x";
weg.title = "Unterschrift entfernen";
weg.setAttribute("aria-label", "Unterschrift entfernen");
marke.append(bild, text, griff, weg);
seitenEl.append(marke);
const zeichnen = () => {
marke.style.left = `${(eintrag.x / blick.width) * 100}%`;
marke.style.bottom = `${(eintrag.y / blick.height) * 100}%`;
marke.style.width = `${(eintrag.breite / blick.width) * 100}%`;
// Dieselbe Rechnung wie beim Stempeln, samt ihrer Ober- und Untergrenze
// -- in "cqw" (Prozent der Blockbreite), damit sie beim Zoomen mitgeht.
text.style.fontSize = `${(schriftgroesse(eintrag.breite) / eintrag.breite) * 100}cqw`;
};
zeichnen();
weg.addEventListener("click", (event) => {
event.stopPropagation();
const stelle = unterschriften.indexOf(eintrag);
if (stelle >= 0) unterschriften.splice(stelle, 1);
marke.remove();
});
// Verschieben und Skalieren teilen sich einen Zeiger: beim Griff aendert
// sich die Breite, sonst die Lage.
const fassen = (event) => {
if (event.target === weg) return;
event.preventDefault();
event.stopPropagation();
const skalieren = event.target === griff;
const rechteck = seitenEl.getBoundingClientRect();
const proPunkt = rechteck.width / blick.width;
const startX = event.clientX;
const startY = event.clientY;
const startBreite = eintrag.breite;
const start = { x: eintrag.x, y: eintrag.y };
const obergrenze = Math.min(MAX_BREITE, blick.width * 0.3);
marke.setPointerCapture(event.pointerId);
marke.classList.add("is-fassend");
const bewegen = (zug) => {
if (skalieren) {
const breite = startBreite + (zug.clientX - startX) / proPunkt;
eintrag.breite = Math.max(40, Math.min(obergrenze, breite));
} else {
eintrag.x = Math.max(2, start.x + (zug.clientX - startX) / proPunkt);
eintrag.y = Math.max(
// Platz fuer die Textzeilen unter der Zeichnung.
schriftgroesse(eintrag.breite) * 2.5,
start.y - (zug.clientY - startY) / proPunkt
);
}
zeichnen();
};
const loslassen = () => {
marke.classList.remove("is-fassend");
marke.removeEventListener("pointermove", bewegen);
marke.removeEventListener("pointerup", loslassen);
marke.removeEventListener("pointercancel", loslassen);
};
marke.addEventListener("pointermove", bewegen);
marke.addEventListener("pointerup", loslassen);
marke.addEventListener("pointercancel", loslassen);
};
marke.addEventListener("pointerdown", fassen);
};
behaelter.addEventListener("click", (event) => {
platzieren(event).catch((fehler) => console.error("Unterschrift", fehler));
});
return { return {
hinein: () => { hinein: () => {
viewer.currentScale = Math.min(MAX_ZOOM, viewer.currentScale * 1.2); viewer.currentScale = Math.min(MAX_ZOOM, viewer.currentScale * 1.2);
@@ -463,12 +793,23 @@ async function pdfBetrachter(behaelter, seiten, url, basis, { formulare = true }
* pdf.js schreibt die Feldwerte; gewaehlte Fotos kommen danach mit * pdf.js schreibt die Feldwerte; gewaehlte Fotos kommen danach mit
* pdf-lib hinein -- pdf.js kann nichts in ein PDF zeichnen. * pdf-lib hinein -- pdf.js kann nichts in ein PDF zeichnen.
*/ */
speichern: async (pdflibAdresse, { abschliessen = false } = {}) => { speichern: async (pdflibAdresse, { abschliessen = false, zeuge = "" } = {}) => {
const bytes = await dokument.saveDocument(); const bytes = await dokument.saveDocument();
// Ohne Bild und ohne Abschluss braucht es pdf-lib gar nicht. // Ohne Bild, ohne Unterschrift und ohne Abschluss braucht es pdf-lib nicht.
if (!bilder.size && !abschliessen) return bytes; if (!bilder.size && !abschliessen && !unterschriften.length) return bytes;
return nachbearbeiten(bytes, [...bilder.values()], pdflibAdresse, { abschliessen }); return nachbearbeiten(bytes, [...bilder.values()], pdflibAdresse, {
abschliessen,
unterschriften,
zeuge,
});
}, },
/** Wartet der Betrachter auf den Tipp, wohin die Unterschrift soll? */
unterschriftAnnehmen: (daten, zeuge = "") => {
wartendeUnterschrift = daten;
zeugenzeile = () => zeuge;
behaelter.classList.add("viewer__pdfscroll--setzen");
},
unterschriften: () => unterschriften.map(({ name, zeit }) => ({ name, zeit })),
/** Wurde ueberhaupt etwas eingetragen? */ /** Wurde ueberhaupt etwas eingetragen? */
veraendert: () => { veraendert: () => {
const s = dokument.annotationStorage; const s = dokument.annotationStorage;
@@ -505,10 +846,49 @@ export function initFilePreview(wurzel = document) {
const ordnerFeld = stueck("ordner"); const ordnerFeld = stueck("ordner");
const nameFeld = stueck("name"); const nameFeld = stueck("name");
const ersetzenFeld = stueck("ersetzen"); const ersetzenFeld = stueck("ersetzen");
const ersetzenKasten = stueck("ersetzenfeld");
const pruefzeile = stueck("pruefung");
// Die Ordner der geoeffneten Freigabe kommen mit der Seite; die einer
// anderen holt das Auswahlfeld bei Bedarf nach.
const eigeneOrdner = () => {
try {
return JSON.parse(rumpf.dataset.viewerOrdnerliste || "[]");
} catch {
return [];
}
};
const jetzt = stueck("jetzt"); const jetzt = stueck("jetzt");
const abbrechen = stueck("abbrechen"); const abbrechen = stueck("abbrechen");
const modusFeld = stueck("modus"); const modusFeld = stueck("modus");
const zielFeld = stueck("ziel");
const unterschreibenKnopf = stueck("unterschreiben");
const unterschriftPanel = stueck("unterschriftpanel");
const vornameFeld = stueck("vorname");
const nachnameFeld = stueck("nachname");
const padLeinwand = stueck("signpad");
const padLeeren = stueck("signleeren");
const padSetzen = stueck("signsetzen");
const padAbbrechen = stueck("signabbrechen");
const padHinweis = stueck("signhinweis");
const basis = rumpf.dataset.viewerPdfjs || ""; const basis = rumpf.dataset.viewerPdfjs || "";
// Der Anmeldename, nicht der volle Name: er ist eindeutig, und genau das
// soll die Zeile belegen.
const konto = rumpf.dataset.viewerKonto || "";
const bezeugen = rumpf.dataset.viewerZeuge === "1";
// Die Uhr des Servers, nicht die des Tablets: eine verstellte Uhr am Geraet
// soll nicht das Datum unter einer Unterschrift bestimmen.
const serverStart = Date.parse(rumpf.dataset.viewerZeit || "") || Date.now();
const geladenSeit = Date.now();
const jetztText = () => {
const zeitpunkt = new Date(serverStart + (Date.now() - geladenSeit));
return zeitpunkt.toLocaleString("de-DE", {
day: "2-digit",
month: "2-digit",
year: "numeric",
hour: "2-digit",
minute: "2-digit",
});
};
// Ein Format, das dieser Browser nicht kann (HEIC ausserhalb von Safari), // Ein Format, das dieser Browser nicht kann (HEIC ausserhalb von Safari),
// soll nicht als leere Flaeche enden. // soll nicht als leere Flaeche enden.
@@ -516,7 +896,7 @@ export function initFilePreview(wurzel = document) {
if (bildBuehne.hidden || !bild.getAttribute("src")) return; if (bildBuehne.hidden || !bild.getAttribute("src")) return;
if (hinweis) { if (hinweis) {
hinweis.textContent = hinweis.textContent =
"Dieses Bildformat kann Ihr Browser nicht anzeigen -- bitte herunterladen."; "Dieses Bildformat kann Ihr Browser nicht anzeigen – bitte herunterladen.";
} }
}); });
@@ -535,9 +915,13 @@ export function initFilePreview(wurzel = document) {
rahmen.hidden = art !== "text"; rahmen.hidden = art !== "text";
leiste.hidden = art === "text"; leiste.hidden = art === "text";
if (speichern) speichern.hidden = art !== "pdf"; if (speichern) speichern.hidden = art !== "pdf";
if (unterschreibenKnopf) unterschreibenKnopf.hidden = art !== "pdf";
if (speichern) speichern.hidden = art !== "pdf";
if (unterschriftPanel) unterschriftPanel.hidden = true;
}; };
const aufraeumen = () => { const aufraeumen = () => {
pruefungZuruecksetzen();
bild.removeAttribute("src"); bild.removeAttribute("src");
bilder.zuruecksetzen(); bilder.zuruecksetzen();
rahmen.src = "about:blank"; rahmen.src = "about:blank";
@@ -555,6 +939,61 @@ export function initFilePreview(wurzel = document) {
aktuell = null; aktuell = null;
}; };
/* Unterschrieben und unveraendert? Die Antwort kommt aus dem
Aenderungsprotokoll: dort steht die Pruefsumme der abgelegten Fassung.
Stimmt sie, bekommt die Seite einen gruenen Schleier -- man soll es sehen,
bevor man liest, nicht danach. */
const pruefungZuruecksetzen = () => {
pdfBehaelter.classList.remove("viewer__pdf--unveraendert", "viewer__pdf--veraendert");
if (pruefzeile) {
pruefzeile.hidden = true;
pruefzeile.textContent = "";
pruefzeile.className = "viewer__pruefung";
}
};
const pruefen = async (adresse) => {
pruefungZuruecksetzen();
if (!adresse) return;
try {
const stand = await window.tesm.api(adresse);
if (stand.status === "unveraendert" || stand.status === "veraendert") {
// Ein unterschriebenes Dokument ist abgeschlossen. Wer daraus eine
// neue Fassung machen koennte, koennte auch eine Unterschrift unter
// einen veraenderten Text setzen -- also gibt es hier nichts mehr zu
// bearbeiten. Der Server weist es zusaetzlich ab.
if (unterschreibenKnopf) unterschreibenKnopf.hidden = true;
if (speichern) speichern.hidden = true;
if (zeile) zeile.hidden = true;
}
if (stand.status === "unveraendert") {
pdfBehaelter.classList.add("viewer__pdf--unveraendert");
if (pruefzeile) {
pruefzeile.hidden = false;
pruefzeile.className = "viewer__pruefung viewer__pruefung--gut";
const namen = (stand.namen || []).join(", ");
pruefzeile.textContent =
`Unverändert seit der Unterschrift${namen ? ` von ${namen}` : ""}` +
` | Prüfsumme stimmt mit dem Änderungsprotokoll überein.`;
}
} else if (stand.status === "veraendert") {
pdfBehaelter.classList.add("viewer__pdf--veraendert");
if (pruefzeile) {
pruefzeile.hidden = false;
pruefzeile.className = "viewer__pruefung viewer__pruefung--schlecht";
pruefzeile.textContent =
"Diese Datei wurde nach der Unterschrift verändert – die Prüfsumme " +
"weicht vom Änderungsprotokoll ab.";
}
}
} catch (fehler) {
// Keine Aussage ist besser als eine falsche: ohne Antwort bleibt die
// Seite schlicht ungekennzeichnet.
console.error("Signaturpruefung", fehler);
}
};
const oeffnen = async (ausloeser) => { const oeffnen = async (ausloeser) => {
aufraeumen(); aufraeumen();
const art = ausloeser.dataset.fillKind || "text"; const art = ausloeser.dataset.fillKind || "text";
@@ -570,6 +1009,7 @@ export function initFilePreview(wurzel = document) {
return; return;
} }
if (hinweis) hinweis.textContent = "PDF wird geladen ..."; if (hinweis) hinweis.textContent = "PDF wird geladen ...";
pruefen(ausloeser.dataset.fillPruefung || "");
try { try {
pdf = await pdfBetrachter(pdfScroll, pdfSeiten, quelle, basis, { pdf = await pdfBetrachter(pdfScroll, pdfSeiten, quelle, basis, {
formulare: rumpf.dataset.viewerFormulare === "1", formulare: rumpf.dataset.viewerFormulare === "1",
@@ -586,7 +1026,7 @@ export function initFilePreview(wurzel = document) {
// Browsers die einzige Spur, die man hat. // Browsers die einzige Spur, die man hat.
const grund = String(fehler?.message || fehler || "").slice(0, 120); const grund = String(fehler?.message || fehler || "").slice(0, 120);
hinweis.textContent = hinweis.textContent =
"Die Formularansicht liess sich nicht laden -- es bleibt bei der einfachen Anzeige." + "Die Formularansicht ließ sich nicht laden – es bleibt bei der einfachen Anzeige." +
(grund ? ` (${grund})` : ""); (grund ? ` (${grund})` : "");
// Die Leiste traegt den Hinweis. "zeigen('text')" hat sie gerade // Die Leiste traegt den Hinweis. "zeigen('text')" hat sie gerade
// verborgen -- ohne diese Zeile blieb der Fehlschlag unsichtbar, und // verborgen -- ohne diese Zeile blieb der Fehlschlag unsichtbar, und
@@ -620,18 +1060,92 @@ export function initFilePreview(wurzel = document) {
if (!aktuell) return; if (!aktuell) return;
const teile = aktuell.pfad.split("/"); const teile = aktuell.pfad.split("/");
const datei = teile.pop() || "formular.pdf"; const datei = teile.pop() || "formular.pdf";
ordnerFeld.value = teile.join("/"); ordnerFuellen(eigeneOrdner(), teile.join("/"));
// Ohne Endung: die haengt der Server an. Eine Datei, die auf ".pdf.txt" // Ohne Endung: die haengt der Server an. Eine Datei, die auf ".pdf.txt"
// oder auf gar nichts endet, oeffnet auf einer Freigabe nichts mehr. // oder auf gar nichts endet, oeffnet auf einer Freigabe nichts mehr.
// Der Zusatz haelt die Vorlage heil, auch wenn jemand blind speichert. // Der Zusatz haelt die Vorlage heil, auch wenn jemand blind speichert.
nameFeld.value = datei.replace(/\.pdf$/i, "") + "-ausgefuellt"; // Der Zusatz sagt, was in der Datei steht: unterschrieben schlaegt
// ausgefuellt. Und einmal reicht -- wer eine Fassung noch einmal oeffnet
// und speichert, bekam sonst "...-ausgefuellt-ausgefuellt".
const rumpfname = datei.replace(/\.pdf$/i, "");
const zusatz = pdf?.unterschriften().length ? "-unterschrieben" : "-ausgefuellt";
const bereits = new RegExp(`${zusatz}$`, "i").test(rumpfname);
// Eine unterschriebene Fassung aus einer ausgefuellten: der alte Zusatz
// haette sonst zweimal dran gestanden ("-ausgefuellt-unterschrieben").
const blank = rumpfname.replace(/-(ausgefuellt|unterschrieben)$/i, "");
nameFeld.value = bereits ? rumpfname : `${blank}${zusatz}`;
ersetzenFeld.checked = false; ersetzenFeld.checked = false;
ersetzenKasten?.classList.remove("is-gefragt");
}; };
/* Unterschreiben: Name tippen, zeichnen, ins Dokument setzen. */
const pad = padLeinwand ? unterschriftPad(padLeinwand) : null;
const panelZeigen = (offen) => {
if (!unterschriftPanel) return;
unterschriftPanel.hidden = !offen;
if (offen) {
pad?.leeren();
if (padHinweis) padHinweis.textContent = "";
vornameFeld?.focus();
}
};
// Gesetzt heisst fertig zum Ablegen: die Zeile geht von selbst auf, statt
// dass man den Knopf daneben sucht.
pdfScroll?.addEventListener("unterschrieben", () => {
if (!zeile || !zeile.hidden) return;
speichern?.click();
});
unterschreibenKnopf?.addEventListener("click", () => {
if (!pdf) return;
panelZeigen(unterschriftPanel?.hidden !== false);
});
padLeeren?.addEventListener("click", () => pad?.leeren());
padAbbrechen?.addEventListener("click", () => panelZeigen(false));
padSetzen?.addEventListener("click", () => {
if (!pdf || !pad) return;
const name = [vornameFeld?.value, nachnameFeld?.value]
.map((wert) => (wert || "").trim())
.filter(Boolean)
.join(" ");
// Beides ist noetig, und beides wird hier gesagt statt stumm abgelehnt.
if (!name) {
if (padHinweis) padHinweis.textContent = "Bitte Vor- und Nachnamen eintragen.";
vornameFeld?.focus();
return;
}
const zeichnung = pad.alsPng();
if (!zeichnung) {
if (padHinweis) padHinweis.textContent = "Bitte im Feld unterschreiben.";
return;
}
pdf.unterschriftAnnehmen(
{ ...zeichnung, name, zeit: jetztText() },
bezeugen ? konto : ""
);
panelZeigen(false);
window.tesm.showToast(
"Jetzt auf die Stelle im Dokument tippen – danach lässt sie sich noch "
+ "verschieben und in der Größe anpassen.",
"info"
);
});
speichern?.addEventListener("click", () => { speichern?.addEventListener("click", () => {
if (!pdf || !aktuell) return; if (!pdf || !aktuell) return;
vorschlagen(); vorschlagen();
zeile.hidden = false; zeile.hidden = false;
if (modusFeld && pdf.unterschriften().length) {
// Mit Unterschrift gibt es nur einen Weg -- und das Feld soll nicht
// etwas anderes behaupten.
modusFeld.value = "fertig";
modusFeld.disabled = true;
}
nameFeld.focus(); nameFeld.focus();
nameFeld.select(); nameFeld.select();
}); });
@@ -640,6 +1154,46 @@ export function initFilePreview(wurzel = document) {
zeile.hidden = true; zeile.hidden = true;
}); });
/* Zielfreigabe: die Ordnerliste gehoert zur gewaehlten Freigabe, nicht zur
geoeffneten. Geholt wird sie erst bei Bedarf -- alle Listen im
Seitenaufbau mitzuschicken waere Ballast fuer einen seltenen Fall. */
/** Fuellt die Ordnerauswahl -- erster Eintrag ist immer die Wurzel. */
const ordnerFuellen = (ordner, ausgewaehlt = "") => {
if (!ordnerFeld) return;
const wurzel = document.createElement("option");
wurzel.value = "";
wurzel.textContent = "Wurzelverzeichnis der Freigabe";
const eintraege = [wurzel];
for (const name of ordner) {
const option = document.createElement("option");
option.value = name;
option.textContent = name;
eintraege.push(option);
}
// Der Ordner der Vorlage kann tiefer liegen, als die Liste reicht.
if (ausgewaehlt && !ordner.includes(ausgewaehlt)) {
const option = document.createElement("option");
option.value = ausgewaehlt;
option.textContent = ausgewaehlt;
eintraege.splice(1, 0, option);
}
ordnerFeld.replaceChildren(...eintraege);
ordnerFeld.value = ausgewaehlt;
};
zielFeld?.addEventListener("change", async () => {
const vorlage = zielFeld.dataset.urlVorlage || "";
if (!vorlage) return;
try {
const antwort = await window.tesm.api(vorlage.replace("__token__", zielFeld.value));
ordnerFuellen(antwort.folders || []);
} catch (fehler) {
// Kein Beinbruch: dann bleibt es bei der Wurzel.
console.error("Ordnerliste", fehler);
ordnerFuellen([]);
}
});
jetzt?.addEventListener("click", async () => { jetzt?.addEventListener("click", async () => {
if (!pdf || !aktuell) return; if (!pdf || !aktuell) return;
const ziel = speichern.dataset.url; const ziel = speichern.dataset.url;
@@ -647,13 +1201,20 @@ export function initFilePreview(wurzel = document) {
const vorher = jetzt.textContent; const vorher = jetzt.textContent;
jetzt.textContent = "Speichern ..."; jetzt.textContent = "Speichern ...";
try { try {
const gesetzt = pdf.unterschriften();
const bytes = await pdf.speichern(rumpf.dataset.viewerPdflib || "", { const bytes = await pdf.speichern(rumpf.dataset.viewerPdflib || "", {
abschliessen: modusFeld?.value === "fertig", // Unterschrieben heisst abgeschlossen: ein Dokument, in dem nach der
// Unterschrift noch Felder aenderbar waeren, ist keine Unterschrift
// wert.
abschliessen: modusFeld?.value === "fertig" || gesetzt.length > 0,
zeuge: bezeugen ? konto : "",
}); });
const daten = new FormData(); const daten = new FormData();
daten.append("path", aktuell.pfad); daten.append("path", aktuell.pfad);
daten.append("ordner", ordnerFeld.value.trim()); daten.append("ordner", ordnerFeld.value.trim());
daten.append("name", nameFeld.value.trim()); daten.append("name", nameFeld.value.trim());
if (zielFeld?.value) daten.append("ziel", zielFeld.value);
if (gesetzt.length) daten.append("unterschriften", JSON.stringify(gesetzt));
if (ersetzenFeld.checked) daten.append("ersetzen", "1"); if (ersetzenFeld.checked) daten.append("ersetzen", "1");
daten.append( daten.append(
"datei", "datei",
@@ -669,6 +1230,12 @@ export function initFilePreview(wurzel = document) {
// Zeile bleibt offen: meist ist es ein Name, der schon vergeben ist. // Zeile bleibt offen: meist ist es ein Name, der schon vergeben ist.
console.error("Formular speichern", fehler); console.error("Formular speichern", fehler);
window.tesm.showToast(fehler.message || "Speichern fehlgeschlagen.", "danger"); window.tesm.showToast(fehler.message || "Speichern fehlgeschlagen.", "danger");
// Genau dieser Fall hat eine Antwort auf der Zeile -- dann zeigen wir
// auch darauf, statt es bei einer Meldung zu belassen.
if (fehler.status === 409) {
ersetzenKasten?.classList.add("is-gefragt");
ersetzenFeld?.focus();
}
} finally { } finally {
jetzt.disabled = false; jetzt.disabled = false;
jetzt.textContent = vorher; jetzt.textContent = vorher;
+6 -6
View File
@@ -78,7 +78,7 @@ class Result:
UNAVAILABLE = Result( UNAVAILABLE = Result(
ok=False, ok=False,
stderr=( stderr=(
"Systemaktionen sind in dieser Umgebung nicht verfuegbar " "Systemaktionen sind in dieser Umgebung nicht verfügbar "
"(Helfer nicht installiert oder kein Linux-Host)." "(Helfer nicht installiert oder kein Linux-Host)."
), ),
code=127, code=127,
@@ -132,16 +132,16 @@ class SysOps:
False, False,
"", "",
"sudo wird durch 'NoNewPrivileges' der systemd-Unit blockiert. Ohne sudo " "sudo wird durch 'NoNewPrivileges' der systemd-Unit blockiert. Ohne sudo "
"kann der Dienst den Helfer nicht aufrufen -- keine Systemaktion " "kann der Dienst den Helfer nicht aufrufen – keine Systemaktion "
"funktioniert. In der Unit 'NoNewPrivileges=yes' entfernen und " "funktioniert. In der Unit 'NoNewPrivileges=yes' entfernen und "
"'systemctl daemon-reload && systemctl restart' ausfuehren.", "'systemctl daemon-reload && systemctl restart' ausführen.",
result.code, result.code,
) )
if not result.ok and "a password is required" in result.output.lower(): if not result.ok and "a password is required" in result.output.lower():
return Result( return Result(
False, False,
"", "",
"sudo verlangt ein Passwort. Die sudoers-Regel fuer den Helfer fehlt oder " "sudo verlangt ein Passwort. Die sudoers-Regel für den Helfer fehlt oder "
"greift nicht.", "greift nicht.",
result.code, result.code,
) )
@@ -177,7 +177,7 @@ class SysOps:
check=False, check=False,
) )
except subprocess.TimeoutExpired: except subprocess.TimeoutExpired:
return Result(False, stderr=f"Zeitlimit ueberschritten ({verb}).", code=124) return Result(False, stderr=f"Zeitlimit überschritten ({verb}).", code=124)
except OSError as exc: except OSError as exc:
return Result(False, stderr=str(exc), code=126) return Result(False, stderr=str(exc), code=126)
return Result( return Result(
@@ -210,7 +210,7 @@ class SysOps:
check=False, check=False,
) )
except subprocess.TimeoutExpired: except subprocess.TimeoutExpired:
return Result(False, stderr="Zeitlimit ueberschritten.", code=124) return Result(False, stderr="Zeitlimit überschritten.", code=124)
except OSError as exc: except OSError as exc:
return Result(False, stderr=str(exc), code=126) return Result(False, stderr=str(exc), code=126)
return Result( return Result(
@@ -136,24 +136,24 @@ def validate_static(
try: try:
prefix_length = int(prefix) prefix_length = int(prefix)
except (TypeError, ValueError) as exc: except (TypeError, ValueError) as exc:
raise ValueError("Die Praefixlaenge muss eine Zahl sein (z. B. 24).") from exc raise ValueError("Die Präfixlänge muss eine Zahl sein (z. B. 24).") from exc
if not 1 <= prefix_length <= 32: if not 1 <= prefix_length <= 32:
raise ValueError("Die Praefixlaenge muss zwischen 1 und 32 liegen.") raise ValueError("Die Präfixlänge muss zwischen 1 und 32 liegen.")
try: try:
interface_ip = ipaddress.ip_interface(f"{address.strip()}/{prefix_length}") interface_ip = ipaddress.ip_interface(f"{address.strip()}/{prefix_length}")
except ValueError as exc: except ValueError as exc:
raise ValueError(f"{address!r} ist keine gueltige IPv4-Adresse.") from exc raise ValueError(f"{address!r} ist keine gültige IPv4-Adresse.") from exc
gateway_clean = (gateway or "").strip() gateway_clean = (gateway or "").strip()
if gateway_clean: if gateway_clean:
try: try:
gateway_ip = ipaddress.ip_address(gateway_clean) gateway_ip = ipaddress.ip_address(gateway_clean)
except ValueError as exc: except ValueError as exc:
raise ValueError(f"{gateway_clean!r} ist keine gueltige Gateway-Adresse.") from exc raise ValueError(f"{gateway_clean!r} ist keine gültige Gateway-Adresse.") from exc
if gateway_ip not in interface_ip.network: if gateway_ip not in interface_ip.network:
raise ValueError( raise ValueError(
f"Das Gateway {gateway_clean} liegt nicht im Netz {interface_ip.network}. " f"Das Gateway {gateway_clean} liegt nicht im Netz {interface_ip.network}. "
"So waere der Host nach der Aenderung nicht mehr erreichbar." "So wäre der Host nach der Änderung nicht mehr erreichbar."
) )
servers: list[str] = [] servers: list[str] = []
@@ -163,7 +163,7 @@ def validate_static(
try: try:
ipaddress.ip_address(token) ipaddress.ip_address(token)
except ValueError as exc: except ValueError as exc:
raise ValueError(f"{token!r} ist keine gueltige DNS-Adresse.") from exc raise ValueError(f"{token!r} ist keine gültige DNS-Adresse.") from exc
servers.append(token) servers.append(token)
return str(interface_ip.ip), prefix_length, gateway_clean, servers return str(interface_ip.ip), prefix_length, gateway_clean, servers
@@ -185,7 +185,7 @@ def render_netplan(
zuruecksetzen -- ein realer Fehler des Vorgaengerprojekts. zuruecksetzen -- ein realer Fehler des Vorgaengerprojekts.
""" """
lines = [ lines = [
"# Von TESM verwaltet. Diese Datei enthaelt ausschliesslich das unten", "# Von TESM verwaltet. Diese Datei enthält ausschließlich das unten",
"# genannte Interface; alle anderen bleiben bei ihrer", "# genannte Interface; alle anderen bleiben bei ihrer",
"# Distributionskonfiguration.", "# Distributionskonfiguration.",
"network:", "network:",
@@ -94,7 +94,7 @@ def validate_domain(value: str) -> str:
if not text: if not text:
return "" return ""
if not DOMAIN_RE.match(text): if not DOMAIN_RE.match(text):
raise ValueError(f"{text!r} ist kein gueltiger Domainname.") raise ValueError(f"{text!r} ist kein gültiger Domainname.")
return text return text
@@ -122,8 +122,8 @@ def render(config: WebConfig) -> str:
] ]
lines: list[str] = [ lines: list[str] = [
f"# Von TESM erzeugt ({config.app_key}). Aenderungen bitte in der Anwendung", f"# Von TESM erzeugt ({config.app_key}). Änderungen bitte in der Anwendung",
"# vornehmen -- diese Datei wird bei jeder Uebernahme neu geschrieben.", "# vornehmen – diese Datei wird bei jeder Übernahme neu geschrieben.",
"", "",
] ]
@@ -200,7 +200,7 @@ def _site_body(config: WebConfig, common_proxy: list[str], body: str, *, tls: bo
f" client_max_body_size {body};", f" client_max_body_size {body};",
f" # {uploads.NGINX_ZUSCHLAG_MB} MB mehr als die Anwendung erlaubt, damit deren" f" # {uploads.NGINX_ZUSCHLAG_MB} MB mehr als die Anwendung erlaubt, damit deren"
" eigene,", " eigene,",
" # erklaerende Fehlerseite gewinnt statt der generischen 413 von nginx.", " # erklärende Fehlerseite gewinnt statt der generischen 413 von nginx.",
"", "",
" location /assets/ {", " location /assets/ {",
f" alias {config.static_root.rstrip('/')}/;", f" alias {config.static_root.rstrip('/')}/;",
@@ -256,10 +256,10 @@ def apply_config(sysops: SysOps, content: str) -> dict[str, Any]:
if not result.ok: if not result.ok:
return { return {
"ok": False, "ok": False,
"message": "nginx hat die Konfiguration abgelehnt -- der alte Stand gilt weiter.", "message": "nginx hat die Konfiguration abgelehnt – der alte Stand gilt weiter.",
"detail": result.output, "detail": result.output,
} }
return {"ok": True, "message": "Konfiguration uebernommen.", "detail": result.output} return {"ok": True, "message": "Konfiguration übernommen.", "detail": result.output}
def request_certificate( def request_certificate(
@@ -274,13 +274,13 @@ def request_certificate(
if not domain: if not domain:
return {"ok": False, "message": "Es ist keine Domain konfiguriert."} return {"ok": False, "message": "Es ist keine Domain konfiguriert."}
if not email: if not email:
return {"ok": False, "message": "Fuer Let's Encrypt ist eine E-Mail-Adresse noetig."} return {"ok": False, "message": "Für Let's Encrypt ist eine E-Mail-Adresse nötig."}
result = sysops.certbot_issue(domain, email, webroot) result = sysops.certbot_issue(domain, email, webroot)
if not result.ok: if not result.ok:
return {"ok": False, "message": "certbot ist fehlgeschlagen.", "detail": result.output} return {"ok": False, "message": "certbot ist fehlgeschlagen.", "detail": result.output}
return { return {
"ok": True, "ok": True,
"message": f"Zertifikat fuer {domain} ausgestellt.", "message": f"Zertifikat für {domain} ausgestellt.",
"detail": result.output, "detail": result.output,
"cert_path": f"/etc/letsencrypt/live/{domain}/fullchain.pem", "cert_path": f"/etc/letsencrypt/live/{domain}/fullchain.pem",
"key_path": f"/etc/letsencrypt/live/{domain}/privkey.pem", "key_path": f"/etc/letsencrypt/live/{domain}/privkey.pem",
@@ -303,9 +303,9 @@ def create_self_signed(
""" """
name = (common_name or "").strip().lower().rstrip(".") name = (common_name or "").strip().lower().rstrip(".")
if not name: if not name:
return {"ok": False, "message": "Es fehlt ein Name fuer das Zertifikat."} return {"ok": False, "message": "Es fehlt ein Name für das Zertifikat."}
if not re.fullmatch(r"[A-Za-z0-9]([A-Za-z0-9.-]{0,251}[A-Za-z0-9])?", name): if not re.fullmatch(r"[A-Za-z0-9]([A-Za-z0-9.-]{0,251}[A-Za-z0-9])?", name):
return {"ok": False, "message": f"{name!r} ist als Zertifikatsname nicht zulaessig."} return {"ok": False, "message": f"{name!r} ist als Zertifikatsname nicht zulässig."}
cleaned: list[str] = [] cleaned: list[str] = []
for entry in alt_names: for entry in alt_names:
@@ -328,8 +328,8 @@ def create_self_signed(
return { return {
"ok": True, "ok": True,
"message": ( "message": (
f"Selbstsigniertes Zertifikat fuer {name} erzeugt. Browser zeigen dafuer eine " f"Selbstsigniertes Zertifikat für {name} erzeugt. Browser zeigen dafür eine "
"Warnung -- fuer ein oeffentlich erreichbares System bitte Let's Encrypt nutzen." "Warnung – für ein öffentlich erreichbares System bitte Let's Encrypt nutzen."
), ),
"cert_path": paths[0], "cert_path": paths[0],
"key_path": paths[1], "key_path": paths[1],
@@ -181,7 +181,7 @@
<div class="palette__hint"> <div class="palette__hint">
<span><kbd>&uarr;</kbd><kbd>&darr;</kbd> wählen</span> <span><kbd>&uarr;</kbd><kbd>&darr;</kbd> wählen</span>
<span><kbd>Enter</kbd> öffnen</span> <span><kbd>Enter</kbd> öffnen</span>
<span><kbd>Esc</kbd> schliessen</span> <span><kbd>Esc</kbd> schließen</span>
</div> </div>
</dialog> </dialog>
<script type="application/json" id="palette-data">{{ palette_entries | tojson }}</script> <script type="application/json" id="palette-data">{{ palette_entries | tojson }}</script>
@@ -44,8 +44,11 @@
<a class="btn btn--sm btn--ghost" href="{{ url_for('logs.audit_view') }}">Zurücksetzen</a> <a class="btn btn--sm btn--ghost" href="{{ url_for('logs.audit_view') }}">Zurücksetzen</a>
</form> </form>
{# "table--stapelbar": schmal wird aus jeder Zeile ein kleiner Block. Vorher
quetschte sich das Detail mit Pruefsumme in eine Spalte von zwei
Zentimetern und lief ueber zehn Zeilen -- lesbar war das nicht. #}
<div class="table-wrap"> <div class="table-wrap">
<table class="table table--compact" data-behavior="sortable"> <table class="table table--compact table--stapelbar" data-behavior="sortable">
<thead> <thead>
<tr> <tr>
<th class="sortable">Zeit</th> <th class="sortable">Zeit</th>
@@ -60,12 +63,18 @@
<tbody> <tbody>
{% for row in rows %} {% for row in rows %}
<tr> <tr>
<td class="fs-sm nowrap" data-sort-value="{{ row.ts }}">{{ row.ts | dt }}</td> <td class="fs-sm nowrap" data-spalte="Zeit" data-sort-value="{{ row.ts }}">
<td class="fs-sm fw-600">{{ row.actor }}</td> {{ row.ts | dt }}
<td class="mono fs-xs">{{ row.action }}</td> </td>
<td class="fs-sm">{{ row.target }}</td> <td class="fs-sm fw-600" data-spalte="Benutzer">{{ row.actor }}</td>
<td class="fs-sm muted break-all">{{ row.detail | audit_detail }}</td> <td class="mono fs-xs" data-spalte="Aktion">{{ row.action }}</td>
<td> <td class="fs-sm" data-spalte="Ziel">{{ row.target }}</td>
{# Ohne die Minuszeichen stuende bei "white-space: pre-line" die
Einrueckung der Vorlage als leere erste Zeile in der Zelle. #}
<td class="fs-sm muted break-all td--detail" data-spalte="Detail">
{{- row.detail | audit_detail -}}
</td>
<td data-spalte="Stufe">
{# Ampel: Info grün, Hinweis gelb, Warnung rot. Vorher war Info {# Ampel: Info grün, Hinweis gelb, Warnung rot. Vorher war Info
grau und Hinweis blau -- dabei war nicht zu sehen, was harmlos grau und Hinweis blau -- dabei war nicht zu sehen, was harmlos
ist und was Aufmerksamkeit braucht. "kritisch" bekommt eine ist und was Aufmerksamkeit braucht. "kritisch" bekommt eine
@@ -76,7 +85,7 @@
{% elif row.severity == 'notice' %}{{ pill('Hinweis', 'warning') }} {% elif row.severity == 'notice' %}{{ pill('Hinweis', 'warning') }}
{% else %}{{ pill('Info', 'online') }}{% endif %} {% else %}{{ pill('Info', 'online') }}{% endif %}
</td> </td>
<td class="mono fs-xs faint">{{ row.ip or '&ndash;' | safe }}</td> <td class="mono fs-xs faint" data-spalte="Herkunft">{{ row.ip or '&ndash;' | safe }}</td>
</tr> </tr>
{% endfor %} {% endfor %}
</tbody> </tbody>
@@ -2,7 +2,7 @@
{% from "tesm_core/macros.html" import field, select_field, checkbox, pill, search_box, modal, empty_state, alert %} {% from "tesm_core/macros.html" import field, select_field, checkbox, pill, search_box, modal, empty_state, alert %}
{% block page_title %}Benutzer{% endblock %} {% block page_title %}Benutzer{% endblock %}
{% block page_sub %}{{ users | length }} Konten &middot; Rechte kommen ausschliesslich aus Gruppen{% endblock %} {% block page_sub %}{{ users | length }} Konten &middot; Rechte kommen ausschließlich aus Gruppen{% endblock %}
{% block topbar_extra %} {% block topbar_extra %}
{{ search_box('users-table', 'Benutzer suchen ...', count_id='users-visible') }} {{ search_box('users-table', 'Benutzer suchen ...', count_id='users-visible') }}
+7 -7
View File
@@ -84,7 +84,7 @@ def _derive_key(passphrase: str, salt: bytes, params: dict[str, int]) -> bytes:
def seal(payload: dict[str, Any], passphrase: str, *, app_tag: str) -> str: def seal(payload: dict[str, Any], passphrase: str, *, app_tag: str) -> str:
if len(passphrase or "") < MIN_PASSPHRASE_LENGTH: if len(passphrase or "") < MIN_PASSPHRASE_LENGTH:
raise TransferError( raise TransferError(
f"Die Passphrase muss mindestens {MIN_PASSPHRASE_LENGTH} Zeichen haben -- sie ist " f"Die Passphrase muss mindestens {MIN_PASSPHRASE_LENGTH} Zeichen haben – sie ist "
"der einzige Schutz dieser Datei." "der einzige Schutz dieser Datei."
) )
salt = secrets.token_bytes(KDF_SALT_BYTES) salt = secrets.token_bytes(KDF_SALT_BYTES)
@@ -116,21 +116,21 @@ def unseal(
try: try:
envelope = json.loads(text) envelope = json.loads(text)
except ValueError as exc: except ValueError as exc:
raise TransferError("Die Datei ist kein gueltiges JSON.") from exc raise TransferError("Die Datei ist kein gültiges JSON.") from exc
if not isinstance(envelope, dict): if not isinstance(envelope, dict):
raise TransferError("Die Datei enthaelt kein Objekt.") raise TransferError("Die Datei enthält kein Objekt.")
if envelope.get("app") != app_tag: if envelope.get("app") != app_tag:
raise TransferError( raise TransferError(
f"Diese Sicherung gehoert zu einer anderen Anwendung ({envelope.get('app')!r})." f"Diese Sicherung gehört zu einer anderen Anwendung ({envelope.get('app')!r})."
) )
if int(envelope.get("format") or 0) != FORMAT_VERSION: if int(envelope.get("format") or 0) != FORMAT_VERSION:
raise TransferError( raise TransferError(
f"Nicht unterstuetztes Sicherungsformat ({envelope.get('format')!r}, " f"Nicht unterstütztes Sicherungsformat ({envelope.get('format')!r}, "
f"erwartet {FORMAT_VERSION})." f"erwartet {FORMAT_VERSION})."
) )
kdf = envelope.get("kdf") or {} kdf = envelope.get("kdf") or {}
if kdf.get("name") != "argon2id": if kdf.get("name") != "argon2id":
raise TransferError("Unbekanntes Schluesselableitungsverfahren.") raise TransferError("Unbekanntes Schlüsselableitungsverfahren.")
header = { header = {
key: envelope[key] key: envelope[key]
@@ -153,7 +153,7 @@ def unseal(
) )
except (InvalidTag, KeyError, ValueError) as exc: except (InvalidTag, KeyError, ValueError) as exc:
raise TransferError( raise TransferError(
"Die Datei konnte nicht geoeffnet werden. Passphrase falsch oder Datei beschaedigt." "Die Datei konnte nicht geöffnet werden. Passphrase falsch oder Datei beschädigt."
) from exc ) from exc
return json.loads(plain.decode("utf-8")), header return json.loads(plain.decode("utf-8")), header
+1 -1
View File
@@ -197,7 +197,7 @@ def yes_no(value: Any) -> str:
#: Tippfehler. Uebersetzt wird an genau einer Stelle, damit die beiden #: Tippfehler. Uebersetzt wird an genau einer Stelle, damit die beiden
#: Anwendungen nicht auseinanderlaufen. #: Anwendungen nicht auseinanderlaufen.
ISSUER_SOURCES: dict[str, tuple[str, str]] = { ISSUER_SOURCES: dict[str, tuple[str, str]] = {
"tofu": ("Erstkontakt", "beim ersten Einspielen automatisch uebernommen"), "tofu": ("Erstkontakt", "beim ersten Einspielen automatisch übernommen"),
"admin-confirmed": ( "admin-confirmed": (
"Bestaetigt", "Bestaetigt",
"Ausstellerwechsel von einem Administrator freigegeben", "Ausstellerwechsel von einem Administrator freigegeben",
@@ -99,7 +99,7 @@ def update_profile():
if current_user.is_directory_account: if current_user.is_directory_account:
flash( flash(
"Ihre Stammdaten kommen aus dem Verzeichnisdienst und werden bei jeder " "Ihre Stammdaten kommen aus dem Verzeichnisdienst und werden bei jeder "
"Anmeldung uebernommen.", "Anmeldung übernommen.",
"info", "info",
) )
return redirect(url_for("account.overview")) return redirect(url_for("account.overview"))
@@ -123,7 +123,7 @@ def update_profile():
def upload_avatar(): def upload_avatar():
upload = request.files.get("avatar") upload = request.files.get("avatar")
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("account.overview")) return redirect(url_for("account.overview"))
suffix = Path(upload.filename).suffix.lower() suffix = Path(upload.filename).suffix.lower()
@@ -139,7 +139,7 @@ def upload_avatar():
data = upload.stream.read(MAX_AVATAR_BYTES + 1) data = upload.stream.read(MAX_AVATAR_BYTES + 1)
if len(data) > MAX_AVATAR_BYTES: if len(data) > MAX_AVATAR_BYTES:
flash("Das Bild ist zu gross (maximal 2 MB).", "danger") flash("Das Bild ist zu groß (maximal 2 MB).", "danger")
return redirect(url_for("account.overview")) return redirect(url_for("account.overview"))
filename = f"{current_user.id}-{secrets.token_hex(8)}{suffix}" filename = f"{current_user.id}-{secrets.token_hex(8)}{suffix}"
@@ -215,7 +215,7 @@ def security():
def change_password(): def change_password():
conn = db() conn = db()
if current_user.is_directory_account: if current_user.is_directory_account:
flash("Verzeichniskonten aendern ihr Passwort im Verzeichnisdienst.", "info") flash("Verzeichniskonten ändern ihr Passwort im Verzeichnisdienst.", "info")
return redirect(url_for("account.security")) return redirect(url_for("account.security"))
current = request.form.get("current_password") or "" current = request.form.get("current_password") or ""
@@ -226,7 +226,7 @@ def change_password():
flash("Das aktuelle Passwort ist nicht korrekt.", "danger") flash("Das aktuelle Passwort ist nicht korrekt.", "danger")
return redirect(url_for("account.security")) return redirect(url_for("account.security"))
if new != repeat: if new != repeat:
flash("Die beiden neuen Passwoerter stimmen nicht ueberein.", "danger") flash("Die beiden neuen Passwörter stimmen nicht überein.", "danger")
return redirect(url_for("account.security")) return redirect(url_for("account.security"))
if passwords.constant_time_equals(current, new): if passwords.constant_time_equals(current, new):
flash("Das neue Passwort muss sich vom alten unterscheiden.", "danger") flash("Das neue Passwort muss sich vom alten unterscheiden.", "danger")
@@ -244,7 +244,7 @@ def change_password():
flash(str(exc), "danger") flash(str(exc), "danger")
return redirect(url_for("account.security")) return redirect(url_for("account.security"))
flash(f"Passwort geaendert. {revoked} weitere Sitzung(en) wurden beendet.", "success") flash(f"Passwort geändert. {revoked} weitere Sitzung(en) wurden beendet.", "success")
return redirect(url_for("account.security")) return redirect(url_for("account.security"))
@@ -253,7 +253,7 @@ def change_password():
@reauth_required @reauth_required
def totp_start(): def totp_start():
session["_totp_setup"] = totp.generate_secret() session["_totp_setup"] = totp.generate_secret()
flash("Scannen Sie den Code und bestaetigen Sie mit einem gueltigen Zahlencode.", "info") flash("Scannen Sie den Code und bestätigen Sie mit einem gültigen Zahlencode.", "info")
return redirect(url_for("account.security")) return redirect(url_for("account.security"))
@@ -263,7 +263,7 @@ def totp_start():
def totp_confirm(): def totp_confirm():
secret = session.get("_totp_setup", "") secret = session.get("_totp_setup", "")
if not secret: if not secret:
flash("Es laeuft keine Einrichtung. Bitte neu starten.", "danger") flash("Es läuft keine Einrichtung. Bitte neu starten.", "danger")
return redirect(url_for("account.security")) return redirect(url_for("account.security"))
if not totp.verify_code(secret, request.form.get("code") or ""): if not totp.verify_code(secret, request.form.get("code") or ""):
flash("Der Code ist nicht korrekt. Bitte erneut versuchen.", "danger") flash("Der Code ist nicht korrekt. Bitte erneut versuchen.", "danger")
@@ -306,7 +306,7 @@ def totp_confirm():
def totp_disable(): def totp_disable():
if core().config.security.require_totp_for_admins and current_user.is_admin: if core().config.security.require_totp_for_admins and current_user.is_admin:
flash( flash(
"Fuer Administratoren ist die Zwei-Faktor-Authentisierung in dieser " "Für Administratoren ist die Zwei-Faktor-Authentisierung in dieser "
"Installation verpflichtend.", "Installation verpflichtend.",
"danger", "danger",
) )
@@ -120,7 +120,7 @@ def user_update(user_id: int):
policy=core().password_policy, policy=core().password_policy,
must_change=True, must_change=True,
) )
changes.append("Passwort zurueckgesetzt") changes.append("Passwort zurückgesetzt")
session_store.revoke_all_for_user(conn, user_id, reason="password-reset") session_store.revoke_all_for_user(conn, user_id, reason="password-reset")
target = rbac.get_user(conn, user_id) or {} target = rbac.get_user(conn, user_id) or {}
if changes: if changes:
@@ -131,7 +131,7 @@ def user_update(user_id: int):
"; ".join(changes), "; ".join(changes),
severity="notice", severity="notice",
) )
flash("Aenderungen gespeichert." if changes else "Keine Aenderungen.", "success" if changes else "info") flash("Änderungen gespeichert." if changes else "Keine Änderungen.", "success" if changes else "info")
except (rbac.ValidationError, rbac.PermissionDenied) as exc: except (rbac.ValidationError, rbac.PermissionDenied) as exc:
flash(str(exc), "danger") flash(str(exc), "danger")
return redirect(url_for("admin.users")) return redirect(url_for("admin.users"))
@@ -153,7 +153,7 @@ def user_delete(user_id: int):
severity="warning", severity="warning",
) )
flash( flash(
"Konto endgueltig entfernt (Verzeichniskonto)." "Konto endgültig entfernt (Verzeichniskonto)."
if mode == "endgueltig" if mode == "endgueltig"
else "Konto in den Papierkorb verschoben.", else "Konto in den Papierkorb verschoben.",
"success", "success",
@@ -328,7 +328,7 @@ def group_update(group_id: int):
"; ".join(changes), "; ".join(changes),
severity="notice", severity="notice",
) )
flash("Aenderungen gespeichert." if changes else "Keine Aenderungen.", "success" if changes else "info") flash("Änderungen gespeichert." if changes else "Keine Änderungen.", "success" if changes else "info")
except (rbac.ValidationError, rbac.PermissionDenied) as exc: except (rbac.ValidationError, rbac.PermissionDenied) as exc:
flash(str(exc), "danger") flash(str(exc), "danger")
return redirect(url_for("admin.group_detail", group_id=group_id)) return redirect(url_for("admin.group_detail", group_id=group_id))
@@ -364,7 +364,7 @@ def group_apply_preset(group_id: int):
abort(404) abort(404)
preset = next((p for p in core().role_presets if p.key == group.preset_key), None) preset = next((p for p in core().role_presets if p.key == group.preset_key), None)
if preset is None: if preset is None:
flash("Fuer diese Gruppe ist keine Vorlage hinterlegt.", "info") flash("Für diese Gruppe ist keine Vorlage hinterlegt.", "info")
return redirect(url_for("admin.group_detail", group_id=group_id)) return redirect(url_for("admin.group_detail", group_id=group_id))
tree = core().permissions tree = core().permissions
with transaction(): with transaction():
@@ -377,7 +377,7 @@ def group_apply_preset(group_id: int):
unlock_system=True, unlock_system=True,
) )
_audit(conn, "group.preset_applied", group.name, preset.key, severity="notice") _audit(conn, "group.preset_applied", group.name, preset.key, severity="notice")
flash(f"Rechte der Vorlage {preset.name} uebernommen.", "success") flash(f"Rechte der Vorlage {preset.name} übernommen.", "success")
return redirect(url_for("admin.group_detail", group_id=group_id)) return redirect(url_for("admin.group_detail", group_id=group_id))
@@ -427,9 +427,15 @@ def nav_order():
if submitted: if submitted:
child_order[item.key] = extension.nav.sanitize_child_order(item.key, submitted) child_order[item.key] = extension.nav.sanitize_child_order(item.key, submitted)
with transaction() as conn: with transaction() as conn:
vorher = extension.settings.get(conn, "nav_order") or extension.nav.default_order()
extension.settings.set(conn, "nav_order", order, actor=current_user.username) extension.settings.set(conn, "nav_order", order, actor=current_user.username)
extension.settings.set(conn, "nav_child_order", child_order, actor=current_user.username) extension.settings.set(conn, "nav_child_order", child_order, actor=current_user.username)
_audit(conn, "system.nav_order_changed", severity="info") _audit(
conn,
"system.nav_order_changed",
detail=f"{', '.join(vorher)} -> {', '.join(order)}",
severity="info",
)
flash("Reihenfolge der Navigation gespeichert.", "success") flash("Reihenfolge der Navigation gespeichert.", "success")
return redirect(request.referrer or url_for("index")) return redirect(request.referrer or url_for("index"))
@@ -82,7 +82,7 @@ def overview():
"Debug": "ja" if config.debug else "nein", "Debug": "ja" if config.debug else "nein",
"Cookie Secure": "ja" if config.security.cookie_secure else "nein", "Cookie Secure": "ja" if config.security.cookie_secure else "nein",
"HSTS": "ja" if config.security.hsts_enabled else "nein", "HSTS": "ja" if config.security.hsts_enabled else "nein",
"2FA-Pflicht fuer Admins": "ja" if config.security.require_totp_for_admins else "nein", "2FA-Pflicht für Admins": "ja" if config.security.require_totp_for_admins else "nein",
}, },
now=datetime.now(timezone.utc), now=datetime.now(timezone.utc),
) )
@@ -109,7 +109,7 @@ def housekeeping():
severity="notice", severity="notice",
) )
flash( flash(
f"Aufgeraeumt: {sessions_removed} Sitzungen, {rate_removed} Ratenereignisse, " f"Aufgeräumt: {sessions_removed} Sitzungen, {rate_removed} Ratenereignisse, "
f"{len(archived)} archivierte Protokolltage.", f"{len(archived)} archivierte Protokolltage.",
"success", "success",
) )
@@ -139,12 +139,12 @@ def rotate_keys():
actor_id=current_user.id, actor_id=current_user.id,
action="system.data_key_rotated", action="system.data_key_rotated",
target=new_id, target=new_id,
detail=f"{migrated} Werte neu verschluesselt", detail=f"{migrated} Werte neu verschlüsselt",
severity="notice", severity="notice",
) )
flash( flash(
f"Neuer Datenschluessel {new_id} aktiv, {migrated} gespeicherte Geheimnisse " f"Neuer Datenschlüssel {new_id} aktiv, {migrated} gespeicherte Geheimnisse "
"wurden neu verschluesselt.", "wurden neu verschlüsselt.",
"success", "success",
) )
return redirect(url_for("diagnostics.overview")) return redirect(url_for("diagnostics.overview"))
@@ -86,7 +86,7 @@ def network_apply():
extension = core() extension = core()
sysops = extension.sysops sysops = extension.sysops
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("hostsettings.network")) return redirect(url_for("hostsettings.network"))
interface = (request.form.get("interface") or "").strip() interface = (request.form.get("interface") or "").strip()
@@ -127,7 +127,7 @@ def network_apply():
sysops.write_netplan(previous.stdout if previous.ok else "") sysops.write_netplan(previous.stdout if previous.ok else "")
sysops.apply_netplan() sysops.apply_netplan()
flash( flash(
f"Uebernahme fehlgeschlagen, alter Zustand wiederhergestellt: {applied.message}", f"Übernahme fehlgeschlagen, alter Zustand wiederhergestellt: {applied.message}",
"danger", "danger",
) )
return redirect(url_for("hostsettings.network")) return redirect(url_for("hostsettings.network"))
@@ -140,8 +140,8 @@ def network_apply():
_start_revert_timer(extension) _start_revert_timer(extension)
_audit("settings.network_applied", interface, severity="warning") _audit("settings.network_applied", interface, severity="warning")
flash( flash(
f"Uebernommen. Bitte innerhalb von {hostnet.REVERT_SECONDS} Sekunden bestaetigen -- " f"Übernommen. Bitte innerhalb von {hostnet.REVERT_SECONDS} Sekunden bestätigen – "
"sonst wird automatisch zurueckgerollt.", "sonst wird automatisch zurückgerollt.",
"warning", "warning",
) )
return redirect(url_for("hostsettings.network")) return redirect(url_for("hostsettings.network"))
@@ -151,10 +151,10 @@ def network_apply():
@permission_required("settings_network.edit") @permission_required("settings_network.edit")
def network_confirm(): def network_confirm():
if core().registry.pop(PENDING_NETWORK_KEY, None) is None: if core().registry.pop(PENDING_NETWORK_KEY, None) is None:
flash("Es liegt keine Aenderung zur Bestaetigung vor.", "info") flash("Es liegt keine Änderung zur Bestätigung vor.", "info")
else: else:
_audit("settings.network_confirmed", severity="notice") _audit("settings.network_confirmed", severity="notice")
flash("Netzwerkeinstellungen bestaetigt.", "success") flash("Netzwerkeinstellungen bestätigt.", "success")
return redirect(url_for("hostsettings.network")) return redirect(url_for("hostsettings.network"))
@@ -173,7 +173,7 @@ def _start_revert_timer(extension: Any) -> None:
conn, conn,
actor="system", actor="system",
action="settings.network_auto_reverted", action="settings.network_auto_reverted",
detail="Keine Bestaetigung innerhalb der Frist -- automatisch zurueckgerollt.", detail="Keine Bestätigung innerhalb der Frist – automatisch zurückgerollt.",
severity="warning", severity="warning",
) )
@@ -281,7 +281,7 @@ def web_save():
cert_path = (request.form.get("web_cert_path") or "").strip() cert_path = (request.form.get("web_cert_path") or "").strip()
key_path = (request.form.get("web_key_path") or "").strip() key_path = (request.form.get("web_key_path") or "").strip()
if https_enabled and not (cert_path and key_path): if https_enabled and not (cert_path and key_path):
flash("Fuer HTTPS werden Zertifikat und privater Schluessel gebraucht.", "danger") flash("Für HTTPS werden Zertifikat und privater Schlüssel gebraucht.", "danger")
return redirect(url_for("hostsettings.web")) return redirect(url_for("hostsettings.web"))
values = { values = {
@@ -298,7 +298,7 @@ def web_save():
with transaction(): with transaction():
changed = extension.settings.set_many(conn, values, actor=current_user.username) changed = extension.settings.set_many(conn, values, actor=current_user.username)
_audit("settings.web_saved", domain, ", ".join(changed), severity="notice") _audit("settings.web_saved", domain, ", ".join(changed), severity="notice")
flash("Gespeichert. Uebernehmen Sie die Konfiguration, damit sie wirksam wird.", "success") flash("Gespeichert. Übernehmen Sie die Konfiguration, damit sie wirksam wird.", "success")
return redirect(url_for("hostsettings.web")) return redirect(url_for("hostsettings.web"))
@@ -311,7 +311,7 @@ def web_apply():
return redirect(url_for("hostsettings.web")) return redirect(url_for("hostsettings.web"))
sysops = core().sysops sysops = core().sysops
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("hostsettings.web")) return redirect(url_for("hostsettings.web"))
result = webserver.apply_config(sysops, webserver.render(_web_config())) result = webserver.apply_config(sysops, webserver.render(_web_config()))
@@ -338,7 +338,7 @@ def web_certificate():
sysops = extension.sysops sysops = extension.sysops
conn = db() conn = db()
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("hostsettings.web")) return redirect(url_for("hostsettings.web"))
config = _web_config() config = _web_config()
@@ -381,7 +381,7 @@ def web_self_signed():
sysops = extension.sysops sysops = extension.sysops
conn = db() conn = db()
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("hostsettings.web")) return redirect(url_for("hostsettings.web"))
config = _web_config() config = _web_config()
@@ -454,8 +454,8 @@ def directory_save():
if values.get("ldap_security") == "none" and not values.get("ldap_allow_insecure"): if values.get("ldap_security") == "none" and not values.get("ldap_allow_insecure"):
flash( flash(
"Eine unverschluesselte Verbindung muss ausdruecklich erlaubt werden -- das Passwort " "Eine unverschlüsselte Verbindung muss ausdrücklich erlaubt werden – das Passwort "
"geht dabei im Klartext ueber das Netz.", "geht dabei im Klartext über das Netz.",
"danger", "danger",
) )
return redirect(url_for("hostsettings.directory_settings")) return redirect(url_for("hostsettings.directory_settings"))
@@ -235,7 +235,7 @@ def audit_export():
mimetype="text/csv; charset=utf-8", mimetype="text/csv; charset=utf-8",
headers={ headers={
"Content-Disposition": ( "Content-Disposition": (
f'attachment; filename="{core().config.app_key}-aenderungsprotokoll.csv"' f'attachment; filename="{core().config.app_key}-änderungsprotokoll.csv"'
) )
}, },
) )
+11 -11
View File
@@ -100,7 +100,7 @@ def register_core_providers() -> None:
list_fn=_users_in_trash, list_fn=_users_in_trash,
restore_fn=lambda conn, ident: rbac.restore_user(conn, int(ident)), restore_fn=lambda conn, ident: rbac.restore_user(conn, int(ident)),
purge_fn=lambda conn, ident: rbac.purge_user(conn, int(ident)), purge_fn=lambda conn, ident: rbac.purge_user(conn, int(ident)),
hint="Verzeichniskonten landen nie hier -- sie legen sich bei der naechsten " hint="Verzeichniskonten landen nie hier – sie legen sich bei der nächsten "
"Anmeldung von selbst wieder an.", "Anmeldung von selbst wieder an.",
) )
) )
@@ -112,7 +112,7 @@ def register_core_providers() -> None:
list_fn=_groups_in_trash, list_fn=_groups_in_trash,
restore_fn=lambda conn, ident: rbac.restore_group(conn, int(ident)), restore_fn=lambda conn, ident: rbac.restore_group(conn, int(ident)),
purge_fn=lambda conn, ident: rbac.purge_group(conn, int(ident)), purge_fn=lambda conn, ident: rbac.purge_group(conn, int(ident)),
hint="Beim endgueltigen Loeschen verschwinden auch Mitgliedschaften und Rechte.", hint="Beim endgültigen Löschen verschwinden auch Mitgliedschaften und Rechte.",
) )
) )
@@ -158,11 +158,11 @@ def overview():
conn, conn,
actor="system", actor="system",
action="trash.auto_purged", action="trash.auto_purged",
detail=f"{removed} abgelaufene Eintraege", detail=f"{removed} abgelaufene Einträge",
severity="notice", severity="notice",
) )
if removed: if removed:
flash(f"{removed} abgelaufene Eintraege wurden endgueltig entfernt.", "info") flash(f"{removed} abgelaufene Einträge wurden endgültig entfernt.", "info")
sections = [] sections = []
for provider in providers().values(): for provider in providers().values():
@@ -197,8 +197,8 @@ def restore(kind: str, ident: str):
extension = core() extension = core()
if extension.license is not None and not extension.license.feature_allowed("trash_restore"): if extension.license is not None and not extension.license.feature_allowed("trash_restore"):
flash( flash(
"Das Wiederherstellen aus dem Papierkorb erfordert eine gueltige, " "Das Wiederherstellen aus dem Papierkorb erfordert eine gültige, "
"aktivierte Lizenz. Ansehen bleibt jederzeit moeglich.", "aktivierte Lizenz. Ansehen bleibt jederzeit möglich.",
"danger", "danger",
) )
return redirect(url_for("trash.overview")) return redirect(url_for("trash.overview"))
@@ -228,7 +228,7 @@ def purge(kind: str, ident: str):
extension = core() extension = core()
if extension.license is not None and not extension.license.feature_allowed("trash_restore"): if extension.license is not None and not extension.license.feature_allowed("trash_restore"):
flash( flash(
"Das endgueltige Loeschen erfordert eine gueltige, aktivierte Lizenz.", "Das endgültige Löschen erfordert eine gültige, aktivierte Lizenz.",
"danger", "danger",
) )
return redirect(url_for("trash.overview")) return redirect(url_for("trash.overview"))
@@ -246,7 +246,7 @@ def purge(kind: str, ident: str):
ip=client_ip(), ip=client_ip(),
severity="warning", severity="warning",
) )
flash("Eintrag endgueltig entfernt.", "success") flash("Eintrag endgültig entfernt.", "success")
except (rbac.ValidationError, rbac.PermissionDenied) as exc: except (rbac.ValidationError, rbac.PermissionDenied) as exc:
flash(str(exc), "danger") flash(str(exc), "danger")
return redirect(url_for("trash.overview")) return redirect(url_for("trash.overview"))
@@ -257,7 +257,7 @@ def purge(kind: str, ident: str):
def purge_all(): def purge_all():
extension = core() extension = core()
if extension.license is not None and not extension.license.feature_allowed("trash_restore"): if extension.license is not None and not extension.license.feature_allowed("trash_restore"):
flash("Das endgueltige Loeschen erfordert eine gueltige, aktivierte Lizenz.", "danger") flash("Das endgültige Löschen erfordert eine gültige, aktivierte Lizenz.", "danger")
return redirect(url_for("trash.overview")) return redirect(url_for("trash.overview"))
kind = request.form.get("kind") or "" kind = request.form.get("kind") or ""
@@ -274,9 +274,9 @@ def purge_all():
actor_id=current_user.id, actor_id=current_user.id,
action="trash.emptied", action="trash.emptied",
target=kind or "alle", target=kind or "alle",
detail=f"{removed} Eintraege", detail=f"{removed} Einträge",
ip=client_ip(), ip=client_ip(),
severity="warning", severity="warning",
) )
flash(f"{removed} Eintraege endgueltig entfernt.", "success") flash(f"{removed} Einträge endgültig entfernt.", "success")
return redirect(url_for("trash.overview")) return redirect(url_for("trash.overview"))
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project] [project]
name = "tesm-licensing" name = "tesm-licensing"
version = "2.5.1" version = "2.7.6"
description = "Ed25519-basiertes Lizenz- und Aktivierungsprotokoll fuer TESM und TESM-Lizenzserver" description = "Ed25519-basiertes Lizenz- und Aktivierungsprotokoll fuer TESM und TESM-Lizenzserver"
requires-python = ">=3.11" requires-python = ">=3.11"
dependencies = ["cryptography>=42"] dependencies = ["cryptography>=42"]
@@ -42,7 +42,7 @@ def _b64d(value: str, *, field: str) -> bytes:
try: try:
return base64.b64decode(value, validate=True) return base64.b64decode(value, validate=True)
except (binascii.Error, ValueError, TypeError) as exc: # pragma: no cover - defensiv except (binascii.Error, ValueError, TypeError) as exc: # pragma: no cover - defensiv
raise ValueError(f"{field} ist kein gueltiges Base64") from exc raise ValueError(f"{field} ist kein gültiges Base64") from exc
@@ -96,7 +96,7 @@ def decode_code(code: str) -> dict[str, Any]:
try: try:
data = json.loads(base64.urlsafe_b64decode(cleaned.encode("ascii")).decode("utf-8")) data = json.loads(base64.urlsafe_b64decode(cleaned.encode("ascii")).decode("utf-8"))
except (binascii.Error, ValueError, UnicodeDecodeError) as exc: except (binascii.Error, ValueError, UnicodeDecodeError) as exc:
raise ValueError("Code ist unlesbar oder beschaedigt") from exc raise ValueError("Code ist unlesbar oder beschädigt") from exc
if not isinstance(data, dict): if not isinstance(data, dict):
raise ValueError("Code enthaelt kein Objekt") raise ValueError("Code enthält kein Objekt")
return data return data
@@ -161,7 +161,7 @@ def split_bundle(bundle: Mapping[str, Any]) -> tuple[dict[str, Any], str]:
eingebettetem ``license_privkey``. eingebettetem ``license_privkey``.
""" """
if not isinstance(bundle, Mapping): if not isinstance(bundle, Mapping):
raise InvalidLicenseFile("Lizenzdatei enthaelt kein Objekt") raise InvalidLicenseFile("Lizenzdatei enthält kein Objekt")
inner = bundle.get("license") inner = bundle.get("license")
if isinstance(inner, Mapping): if isinstance(inner, Mapping):
@@ -194,10 +194,10 @@ def verify_license(
missing = [name for name in _PAYLOAD_FIELDS if name not in document] missing = [name for name in _PAYLOAD_FIELDS if name not in document]
if missing: if missing:
raise InvalidLicenseFile(f"Lizenzdokument unvollstaendig: {', '.join(missing)}") raise InvalidLicenseFile(f"Lizenzdokument unvollständig: {', '.join(missing)}")
if int(document.get("fmt") or 0) != FORMAT_VERSION: if int(document.get("fmt") or 0) != FORMAT_VERSION:
raise InvalidLicenseFile( raise InvalidLicenseFile(
f"Nicht unterstuetzte Formatversion: {document.get('fmt')!r}" f"Nicht unterstützte Formatversion: {document.get('fmt')!r}"
f" (erwartet {FORMAT_VERSION})" f" (erwartet {FORMAT_VERSION})"
) )
if document.get("type") not in LICENSE_TYPES: if document.get("type") not in LICENSE_TYPES:
@@ -207,17 +207,17 @@ def verify_license(
master_pubkey = str(document.get("master_pubkey") or "") master_pubkey = str(document.get("master_pubkey") or "")
if key_id(master_pubkey) != str(document.get("master_key_id") or ""): if key_id(master_pubkey) != str(document.get("master_key_id") or ""):
raise InvalidLicenseFile("master_key_id passt nicht zum eingebetteten Schluessel") raise InvalidLicenseFile("master_key_id passt nicht zum eingebetteten Schlüssel")
payload = {k: v for k, v in document.items() if k != "signature"} payload = {k: v for k, v in document.items() if k != "signature"}
if not verify( if not verify(
payload, str(document.get("signature") or ""), master_pubkey, SIG_CONTEXT_LICENSE payload, str(document.get("signature") or ""), master_pubkey, SIG_CONTEXT_LICENSE
): ):
raise SignatureInvalid("Signatur der Lizenzdatei ist ungueltig") raise SignatureInvalid("Signatur der Lizenzdatei ist ungültig")
if trusted_master_keys and master_pubkey not in set(trusted_master_keys): if trusted_master_keys and master_pubkey not in set(trusted_master_keys):
raise UntrustedIssuer( raise UntrustedIssuer(
"Die Lizenz wurde nicht von einem vertrauenswuerdigen Lizenzserver ausgestellt" "Die Lizenz wurde nicht von einem vertrauenswürdigen Lizenzserver ausgestellt"
) )
@@ -236,7 +236,7 @@ def license_status(
lifetime = bool(document.get("lifetime")) lifetime = bool(document.get("lifetime"))
warnings: list[str] = [] warnings: list[str] = []
if not issuer_pinned: if not issuer_pinned:
warnings.append("Aussteller nicht gepinnt -- nur innere Konsistenz geprueft") warnings.append("Aussteller nicht gepinnt – nur innere Konsistenz geprüft")
issued_at: datetime | None = None issued_at: datetime | None = None
expires_at: datetime | None = None expires_at: datetime | None = None
@@ -248,7 +248,7 @@ def license_status(
try: try:
parsed = parse_iso(str(raw)) parsed = parse_iso(str(raw))
except ValueError: except ValueError:
warnings.append(f"Feld {name} ist kein gueltiges Datum") warnings.append(f"Feld {name} ist kein gültiges Datum")
continue continue
if name == "issued_at": if name == "issued_at":
issued_at = parsed issued_at = parsed
@@ -77,7 +77,7 @@ def build_client_request(
if action not in ACTIONS: if action not in ACTIONS:
raise ProtocolViolation(f"Unbekannte Aktion: {action!r}") raise ProtocolViolation(f"Unbekannte Aktion: {action!r}")
if not client_private_key: if not client_private_key:
raise ProtocolViolation("Kein Client-Schluessel vorhanden -- Lizenz unvollstaendig importiert") raise ProtocolViolation("Kein Client-Schlüssel vorhanden | Lizenz unvollständig importiert")
payload: dict[str, Any] = { payload: dict[str, Any] = {
"fmt": FORMAT_VERSION, "fmt": FORMAT_VERSION,
@@ -130,22 +130,22 @@ def verify_master_response(
if not isinstance(response, Mapping): if not isinstance(response, Mapping):
raise ProtocolViolation("Antwort ist kein Objekt") raise ProtocolViolation("Antwort ist kein Objekt")
if int(response.get("fmt") or 0) != FORMAT_VERSION: if int(response.get("fmt") or 0) != FORMAT_VERSION:
raise ProtocolViolation("Nicht unterstuetzte Protokollversion") raise ProtocolViolation("Nicht unterstützte Protokollversion")
payload = {k: v for k, v in response.items() if k != "signature"} payload = {k: v for k, v in response.items() if k != "signature"}
if not verify( if not verify(
payload, str(response.get("signature") or ""), master_public_key, SIG_CONTEXT_RESPONSE payload, str(response.get("signature") or ""), master_public_key, SIG_CONTEXT_RESPONSE
): ):
raise SignatureInvalid("Signatur der Antwort ist ungueltig") raise SignatureInvalid("Signatur der Antwort ist ungültig")
if str(response.get("license_id") or "") != expected_license_id: if str(response.get("license_id") or "") != expected_license_id:
raise ProtocolViolation("Antwort gehoert zu einer anderen Lizenz") raise ProtocolViolation("Antwort gehört zu einer anderen Lizenz")
if str(response.get("fingerprint") or "") != expected_fingerprint: if str(response.get("fingerprint") or "") != expected_fingerprint:
raise ProtocolViolation("Antwort gehoert zu einer anderen Maschine") raise ProtocolViolation("Antwort gehört zu einer anderen Maschine")
if str(response.get("action") or "") != expected_action: if str(response.get("action") or "") != expected_action:
raise ProtocolViolation("Antwort gehoert zu einer anderen Aktion") raise ProtocolViolation("Antwort gehört zu einer anderen Aktion")
if str(response.get("nonce") or "") != expected_nonce: if str(response.get("nonce") or "") != expected_nonce:
raise ReplayDetected("Antwort gehoert nicht zu dieser Anfrage") raise ReplayDetected("Antwort gehört nicht zu dieser Anfrage")
if str(response.get("status") or "") not in RESPONSE_STATES: if str(response.get("status") or "") not in RESPONSE_STATES:
raise ProtocolViolation("Unbekannter Antwortstatus") raise ProtocolViolation("Unbekannter Antwortstatus")
@@ -156,4 +156,4 @@ def verify_master_response(
if not isinstance(update, Mapping): if not isinstance(update, Mapping):
raise ProtocolViolation("license_update ist kein Objekt") raise ProtocolViolation("license_update ist kein Objekt")
if int(update.get("seq") or 0) < int(current_seq or 0): if int(update.get("seq") or 0) < int(current_seq or 0):
raise ReplayDetected("license_update ist aelter als die installierte Lizenz") raise ReplayDetected("license_update ist älter als die installierte Lizenz")