From dcb4f57eeb973af5975975e61845dfcee1dbecaf Mon Sep 17 00:00:00 2001 From: Tim Eertmoed Date: Fri, 11 Sep 2026 23:24:28 +0200 Subject: [PATCH] Version 2.7.2: Umlaute in der Oberflaeche, Einbindungen an der Sitzung --- apps/tesm/pyproject.toml | 2 +- apps/tesm/src/tesm/__init__.py | 33 +-- apps/tesm/src/tesm/blueprints/backup.py | 22 +- apps/tesm/src/tesm/blueprints/credentials.py | 2 +- apps/tesm/src/tesm/blueprints/dashboard.py | 4 +- apps/tesm/src/tesm/blueprints/devices.py | 30 +-- apps/tesm/src/tesm/blueprints/dhcp.py | 20 +- apps/tesm/src/tesm/blueprints/fileshare.py | 48 ++-- apps/tesm/src/tesm/blueprints/hostkeys.py | 12 +- apps/tesm/src/tesm/blueprints/license.py | 12 +- apps/tesm/src/tesm/blueprints/logs.py | 2 +- apps/tesm/src/tesm/blueprints/maintenance.py | 10 +- apps/tesm/src/tesm/blueprints/settings.py | 10 +- apps/tesm/src/tesm/blueprints/switches.py | 12 +- apps/tesm/src/tesm/blueprints/terminal.py | 20 +- apps/tesm/src/tesm/cli.py | 76 ++++-- apps/tesm/src/tesm/permissions.py | 42 ++-- apps/tesm/src/tesm/services/fileshare.py | 202 ++++++++++++++-- apps/tesm/src/tesm/services/inventory.py | 46 ++-- apps/tesm/src/tesm/services/kea.py | 24 +- .../tesm/src/tesm/services/legacy_transfer.py | 62 ++--- apps/tesm/src/tesm/services/maintenance.py | 64 ++--- apps/tesm/src/tesm/services/monitor.py | 14 +- apps/tesm/src/tesm/services/restart.py | 12 +- apps/tesm/src/tesm/services/shellcli.py | 2 +- apps/tesm/src/tesm/services/ssh.py | 22 +- apps/tesm/src/tesm/services/switchcli.py | 8 +- apps/tesm/src/tesm/services/transfer.py | 8 +- apps/tesm/src/tesm/services/windowsops.py | 40 +-- apps/tesm/src/tesm/settings_spec.py | 36 +-- apps/tesm/src/tesm/templates/tesm/backup.html | 4 +- .../tesm/templates/tesm/device_detail.html | 2 +- apps/tesm/src/tesm/templates/tesm/dhcp.html | 2 +- .../src/tesm/templates/tesm/fileshare.html | 20 +- .../src/tesm/templates/tesm/hostkeys.html | 4 +- .../tesm/src/tesm/templates/tesm/license.html | 4 +- .../src/tesm/templates/tesm/maintenance.html | 6 +- .../tesm/templates/tesm/settings_system.html | 2 +- apps/tesm/src/tesm/worker.py | 14 +- deploy/install.sh | 9 + deploy/systemd/tesm-cleanup.service | 34 +++ deploy/systemd/tesm-cleanup.timer | 18 ++ deploy/systemd/tesm-license-cleanup.service | 34 +++ deploy/systemd/tesm-license-cleanup.timer | 18 ++ packages/tesm-core/pyproject.toml | 2 +- .../tesm-core/src/tesm_core/app_factory.py | 26 +- packages/tesm-core/src/tesm_core/audit.py | 4 +- .../tesm-core/src/tesm_core/auth/directory.py | 18 +- .../tesm-core/src/tesm_core/auth/models.py | 8 +- .../tesm-core/src/tesm_core/auth/service.py | 8 +- packages/tesm-core/src/tesm_core/cli_web.py | 24 +- packages/tesm-core/src/tesm_core/config.py | 4 +- packages/tesm-core/src/tesm_core/db.py | 27 ++- packages/tesm-core/src/tesm_core/extension.py | 2 +- packages/tesm-core/src/tesm_core/keystore.py | 12 +- .../src/tesm_core/licensing_client.py | 56 ++--- .../tesm-core/src/tesm_core/logging_setup.py | 2 +- .../tesm-core/src/tesm_core/migrations.py | 8 +- .../tesm-core/src/tesm_core/rbac/common.py | 24 +- .../tesm-core/src/tesm_core/rbac/model.py | 20 +- .../tesm-core/src/tesm_core/rbac/service.py | 40 +-- .../tesm-core/src/tesm_core/security/csrf.py | 2 +- .../src/tesm_core/security/passwords.py | 4 +- .../static/tesm_core/css/components.css | 80 +++++- .../src/tesm_core/static/tesm_core/js/app.js | 14 +- .../tesm_core/static/tesm_core/js/viewer.js | 227 ++++++++++++++---- packages/tesm-core/src/tesm_core/sysops.py | 12 +- .../tesm-core/src/tesm_core/system/hostnet.py | 14 +- .../src/tesm_core/system/webserver.py | 24 +- .../tesm_core/templates/tesm_core/base.html | 2 +- .../tesm_core/templates/tesm_core/users.html | 2 +- packages/tesm-core/src/tesm_core/transfer.py | 14 +- packages/tesm-core/src/tesm_core/ui.py | 2 +- .../tesm-core/src/tesm_core/views/account.py | 18 +- .../tesm-core/src/tesm_core/views/admin.py | 12 +- .../src/tesm_core/views/diagnostics.py | 10 +- .../src/tesm_core/views/hostsettings.py | 28 +-- .../tesm-core/src/tesm_core/views/logs.py | 2 +- .../tesm-core/src/tesm_core/views/trash.py | 22 +- packages/tesm-licensing/pyproject.toml | 2 +- .../src/tesm_licensing/canonical.py | 6 +- .../src/tesm_licensing/license.py | 16 +- .../src/tesm_licensing/protocol.py | 16 +- 83 files changed, 1224 insertions(+), 658 deletions(-) create mode 100644 deploy/systemd/tesm-cleanup.service create mode 100644 deploy/systemd/tesm-cleanup.timer create mode 100644 deploy/systemd/tesm-license-cleanup.service create mode 100644 deploy/systemd/tesm-license-cleanup.timer diff --git a/apps/tesm/pyproject.toml b/apps/tesm/pyproject.toml index c87d071..55d9273 100644 --- a/apps/tesm/pyproject.toml +++ b/apps/tesm/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "tesm" -version = "2.7.1" +version = "2.7.2" description = "TESM -- Verwaltung und Ueberwachung von PoE-Endgeraeten und Aruba-Switchen" requires-python = ">=3.11" dependencies = [ diff --git a/apps/tesm/src/tesm/__init__.py b/apps/tesm/src/tesm/__init__.py index 089072c..fa649da 100644 --- a/apps/tesm/src/tesm/__init__.py +++ b/apps/tesm/src/tesm/__init__.py @@ -164,7 +164,7 @@ def _mount_shares_after_login(conn: Any, user: Any, secret: str, group_dns: tupl group_dns=group_dns, ) 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: @@ -174,14 +174,19 @@ def _release_shares_on_logout(conn: Any, user: Any) -> None: dem Abmelden bestehen, waere sein Zugriff weiter offen -- bis irgendwann die Zeitgrenze greift. Das ist zu lange. """ + from flask import session + from .services import fileshare 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): - released = fileshare.release_all_for_user(conn, user.id) + released = fileshare.release_all_for_user(conn, user.id, sitzung) if released: 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) if faellig is not None: eintrag = { - "label": "Naechste Pruefung in", + "label": "Nächste Prüfung in", "countdown_to": faellig.isoformat(), "countdown_done": "jetzt", "css_class": "", "dot": "1", "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"): from flask import url_for 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) counts = monitor.dashboard_counts(verbindung) @@ -228,7 +233,7 @@ def _status_pills(user: Any) -> list[dict[str, str]]: "css_class": "status-pill--danger", "dot": "1", "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" ), 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( @@ -294,7 +299,7 @@ def _register_trash_providers(app: Flask) -> None: conn, "credentials", int(ident), unique_column="name" ), 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) if not extension.config.web_process: logger.info( - "Startaufgaben uebersprungen -- dieser Prozess ist nicht als Webprozess markiert." + "Startaufgaben übersprungen – dieser Prozess ist nicht als Webprozess markiert." ) return @@ -323,7 +328,7 @@ def _startup_tasks(app: Flask) -> None: # die Oberflaeche Freigaben, die es nicht gibt. vergessen = fileshare.forget_all_mounts(conn) if orphaned: - logger.warning("%s unterbrochene Wartungsauftraege als unklar markiert", orphaned) + logger.warning("%s unterbrochene Wartungsaufträge als unklar markiert", orphaned) if vergessen: logger.info( "%s Freigabe-Einbindungen eines vorherigen Prozesses vergessen " @@ -361,7 +366,7 @@ def _start_fileshare_sweep(app: Flask) -> None: with app.app_context(): geloest = fileshare.sweep_expired(core(app).database) if geloest: - logger.info("%s abgelaufene Freigabe-Einbindungen geloest", geloest) + logger.info("%s abgelaufene Freigabe-Einbindungen gelöst", geloest) def schleife() -> None: # Zwei Netze: das innere haelt den Faden am Leben, wenn ein einzelner @@ -375,9 +380,9 @@ def _start_fileshare_sweep(app: Flask) -> None: try: einmal() 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 - logger.exception("Der Aufraeumfaden der Dateifreigaben ist beendet") + logger.exception("Der Aufräumfaden der Dateifreigaben ist beendet") raise threading.Thread(target=schleife, name="fileshare-sweep", daemon=True).start() diff --git a/apps/tesm/src/tesm/blueprints/backup.py b/apps/tesm/src/tesm/blueprints/backup.py index e514551..4dcb02c 100644 --- a/apps/tesm/src/tesm/blueprints/backup.py +++ b/apps/tesm/src/tesm/blueprints/backup.py @@ -92,7 +92,7 @@ def export(): allowed = {category.key for category in _allowed_categories()} chosen = [key for key in request.form.getlist("categories") if key in allowed] if not chosen: - flash("Es wurde keine Kategorie ausgewaehlt.", "warning") + flash("Es wurde keine Kategorie ausgewählt.", "warning") return redirect(url_for("backup.overview")) include_secrets = ( @@ -101,7 +101,7 @@ def export(): passphrase = request.form.get("passphrase") or "" repeat = request.form.get("passphrase_repeat") or "" 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")) try: @@ -136,11 +136,11 @@ def import_preview(): conn = db() upload = request.files.get("backup_file") 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")) raw = upload.stream.read(MAX_IMPORT_BYTES + 1) 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")) try: @@ -154,8 +154,8 @@ def import_preview(): session[PREVIEW_SESSION_KEY] = token _audit("backup.import_previewed", f"Erstellt {header.get('created_at')}", severity="notice") flash( - f"Datei geoeffnet. Sie haben {transfer.PREVIEW_TTL_MINUTES} Minuten Zeit, die " - "Uebernahme zu bestaetigen.", + f"Datei geöffnet. Sie haben {transfer.PREVIEW_TTL_MINUTES} Minuten Zeit, die " + "Übernahme zu bestätigen.", "success", ) return redirect(url_for("backup.overview")) @@ -169,13 +169,13 @@ def import_apply(): conn = db() token = session.get(PREVIEW_SESSION_KEY, "") 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")) allowed = {category.key for category in _allowed_categories()} chosen = [key for key in request.form.getlist("categories") if key in allowed] if not chosen: - flash("Es wurde keine Kategorie ausgewaehlt.", "warning") + flash("Es wurde keine Kategorie ausgewählt.", "warning") return redirect(url_for("backup.overview")) try: @@ -206,7 +206,7 @@ def import_apply(): action="backup.imported", detail="; ".join( f"{key}: {counts['created']} neu, {counts['updated']} aktualisiert, " - f"{counts['skipped']} uebersprungen" + f"{counts['skipped']} übersprungen" for key, counts in report.items() ), ip=client_ip(), @@ -217,11 +217,11 @@ def import_apply(): lines = [ f"{transfer.CATEGORY_BY_KEY[key].label}: {counts['created']} neu, " 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() 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")) diff --git a/apps/tesm/src/tesm/blueprints/credentials.py b/apps/tesm/src/tesm/blueprints/credentials.py index 71ec995..5286308 100644 --- a/apps/tesm/src/tesm/blueprints/credentials.py +++ b/apps/tesm/src/tesm/blueprints/credentials.py @@ -85,7 +85,7 @@ def update(credential_id: int): "; ".join(changes), 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: flash(str(exc), "danger") return redirect(url_for("credentials.overview")) diff --git a/apps/tesm/src/tesm/blueprints/dashboard.py b/apps/tesm/src/tesm/blueprints/dashboard.py index 8f76919..ba1e178 100644 --- a/apps/tesm/src/tesm/blueprints/dashboard.py +++ b/apps/tesm/src/tesm/blueprints/dashboard.py @@ -80,7 +80,7 @@ def _tiles(*, authenticated: bool) -> dict[str, Any]: # erwartbar weg. Wer die Uebersicht ansieht, will genau das zuerst # wissen -- sonst haelt er einen laufenden Neustart fuer einen Ausfall. "key": "restarting", - "label": "Neustart laeuft", + "label": "Neustart läuft", "kind": "warning", "tiles": [tile for tile in tiles if tile["state"] == "restarting"], }, @@ -98,7 +98,7 @@ def _tiles(*, authenticated: bool) -> dict[str, Any]: }, { "key": "unknown", - "label": "Noch nicht geprueft", + "label": "Noch nicht geprüft", "kind": "warning", "tiles": [tile for tile in tiles if tile["state"] == "unknown"], }, diff --git a/apps/tesm/src/tesm/blueprints/devices.py b/apps/tesm/src/tesm/blueprints/devices.py index 968d2ec..6750705 100644 --- a/apps/tesm/src/tesm/blueprints/devices.py +++ b/apps/tesm/src/tesm/blueprints/devices.py @@ -100,7 +100,7 @@ def _credential_from_form(conn: Any) -> int | None: ) name = (request.form.get("new_credential_name") or "").strip() 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( conn, core().keystore, @@ -108,7 +108,7 @@ def _credential_from_form(conn: Any) -> int | None: username=(request.form.get("new_credential_username") or "").strip(), secret=request.form.get("new_credential_secret") or "", 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, ) @@ -130,7 +130,7 @@ def create(): f"MAC {request.form.get('mac')}, IP {request.form.get('ip')}", severity="notice", ) - flash("Geraet angelegt.", "success") + flash("Gerät angelegt.", "success") return redirect(url_for("devices.overview", highlight=device_id)) except inventory.ValidationError as exc: flash(str(exc), "danger") @@ -155,7 +155,7 @@ def update(device_id: int): "; ".join(changes), 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: flash(str(exc), "danger") return redirect(url_for("devices.overview")) @@ -176,7 +176,7 @@ def toggle_active(device_id: int): str(device["name"]), 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")) @@ -189,7 +189,7 @@ def delete(device_id: int): abort(404) inventory.delete_device(conn, device_id) _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")) @@ -219,14 +219,14 @@ def restart_device(device_id: int): options = restart.available(dict(device), maintenance_active=maintenance_active) if options: flash( - "Der gewaehlte Weg steht fuer dieses Geraet nicht zur Verfuegung. " - "Moeglich waere: " + ", ".join(entry.label for entry in options), + "Der gewählte Weg steht für dieses Gerät nicht zur Verfügung. " + "Möglich wäre: " + ", ".join(entry.label for entry in options), "warning", ) else: flash( - f"Fuer {device['name']} ist kein Neustart moeglich: es fehlen Switch und Port " - "fuer den PoE-Weg und passende Zugangsdaten fuer SSH oder RPC.", + f"Für {device['name']} ist kein Neustart möglich: es fehlen Switch und Port " + "für den PoE-Weg und passende Zugangsdaten für SSH oder RPC.", "warning", ) 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")) flash( - f"PoE-Neustart fuer {device['name']} durchgefuehrt." + f"PoE-Neustart für {device['name']} durchgeführt." if outcome["ok"] else f"PoE-Neustart fehlgeschlagen: {outcome['message']}", "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, ) 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", ) 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() fingerprint = (request.form.get("fingerprint") or "").strip() 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)) with transaction(): @@ -420,7 +420,7 @@ def trust_host_key(device_id: int): f"{key_type} {fingerprint}", severity="notice", ) - flash("Host-Schluessel freigegeben.", "success") + flash("Host-Schlüssel freigegeben.", "success") return redirect(url_for("devices.detail", device_id=device_id)) @@ -434,5 +434,5 @@ def forget_host_key(device_id: int): with transaction(): ssh.forget_key(conn, str(device["ip"]), int(device["ssh_port"] or 22)) _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)) diff --git a/apps/tesm/src/tesm/blueprints/dhcp.py b/apps/tesm/src/tesm/blueprints/dhcp.py index 4ac754d..3b8afaf 100644 --- a/apps/tesm/src/tesm/blueprints/dhcp.py +++ b/apps/tesm/src/tesm/blueprints/dhcp.py @@ -118,7 +118,7 @@ def save_settings(): with transaction(): changed = extension.settings.set_many(conn, values, actor=current_user.username) _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() @@ -289,7 +289,7 @@ def set_device_option_value(): value or "(geleert)", ) flash( - f"Option {option['name']} fuer {device['name']} " + f"Option {option['name']} für {device['name']} " f"{'gespeichert' if value else 'entfernt'}.", "success", ) @@ -336,7 +336,7 @@ def delete_custom_option(option_id: int): abort(404) if int(option["is_standard"] or 0): 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", ) return redirect(url_for("dhcp.overview")) @@ -359,7 +359,7 @@ def add_reservation(): mac = kea.normalize_mac(request.form.get("mac", "")) ip = (request.form.get("ip") or "").strip() if not mac: - flash("Die MAC-Adresse ist ungueltig.", "danger") + flash("Die MAC-Adresse ist ungültig.", "danger") return redirect(url_for("dhcp.overview")) # Eine Reservierung ausserhalb aller eingerichteten Bereiche liefert Kea nie @@ -367,7 +367,7 @@ def add_reservation(): # schlechteste der drei Moeglichkeiten -- deshalb hier ablehnen. if not Database.value(conn, "SELECT COUNT(*) FROM dhcp_subnets WHERE enabled=1"): 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.", "danger", ) @@ -380,7 +380,7 @@ def add_reservation(): # keine Reservierung mehr moeglich. Stattdessen benennen, was ungeprueft # bleibt. 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.", "warning", ) @@ -390,7 +390,7 @@ def add_reservation(): bereiche = ", ".join(str(netz) for netz in netze) flash( 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", ) return redirect(url_for("dhcp.overview")) @@ -437,14 +437,14 @@ def _write_config() -> dict[str, Any]: sysops = extension.sysops conn = db() 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) config, warnings = kea.render_config(conn, networks=networks, settings=_settings_values()) if not config["Dhcp4"]["subnet4"]: return { "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, } result = sysops.write_kea_config(kea.config_to_text(config)) @@ -495,7 +495,7 @@ def service_action(action: str): extension = core() sysops = extension.sysops 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")) if action == "install": diff --git a/apps/tesm/src/tesm/blueprints/fileshare.py b/apps/tesm/src/tesm/blueprints/fileshare.py index 132a23f..29204af 100644 --- a/apps/tesm/src/tesm/blueprints/fileshare.py +++ b/apps/tesm/src/tesm/blueprints/fileshare.py @@ -73,7 +73,10 @@ def _unterschriften(roh: str) -> list[str]: 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: if mount.token == token: return mount @@ -84,7 +87,9 @@ def _mount_or_404(token: str) -> service.Mount: @permission_required("fileshare.view") def browse(): 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 "") relative = request.args.get("path") or "" listing = None @@ -188,7 +193,7 @@ def upload(token: str): relative = request.form.get("path") or "" files = request.files.getlist("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)) limit = uploads.limit_bytes(db()) @@ -201,7 +206,7 @@ def upload(token: str): name = service.safe_filename(upload_file.filename) data = upload_file.stream.read(limit + 1) 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 (target_dir / name).write_bytes(data) stored += 1 @@ -270,7 +275,7 @@ def save_form(token: str): grenze = uploads.limit_bytes(db()) daten = upload_file.stream.read(grenze + 1) 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-"): # Diese Route nimmt nur, was pdf.js hier ablegt. Ein anderer Inhalt # waere ein Umweg, um beliebige Dateien in die Freigabe zu bekommen. @@ -283,8 +288,8 @@ def save_form(token: str): # niemandem weiter. if service.hat_formularfelder(daten) and not current_user.has("fileshare_pdf.edit"): return { - "error": "Sie duerfen ausgefuellte Formulare nur abgeschlossen speichern " - "(Felder gesperrt). Bitte im Auswahlfeld 'Abgeschlossen' waehlen." + "error": "Sie dürfen ausgefüllte Formulare nur abgeschlossen speichern " + "(Felder gesperrt). Bitte im Auswahlfeld 'Abgeschlossen' wählen." }, 403 # Wohin und unter welchem Namen -- beides darf der Benutzer bestimmen. @@ -297,7 +302,7 @@ def save_form(token: str): # 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": "Fuer eine andere Freigabe braucht es einen Dateinamen."}, 400 + return {"error": "Für eine andere Freigabe braucht es einen Dateinamen."}, 400 # Mit Namen entscheidet das Ordnerfeld -- leer heisst Wurzelverzeichnis, # so wie es im Feld steht. Ohne Namen (Aufruf ohne Oberflaeche) bleibt # es beim Ordner des Originals. @@ -321,7 +326,7 @@ def save_form(token: str): if ziel.exists() and not ersetzen: return { "error": f"In '{ziel_mount.label}' gibt es '{name}' schon. Anderen " - "Namen waehlen oder 'Vorhandene ersetzen' ankreuzen." + "Namen wählen oder 'Vorhandene ersetzen' ankreuzen." }, 409 else: # Ohne Angabe wie bisher: neben das Original, ohne etwas zu ueberschreiben. @@ -351,16 +356,27 @@ def save_form(token: str): if unterschriften: # Zusaetzlich ins Signaturverzeichnis: das Protokoll wird irgendwann # archiviert, die Frage "ist diese Datei noch die unterschriebene?" - # stellt sich aber noch Jahre spaeter. - with transaction() as conn: + # 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( - conn, + 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} @@ -409,7 +425,7 @@ def delete(token: str): except (service.FileshareError, OSError) as exc: flash(str(exc), "danger") 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)) @@ -420,7 +436,7 @@ def release(token: str): with transaction() as conn: service.release_mount(conn, mount.id) _audit("fileshare.released", mount.label) - flash("Einbindung geloest.", "success") + flash("Einbindung gelöst.", "success") return redirect(url_for("fileshare.browse")) @@ -494,10 +510,10 @@ def mapping_update(mapping_id: int): _audit( "fileshare.mapping_updated", 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", ) - flash("Zuordnung geaendert.", "success") + flash("Zuordnung geändert.", "success") except service.FileshareError as exc: flash(str(exc), "danger") return redirect(url_for("fileshare.mappings")) diff --git a/apps/tesm/src/tesm/blueprints/hostkeys.py b/apps/tesm/src/tesm/blueprints/hostkeys.py index a8aa2b8..48055f4 100644 --- a/apps/tesm/src/tesm/blueprints/hostkeys.py +++ b/apps/tesm/src/tesm/blueprints/hostkeys.py @@ -181,7 +181,7 @@ def trust(kind: str, target_id: int): key_type = (request.form.get("key_type") or "").strip() fingerprint = (request.form.get("fingerprint") or "").strip() 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")) with transaction(): @@ -199,7 +199,7 @@ def trust(kind: str, target_id: int): f"{key_type} {fingerprint}", 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")) @@ -218,7 +218,7 @@ def forget(kind: str, target_id: int): target["label"], 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")) @@ -232,18 +232,18 @@ def forget_orphan(): except ValueError: port = 0 if not host or not 1 <= port <= 65535: - flash("Ungueltiges Ziel.", "danger") + flash("Ungültiges Ziel.", "danger") return redirect(url_for("hostkeys.overview")) conn = db() 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 -- # 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")) with transaction(): ssh.forget_key(conn, host, port) _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")) diff --git a/apps/tesm/src/tesm/blueprints/license.py b/apps/tesm/src/tesm/blueprints/license.py index 9e2c5e7..fde0498 100644 --- a/apps/tesm/src/tesm/blueprints/license.py +++ b/apps/tesm/src/tesm/blueprints/license.py @@ -80,11 +80,11 @@ def upload(): upload_file = request.files.get("license_file") 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")) raw = upload_file.stream.read(MAX_UPLOAD_BYTES + 1) if len(raw) > MAX_UPLOAD_BYTES: - flash("Die Datei ist zu gross fuer eine Lizenzdatei.", "danger") + flash("Die Datei ist zu groß für eine Lizenzdatei.", "danger") return redirect(url_for("license.overview")) 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": flash( 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", ) return redirect(url_for("license.overview")) @@ -214,9 +214,9 @@ def server_certificate(): severity="notice", ) 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 - else "Zertifikat entfernt -- es gelten wieder die Systemanker.", + else "Zertifikat entfernt – es gelten wieder die Systemanker.", "success", ) return redirect(url_for("license.overview")) @@ -246,5 +246,5 @@ def remove_trusted_key(key_id: str): if manager is not None: with core().database.session() as conn: manager.reload(conn) - flash("Aussteller-Schluessel entfernt.", "info") + flash("Aussteller-Schlüssel entfernt.", "info") return redirect(url_for("license.overview")) diff --git a/apps/tesm/src/tesm/blueprints/logs.py b/apps/tesm/src/tesm/blueprints/logs.py index 2a2164d..b837969 100644 --- a/apps/tesm/src/tesm/blueprints/logs.py +++ b/apps/tesm/src/tesm/blueprints/logs.py @@ -34,7 +34,7 @@ def dhcp(): else: error = result.message 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) diff --git a/apps/tesm/src/tesm/blueprints/maintenance.py b/apps/tesm/src/tesm/blueprints/maintenance.py index 47a9f92..79afc82 100644 --- a/apps/tesm/src/tesm/blueprints/maintenance.py +++ b/apps/tesm/src/tesm/blueprints/maintenance.py @@ -116,7 +116,7 @@ def install_windows_tool(): """ sysops = core().sysops 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")) result = sysops.apt_install(windowsops.REQUIRED_PACKAGE) @@ -191,7 +191,7 @@ def _start(kind: str) -> None: conn = db() device_ids = [int(value) for value in request.form.getlist("device_ids") if value.isdigit()] if not device_ids: - flash("Es wurde kein Geraet ausgewaehlt.", "warning") + flash("Es wurde kein Gerät ausgewählt.", "warning") return 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. if kind == service.JOB_KIND_UPDATE and str(device["category"]) != "linux": flash( - f"{device['name']}: Paketaktualisierung gibt es nur fuer Linux-Clients. " - "Windows-Geraete lassen sich hier nur neu starten.", + f"{device['name']}: Paketaktualisierung gibt es nur für Linux-Clients. " + "Windows-Geräte lassen sich hier nur neu starten.", "warning", ) continue @@ -273,7 +273,7 @@ def _start(kind: str) -> None: started += 1 if started: - flash(f"{started} Auftrag/Auftraege gestartet.", "success") + flash(f"{started} Auftrag/Aufträge gestartet.", "success") @bp.post("/update") diff --git a/apps/tesm/src/tesm/blueprints/settings.py b/apps/tesm/src/tesm/blueprints/settings.py index 5deec0f..2793e49 100644 --- a/apps/tesm/src/tesm/blueprints/settings.py +++ b/apps/tesm/src/tesm/blueprints/settings.py @@ -160,10 +160,10 @@ def system_save(): if changed: message = "Einstellungen gespeichert." if interval_changed: - message += " Das Pruefintervall greift beim naechsten Durchlauf." + message += " Das Prüfintervall greift beim nächsten Durchlauf." flash(message, "success") else: - flash("Keine Aenderungen.", "info") + flash("Keine Änderungen.", "info") return redirect(url_for("settings.system")) @@ -174,10 +174,10 @@ def set_hostname(): sysops = extension.sysops hostname = (request.form.get("hostname") or "").strip() if not hostname or len(hostname) > 63 or not hostname.replace("-", "").isalnum(): - flash("Der Hostname ist ungueltig.", "danger") + flash("Der Hostname ist ungültig.", "danger") return redirect(url_for("settings.system")) 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")) result = sysops.set_hostname(hostname) if result.ok: @@ -200,7 +200,7 @@ def set_timezone(): flash("Unbekannte Zeitzone.", "danger") return redirect(url_for("settings.system")) if sysops is None or not sysops.available: - flash("Systemaktionen sind in dieser Umgebung nicht verfuegbar.", "warning") + flash("Systemaktionen sind in dieser Umgebung nicht verfügbar.", "warning") return redirect(url_for("settings.system")) result = sysops.set_timezone(timezone) if result.ok: diff --git a/apps/tesm/src/tesm/blueprints/switches.py b/apps/tesm/src/tesm/blueprints/switches.py index 552906c..28a1407 100644 --- a/apps/tesm/src/tesm/blueprints/switches.py +++ b/apps/tesm/src/tesm/blueprints/switches.py @@ -71,7 +71,7 @@ def create(): f"IP {request.form.get('ip')}", 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)) except inventory.ValidationError as exc: flash(str(exc), "danger") @@ -92,7 +92,7 @@ def update(switch_id: int): "; ".join(changes), 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: flash(str(exc), "danger") 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() fingerprint = (request.form.get("fingerprint") or "").strip() 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)) with transaction(): @@ -211,7 +211,7 @@ def trust_host_key(switch_id: int): f"{key_type} {fingerprint}", severity="notice", ) - flash("Host-Schluessel freigegeben.", "success") + flash("Host-Schlüssel freigegeben.", "success") return redirect(url_for("switches.detail", switch_id=switch_id)) @@ -225,7 +225,7 @@ def forget_host_key(switch_id: int): with transaction(): ssh.forget_key(conn, str(switch["ip"]), int(switch["ssh_port"])) _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)) @@ -256,7 +256,7 @@ def run_command(switch_id: int): flash("Dieses Kommando steht nicht auf der Positivliste.", "danger") return redirect(url_for("switches.detail", switch_id=switch_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)) try: diff --git a/apps/tesm/src/tesm/blueprints/terminal.py b/apps/tesm/src/tesm/blueprints/terminal.py index 5ccc1fe..f111d70 100644 --- a/apps/tesm/src/tesm/blueprints/terminal.py +++ b/apps/tesm/src/tesm/blueprints/terminal.py @@ -74,7 +74,7 @@ CLIENT_COMMANDS: tuple[tuple[str, str], ...] = ( ("ip -brief address", "Netzwerkadressen"), ("systemctl --failed", "Fehlgeschlagene Dienste"), ("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"), ) @@ -154,7 +154,7 @@ def _render_console(target: Target): # type: ignore[no-untyped-def] if not _konsole_erlaubt(conn, target.kind): abort(404) 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( "tesm/console.html", @@ -195,7 +195,7 @@ def init_app(app) -> None: # type: ignore[no-untyped-def] try: from flask_sock import Sock 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 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. required = "switches.execute" if kind == KIND_SWITCH else "maintenance.execute" 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 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 if not _konsole_erlaubt(conn, kind): _send(ws, "error", "Die Konsole ist in dieser Installation abgeschaltet.") return 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 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( ws, "error", - f"{exc} Die Konsole akzeptiert keine unbekannten Schluessel -- bitte auf der " - f"{page} ausdruecklich freigeben.", + f"{exc} Die Konsole akzeptiert keine unbekannten Schlüssel – bitte auf der " + f"{page} ausdrücklich freigeben.", ) except ssh.SshError as 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.""" raw = ws.receive(timeout=idle_timeout) if raw is None: - _send(ws, "info", "Verbindung wegen Untaetigkeit getrennt.") + _send(ws, "info", "Verbindung wegen Untätigkeit getrennt.") return None try: message = json.loads(raw) @@ -407,6 +407,6 @@ def _run_client_session( # type: ignore[no-untyped-def] _send( ws, "error", - f"Rueckgabewert {result['status']} nach {result['duration_ms']} ms.", + f"Rückgabewert {result['status']} nach {result['duration_ms']} ms.", ) return commands diff --git a/apps/tesm/src/tesm/cli.py b/apps/tesm/src/tesm/cli.py index dc88ae1..c5c8286 100644 --- a/apps/tesm/src/tesm/cli.py +++ b/apps/tesm/src/tesm/cli.py @@ -39,7 +39,7 @@ def cmd_create_admin(args: argparse.Namespace) -> int: if not password: password = getpass.getpass("Passwort: ") 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 problems = extension.password_policy.check(password, username=username) @@ -75,7 +75,7 @@ def cmd_create_admin(args: argparse.Namespace) -> int: actor_id=user_id, action="user.admin_created", target=username, - detail=f"ueber die Kommandozeile {action}", + detail=f"über die Kommandozeile {action}", severity="warning", ) print(f"Administrator {username!r} {action}.") @@ -133,7 +133,7 @@ def cmd_status(args: argparse.Namespace) -> int: print( "Audit-Kette : " + ("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(): print(f"{key.capitalize():<15}: {value}") @@ -168,6 +168,27 @@ def cmd_housekeeping(_args: argparse.Namespace) -> int: 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: app, extension = _app_and_core() from .services import inventory, monitor @@ -183,7 +204,7 @@ def cmd_check_now(_args: argparse.Namespace) -> int: license_allows_auto_restart=allowed, ) 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.duration_ms} ms" ) @@ -214,7 +235,7 @@ def cmd_convert_legacy(args: argparse.Namespace) -> int: return 2 ziel = Path(args.ausgabe) if args.ausgabe else quelle.with_name(f"{quelle.stem}-neu.json") 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 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.") 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: 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 @@ -265,10 +286,10 @@ def cmd_legacy_export(args: argparse.Namespace) -> int: quelle = Path(args.source) ziel = Path(args.out) 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 - passphrase = args.passphrase or getpass("Passphrase fuer die Exportdatei: ") + passphrase = args.passphrase or getpass("Passphrase für die Exportdatei: ") try: nutzlast, bericht = legacy.read_installation(quelle) inhalt = legacy.seal_legacy(nutzlast, passphrase) @@ -354,7 +375,7 @@ def cmd_legacy_import(args: argparse.Namespace) -> int: action="backup.imported", detail="; ".join( f"{key}: {zahlen['created']} neu, {zahlen['updated']} aktualisiert, " - f"{zahlen['skipped']} uebersprungen" + f"{zahlen['skipped']} übersprungen" for key, zahlen in bericht.items() ) + " (Kommandozeile)", @@ -364,19 +385,20 @@ def cmd_legacy_import(args: argparse.Namespace) -> int: for key, zahlen in sorted(bericht.items()): print( f" {key}: {zahlen['created']} neu, {zahlen['updated']} aktualisiert, " - f"{zahlen['skipped']} uebersprungen" + f"{zahlen['skipped']} übersprungen" ) return 0 + def build_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) - 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("--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.set_defaults(func=cmd_create_admin) @@ -384,18 +406,24 @@ def build_parser() -> argparse.ArgumentParser: status.add_argument("--json", action="store_true") 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) - 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) legacy = sub.add_parser( "convert-legacy", - help="Export des alten TESM in das neue Format uebersetzen", + help="Export des alten TESM in das neue Format übersetzen", description=( - "Liest eine Exportdatei des Vorgaengers und schreibt eine Datei im " - "Format dieser Anwendung. Uebersetzt werden Clients, Switche, " + "Liest eine Exportdatei des Vorgängers und schreibt eine Datei im " + "Format dieser Anwendung. Übersetzt werden Clients, Switche, " "Zugangsdaten und die DHCP-Konfiguration." ), ) @@ -410,7 +438,7 @@ def build_parser() -> argparse.ArgumentParser: legacy.add_argument( "--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) @@ -418,10 +446,10 @@ def build_parser() -> argparse.ArgumentParser: "legacy-export", help="Daten einer alten Installation als Exportdatei sichern", 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. " "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( @@ -430,16 +458,16 @@ def build_parser() -> argparse.ArgumentParser: 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("--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) import_neu = sub.add_parser( "legacy-import", help="Datei im Format dieser Anwendung einlesen", 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 " - "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") diff --git a/apps/tesm/src/tesm/permissions.py b/apps/tesm/src/tesm/permissions.py index c860c01..bf3bcd0 100644 --- a/apps/tesm/src/tesm/permissions.py +++ b/apps/tesm/src/tesm/permissions.py @@ -33,9 +33,9 @@ DEVICES = Resource( actions=(A.VIEW, A.CREATE, A.EDIT, A.DELETE, A.EXECUTE), hint="Überwachte PoE-Endgeräte", action_hints={ - A.EXECUTE: "PoE-Neustart eines Geraets manuell ausloesen. Der automatische " - "Neustart bei Ausfall ist davon unabhaengig und laeuft immer.", - A.DELETE: "Geraet in den Papierkorb legen.", + A.EXECUTE: "PoE-Neustart eines Geräts manuell auslösen. Der automatische " + "Neustart bei Ausfall ist davon unabhängig und läuft immer.", + 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), hint="Aruba-Switche und ihre Erreichbarkeit", action_hints={ - A.EXECUTE: "Browser-Terminal oeffnen und Kommandos auf dem Switch ausfuehren. " - "Verlangt zusaetzlich eine frische Passwortbestaetigung; jede Sitzung wird " - "vollstaendig protokolliert.", + A.EXECUTE: "Browser-Terminal öffnen und Kommandos auf dem Switch ausführen. " + "Verlangt zusätzlich eine frische Passwortbestätigung; jede Sitzung wird " + "vollständig protokolliert.", }, ) @@ -57,9 +57,9 @@ CREDENTIALS = Resource( actions=(A.VIEW, A.CREATE, A.EDIT, A.DELETE, A.SECRETS), hint="Wiederverwendbare SSH-Logins", 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 " - "Passwortbestaetigung und wird protokolliert.", + "Passwortbestätigung und wird protokolliert.", }, ) @@ -69,7 +69,7 @@ MAINTENANCE = Resource( label="Aktualisierung und Neustart", actions=(A.VIEW, A.EXECUTE), 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( @@ -78,7 +78,7 @@ DHCP = Resource( actions=(A.VIEW, A.EDIT, A.EXECUTE), hint="DHCP: Subnetze, Optionen, Reservierungen (lizenzpflichtiges Modul)", action_hints={ - A.EDIT: "Konfiguration aendern und schreiben.", + A.EDIT: "Konfiguration ändern und schreiben.", A.EXECUTE: "Dienst installieren, starten oder stoppen.", }, ) @@ -111,11 +111,11 @@ FILESHARE_PDF = Resource( key="fileshare_pdf", label="PDF-Formulare", 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={ - A.VIEW: "In der Vorschau ausfuellen (ohne Speichern).", - A.CREATE: "Ausgefuellt speichern -- abgeschlossen, Felder gesperrt.", - A.EDIT: "Ausgefuellt speichern -- weiter ausfuellbar, inklusive Bild.", + A.VIEW: "In der Vorschau ausfüllen (ohne Speichern).", + A.CREATE: "Ausgefüllt speichern – abgeschlossen, Felder gesperrt.", + A.EDIT: "Ausgefüllt speichern – weiter ausfüllbar, inklusive Bild.", }, ) @@ -129,7 +129,7 @@ MONITORING_AREA = Area( key="monitoring", label="Überwachung", icon="cpu", - hint="Geraete, Switche und Zugangsdaten. Ohne diesen Zugang bleibt der " + hint="Geräte, Switche und Zugangsdaten. Ohne diesen Zugang bleibt der " "gesamte Bereich verborgen.", resources=(DEVICES, SWITCHES, CREDENTIALS), ) @@ -143,7 +143,7 @@ MAINTENANCE_AREA = Area( label="Wartung", icon="wrench", hint="Paketpflege und Neustarts. Ohne diesen Zugang bleibt der Bereich " - "verborgen -- auch wenn einzelne Rechte darunter gesetzt sind.", + "verborgen – auch wenn einzelne Rechte darunter gesetzt sind.", resources=(MAINTENANCE,), ) @@ -177,7 +177,7 @@ ROLE_PRESETS: tuple[RolePreset, ...] = ( RolePreset( key="viewer", name="Benutzer", - description="Standardgruppe: Geraete und Switche ansehen, laufendes Protokoll lesen.", + description="Standardgruppe: Geräte und Switche ansehen, laufendes Protokoll lesen.", grants=( ("devices", (A.VIEW,)), ("switches", (A.VIEW,)), @@ -188,7 +188,7 @@ ROLE_PRESETS: tuple[RolePreset, ...] = ( RolePreset( key="operations", name="Betrieb", - description="Geraete und Switche pflegen, Neustarts ausloesen, Wartung fahren.", + description="Geräte und Switche pflegen, Neustarts auslösen, Wartung fahren.", grants=( ("devices", "*"), ("switches", (A.VIEW, A.CREATE, A.EDIT, A.EXECUTE)), @@ -202,7 +202,7 @@ ROLE_PRESETS: tuple[RolePreset, ...] = ( RolePreset( key="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=( ("devices", (A.VIEW, A.EXECUTE)), ("switches", (A.VIEW,)), @@ -224,7 +224,7 @@ ROLE_PRESETS: tuple[RolePreset, ...] = ( RolePreset( key="auditor", name="Revision", - description="Nur lesen -- inklusive Aenderungsprotokoll und Diagnose.", + description="Nur lesen – inklusive Änderungsprotokoll und Diagnose.", grants=( ("devices", (A.VIEW,)), ("switches", (A.VIEW,)), @@ -242,7 +242,7 @@ ROLE_PRESETS: tuple[RolePreset, ...] = ( RolePreset( key="administration", name="Administration", - description="Benutzer, Gruppen und Einstellungen -- ohne Admin-Kennzeichen.", + description="Benutzer, Gruppen und Einstellungen – ohne Admin-Kennzeichen.", grants=( ("users", "*"), ("groups", "*"), diff --git a/apps/tesm/src/tesm/services/fileshare.py b/apps/tesm/src/tesm/services/fileshare.py index 168777d..ef2576c 100644 --- a/apps/tesm/src/tesm/services/fileshare.py +++ b/apps/tesm/src/tesm/services/fileshare.py @@ -23,6 +23,7 @@ from __future__ import annotations import hashlib import zlib +import json import logging import mimetypes import os @@ -42,7 +43,7 @@ from tesm_core.extension import core logger = logging.getLogger(__name__) _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." ) @@ -195,11 +196,11 @@ def resolve_unc_host(unc: str, suffixes: Iterable[str]) -> tuple[str, str]: kandidat = f"{host}.{suffix.strip('.')}" if _resolvable(kandidat): 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, ( - f"Der Name {host} ist auf diesem Server nicht aufloesbar. Bitte den " - "vollqualifizierten Namen oder die IP-Adresse eintragen -- oder unter " - "Netzwerk eine Suchdomaene setzen." + f"Der Name {host} ist auf diesem Server nicht auflösbar. Bitte den " + "vollqualifizierten Namen oder die IP-Adresse eintragen – oder unter " + "Netzwerk eine Suchdomäne setzen." ) @@ -237,7 +238,7 @@ def describe_mapping(row: Mapping[str, Any] | None) -> str: gruppe = str(row["ad_group_name"] or row["ad_group_dn"] or "") if 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: @@ -261,7 +262,7 @@ def add_mapping( if not clean_label: raise FileshareError("Der Anzeigename darf nicht leer sein.") 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( "INSERT INTO fileshare_mappings (ad_group_dn, ad_group_name, label, unc, created_at) " "VALUES (?,?,?,?,?) ON CONFLICT(ad_group_dn, unc) DO NOTHING", @@ -293,7 +294,7 @@ def update_mapping( if not clean_label: raise FileshareError("Der Anzeigename darf nicht leer sein.") if not clean_dn: - raise FileshareError("Es ist keine Verzeichnisgruppe ausgewaehlt.") + raise FileshareError("Es ist keine Verzeichnisgruppe ausgewählt.") vorhanden = Database.one( conn, "SELECT id FROM fileshare_mappings WHERE id=?", (mapping_id,) @@ -364,12 +365,32 @@ def mount_root() -> Path: 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( conn, - "SELECT * FROM fileshare_mounts WHERE user_id=? AND released_at IS NULL " - "AND expires_at > ? ORDER BY label COLLATE NOCASE", - (user_id, _iso(_now())), + "SELECT * FROM fileshare_mounts WHERE user_id=? " + bedingung + + "AND released_at IS NULL AND expires_at > ? ORDER BY label COLLATE NOCASE, id DESC", + tuple(werte), ) return [ Mount( @@ -390,6 +411,38 @@ def has_mounts(user_id: int) -> bool: 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( conn: sqlite3.Connection, *, @@ -409,7 +462,7 @@ def mount_for_login( extension = core() sysops = extension.sysops 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()] shares = shares_for_groups(conn, dns) @@ -432,7 +485,7 @@ def mount_for_login( # eingebunden, aber leer. Das betrifft die Anmeldung mit zweitem Faktor, bei # der das Passwort im zweiten Schritt nicht mehr vorliegt. 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 # Sitzung ist die eigentliche Klammer -- mit ihr endet die Einbindung; die @@ -533,14 +586,30 @@ def forget_all_mounts(conn: sqlite3.Connection) -> int: 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 - 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) count += 1 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: """Loest abgelaufene Einbindungen -- laeuft im Hintergrundprozess. @@ -597,11 +666,11 @@ def resolve_path(mount: Mount, relative: str) -> Path: """ candidate = PurePosixPath((relative or "").strip().lstrip("/")) if any(part == ".." for part in candidate.parts): - raise FileshareError("Ungueltiger Pfad.") + raise FileshareError("Ungültiger Pfad.") root = mount.path.resolve() target = (root / str(candidate)).resolve() 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 @@ -669,6 +738,95 @@ def signatur_merken( ) +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? @@ -785,7 +943,7 @@ def preview_mime(name: str) -> str: suffix = Path(name).suffix.lower() if suffix in PREVIEW_MIME: 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: @@ -797,7 +955,7 @@ def safe_filename(name: str) -> str: """Verhindert Pfadanteile und versteckte Dateien in Uploads/Neuanlagen.""" cleaned = os.path.basename((name or "").strip().replace("\\", "/")) if not cleaned or cleaned in {".", ".."} or cleaned.startswith("."): - raise FileshareError("Ungueltiger Dateiname.") + raise FileshareError("Ungültiger Dateiname.") if len(cleaned) > 200: raise FileshareError("Der Dateiname ist zu lang.") return cleaned @@ -869,14 +1027,14 @@ def free_name(parent: Path, stem: str, zusatz: str, suffix: str) -> Path: kandidat = parent / f"{stem}{zusatz}-{zaehler}{suffix}" zaehler += 1 if zaehler > 200: - raise FileshareError("Zu viele Dateien dieses Namens -- bitte aufraeumen.") + raise FileshareError("Zu viele Dateien dieses Namens – bitte aufräumen.") return kandidat def delete_entry(mount: Mount, relative: str) -> str: path = resolve_path(mount, relative) 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(): shutil.rmtree(path) return "Verzeichnis" diff --git a/apps/tesm/src/tesm/services/inventory.py b/apps/tesm/src/tesm/services/inventory.py index f88bdc1..38d9ae3 100644 --- a/apps/tesm/src/tesm/services/inventory.py +++ b/apps/tesm/src/tesm/services/inventory.py @@ -54,13 +54,13 @@ def is_ssh_managed(category: str | None) -> bool: #: war "Windows-Client" vorher -- waehlbar, aber ohne jede Wirkung. CATEGORY_EFFECTS: dict[str, str] = { "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": ( - "Neustart ueber RPC (wie 'shutdown /m'). Kein SSH, kein Host-Schluessel. Das " - "Geraet braucht offene Datei- und Druckerfreigabe (Port 445); das Konto braucht " + "Neustart über RPC (wie 'shutdown /m'). Kein SSH, kein Host-Schlüssel. Das " + "Gerät braucht offene Datei- und Druckerfreigabe (Port 445); das Konto braucht " "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], ...] = ( @@ -95,7 +95,7 @@ def normalize_mac(value: str) -> str: cleaned = re.sub(r"[^0-9a-fA-F]", "", value or "") if len(cleaned) != 12: 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)." ) 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: ipaddress.ip_address(text) 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 @@ -116,7 +116,7 @@ def validate_port_name(value: str) -> str: return "" if not PORT_RE.match(text): raise ValidationError( - "Die Portbezeichnung enthaelt unerlaubte Zeichen (erlaubt: Buchstaben, " + "Die Portbezeichnung enthält unerlaubte Zeichen (erlaubt: Buchstaben, " "Ziffern, / . : -)." ) return text @@ -125,7 +125,7 @@ def validate_port_name(value: str) -> str: def validate_hostname(value: str) -> str: text = (value or "").strip() 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 @@ -208,7 +208,7 @@ def create_credential( if existing["deleted_at"]: raise ValidationError( 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.") @@ -258,11 +258,11 @@ def update_credential( if clean_name != str(row["name"]): changes.append(f"Name: {row['name']} -> {clean_name}") if (username or "").strip() != str(row["username"] or ""): - changes.append("Benutzer geaendert") + changes.append("Benutzer geändert") if category != str(row["category"]): changes.append(f"Kategorie: {row['category']} -> {category}") if (description or "").strip() != str(row["description"] or ""): - changes.append("Beschreibung geaendert") + changes.append("Beschreibung geändert") conn.execute( "UPDATE credentials SET name=?, username=?, category=?, description=?, updated_at=?, " @@ -274,7 +274,7 @@ def update_credential( "UPDATE credentials SET secret_encrypted=? WHERE id=?", (keystore.encrypt(secret, aad=aad_for_credential(credential_id)), credential_id), ) - changes.append("Passwort geaendert") + changes.append("Passwort geändert") return changes @@ -296,8 +296,8 @@ def credential_secret( secret = keystore.decrypt(encrypted, aad=aad_for_credential(int(credential_id))) except DecryptionError as exc: raise ValidationError( - "Das gespeicherte Passwort konnte nicht entschluesselt werden. Wurde der " - "Datenschluessel ausgetauscht? Bitte die Zugangsdaten neu eingeben." + "Das gespeicherte Passwort konnte nicht entschlüsselt werden. Wurde der " + "Datenschlüssel ausgetauscht? Bitte die Zugangsdaten neu eingeben." ) from exc 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): raise ValidationError( 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)) @@ -509,7 +509,7 @@ def delete_switch(conn: sqlite3.Connection, switch_id: int) -> None: ) if int(count or 0): 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)) @@ -590,13 +590,13 @@ def create_device( ) if existing is not None: if existing["deleted_at"]: - raise ValidationError(f"Ein Geraet 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} liegt im Papierkorb.") + raise ValidationError(f"Ein Gerät mit der MAC {clean_mac} existiert bereits.") if switch_id and not clean_port: raise ValidationError( - "Zu einem zugeordneten Switch gehoert eine Portbezeichnung -- sonst ist kein " - "PoE-Neustart moeglich." + "Zu einem zugeordneten Switch gehört eine Portbezeichnung – sonst ist kein " + "PoE-Neustart möglich." ) cursor = conn.execute( @@ -651,7 +651,7 @@ def update_device( conn, "SELECT * FROM devices WHERE id=? AND deleted_at IS NULL", (device_id,) ) if row is None: - raise ValidationError("Dieses Geraet existiert nicht (mehr).") + raise ValidationError("Dieses Gerät existiert nicht (mehr).") clean_name = (name or "").strip() if not clean_name: raise ValidationError("Der Name darf nicht leer sein.") @@ -664,9 +664,9 @@ def update_device( (clean_mac, device_id), ) 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: - raise ValidationError("Zu einem zugeordneten Switch gehoert eine Portbezeichnung.") + raise ValidationError("Zu einem zugeordneten Switch gehört eine Portbezeichnung.") changes: list[str] = [] for label, before, after in ( diff --git a/apps/tesm/src/tesm/services/kea.py b/apps/tesm/src/tesm/services/kea.py index 760d284..bcd0017 100644 --- a/apps/tesm/src/tesm/services/kea.py +++ b/apps/tesm/src/tesm/services/kea.py @@ -46,7 +46,7 @@ STANDARD_OPTIONS: tuple[tuple[int, str, str, str], ...] = ( (9, "lpr-servers", "ipv4-address", "LPR-Druckserver"), (10, "impress-servers", "ipv4-address", "Impress-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"), (16, "swap-server", "ipv4-address", "Swap-Server"), (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"), (20, "non-local-source-routing", "boolean", "Non-Local Source Routing"), (21, "policy-filter", "string", "Policy Filter (Ziel,Maske,...)"), - (22, "max-dgram-reassembly", "uint16", "Maximale Datagramm-Reassemblierungsgroesse"), - (23, "default-ip-ttl", "uint8", "Standard-TTL fuer IP"), + (22, "max-dgram-reassembly", "uint16", "Maximale Datagramm-Reassemblierungsgröße"), + (23, "default-ip-ttl", "uint8", "Standard-TTL für IP"), (24, "path-mtu-aging-timeout", "uint32", "Path-MTU-Aging-Timeout (Sek.)"), (25, "path-mtu-plateau-table", "uint16", "Path-MTU-Plateau-Tabelle"), (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"), (35, "arp-cache-timeout", "uint32", "ARP-Cache-Timeout (Sek.)"), (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.)"), (40, "nis-domain", "string", "NIS-Domain"), (41, "nis-servers", "ipv4-address", "NIS-Server"), @@ -190,12 +190,12 @@ def validate_subnet( ) -> str: """Rueckgabe: leerer String bei Erfolg, sonst die Fehlermeldung.""" if not interface: - return "Es ist kein Interface ausgewaehlt." + return "Es ist kein Interface ausgewählt." try: start = ipaddress.ip_address(range_start) end = ipaddress.ip_address(range_end) 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): return "Die Endadresse liegt vor der Startadresse." 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. warnings.append( 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 interfaces.add(interface) @@ -444,7 +444,7 @@ def render_config( ) 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) 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 #: ueberhaupt nicht auf. RESERVATION_STATES: dict[str, tuple[str, str]] = { - "aktiv": ("online", "Das Geraet benutzt diese Adresse gerade."), - "abgelaufen": ("", "Lease auf dieser Adresse ist abgelaufen -- Geraet derzeit nicht da."), + "aktiv": ("online", "Das Gerät benutzt diese Adresse gerade."), + "abgelaufen": ("", "Lease auf dieser Adresse ist abgelaufen | Gerät derzeit nicht da."), "andere_adresse": ( "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.", ), - "unbenutzt": ("", "Fuer dieses Geraet liegt keine Lease vor."), + "unbenutzt": ("", "Für dieses Gerät liegt keine Lease vor."), } diff --git a/apps/tesm/src/tesm/services/legacy_transfer.py b/apps/tesm/src/tesm/services/legacy_transfer.py index f9a579c..7b02f7f 100644 --- a/apps/tesm/src/tesm/services/legacy_transfer.py +++ b/apps/tesm/src/tesm/services/legacy_transfer.py @@ -42,19 +42,19 @@ SUPPORTED = ("credentials", "switches", "devices", "dhcp") KNOWN_UNSUPPORTED: dict[str, str] = { "users": ( "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." ), - "groups": "Gruppen und Rechte: die Rechteschluessel wurden neu geschnitten.", + "groups": "Gruppen und Rechte: die Rechteschlüssel wurden neu geschnitten.", "ldap": ( "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.", "known_hosts": ( - "SSH-Host-Schluessel: werden hier je Geraet mit Fingerprint gefuehrt und " - "ueber die Seite 'Host-Schluessel' freigegeben." + "SSH-Host-Schlüssel: werden hier je Gerät mit Fingerprint geführt und " + "ü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: raise LegacyTransferError("Die Datei ist kein JSON.") from exc 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: 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." ) 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")) except (InvalidToken, ValueError) as exc: raise LegacyTransferError( - "Entschluesselung fehlgeschlagen -- falsche Passphrase oder beschaedigte Datei." + "Entschlüsselung fehlgeschlagen – falsche Passphrase oder beschädigte Datei." ) from exc try: payload = json.loads(klartext.decode("utf-8")) 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): - raise LegacyTransferError("Die Nutzlast enthaelt kein Objekt.") + raise LegacyTransferError("Die Nutzlast enthält kein Objekt.") return payload @@ -180,14 +180,14 @@ def _credentials(items: Any, report: Report) -> list[dict[str, Any]]: for entry in _liste(items): name = str(entry.get("name") or "").strip() if not name: - report.note("Zugangsdaten ohne Namen uebersprungen.") + report.note("Zugangsdaten ohne Namen übersprungen.") continue umgesetzt: dict[str, Any] = { "name": name, "username": str(entry.get("username") or ""), "category": _category(entry.get("category"), report, f"Zugangsdaten {name!r}"), # 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 # die -- als Ganzes passphrasenverschluesselte -- Datei. Beim Import @@ -199,8 +199,8 @@ def _credentials(items: Any, report: Report) -> list[dict[str, Any]]: ergebnis.append(umgesetzt) if ohne_passwort: report.note( - f"{ohne_passwort} Zugangsdaten ohne Passwort in der Datei -- " - "sie muessen nachgetragen werden." + f"{ohne_passwort} Zugangsdaten ohne Passwort in der Datei – " + "sie müssen nachgetragen werden." ) report.took("credentials", len(ergebnis)) return ergebnis @@ -211,7 +211,7 @@ def _switches(items: Any, report: Report) -> list[dict[str, Any]]: for entry in _liste(items): hostname = str(entry.get("hostname") or "").strip() if not hostname: - report.note("Switch ohne Hostnamen uebersprungen.") + report.note("Switch ohne Hostnamen übersprungen.") continue ergebnis.append( { @@ -240,7 +240,7 @@ def _devices(items: Any, credentials: list[dict[str, Any]], report: Report) -> l for entry in _liste(items): mac = str(entry.get("mac") or "").strip() if not mac: - report.note("Client ohne MAC-Adresse uebersprungen.") + report.note("Client ohne MAC-Adresse übersprungen.") continue zugang = entry.get("credential_name") 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: report.note( 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)) 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")): mac = str(entry.get("mac") or "").strip() if not mac: - report.note("Manuelle Reservierung ohne MAC-Adresse uebersprungen.") + report.note("Manuelle Reservierung ohne MAC-Adresse übersprungen.") continue reservations.append( { "mac": mac, "ip": str(entry.get("ip") 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"))), } 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 werte = _liste(definition.get("values")) if not werte: @@ -339,7 +339,7 @@ def _dhcp(data: Any, report: Report) -> tuple[dict[str, Any], dict[str, str]]: ) if ohne_wert: 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." ) @@ -405,7 +405,7 @@ def convert(legacy: dict[str, Any]) -> tuple[dict[str, Any], Report]: report.note( "Diese Zugangsdaten sind zugeordnet, fehlen aber in der Datei und bleiben " 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(): @@ -413,12 +413,12 @@ def convert(legacy: dict[str, Any]) -> tuple[dict[str, Any], Report]: report.note(grund) unbekannt = sorted(set(legacy) - set(SUPPORTED) - set(KNOWN_UNSUPPORTED)) if unbekannt: - report.note(f"Unbekannte Abschnitte uebersprungen: {', '.join(unbekannt)}.") + report.note(f"Unbekannte Abschnitte übersprungen: {', '.join(unbekannt)}.") if not payload: raise LegacyTransferError( - "Die Datei enthaelt keine Clients, Switche, Zugangsdaten oder DHCP-Daten. " - "Beim Export im alten TESM diese Kategorien mitwaehlen." + "Die Datei enthält keine Clients, Switche, Zugangsdaten oder DHCP-Daten. " + "Beim Export im alten TESM diese Kategorien mitwählen." ) return payload, report @@ -488,7 +488,7 @@ def _entschluessler(root: Path, report: Report) -> Any: schluesseldatei = root / LEGACY_FERNET_NAME if not schluesseldatei.is_file(): 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." ) return lambda _token: "" @@ -499,7 +499,7 @@ def _entschluessler(root: Path, report: Report) -> Any: fernet = Fernet(schluesseldatei.read_bytes().strip()) except (ValueError, TypeError): 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." ) return lambda _token: "" @@ -515,8 +515,8 @@ def _entschluessler(root: Path, report: Report) -> Any: gescheitert.append(1) if len(gescheitert) == 1: report.note( - "Mindestens ein Passwort liess sich mit dem gefundenen Schluessel " - "nicht entschluesseln -- es bleibt leer." + "Mindestens ein Passwort ließ sich mit dem gefundenen Schlüssel " + "nicht entschlüsseln – es bleibt leer." ) 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} if not payload: raise LegacyTransferError( - f"In {root} stehen keine uebernehmbaren Daten (Clients, Switche, " + f"In {root} stehen keine übernehmbaren Daten (Clients, Switche, " "Zugangsdaten, DHCP)." ) for abschnitt, inhalt in payload.items(): diff --git a/apps/tesm/src/tesm/services/maintenance.py b/apps/tesm/src/tesm/services/maintenance.py index 6e4469a..4a378a9 100644 --- a/apps/tesm/src/tesm/services/maintenance.py +++ b/apps/tesm/src/tesm/services/maintenance.py @@ -110,47 +110,47 @@ def is_noise(line: str) -> bool: HINTS: tuple[tuple[str, str], ...] = ( ( "incorrect password attempt", - "Das Passwort aus den Zugangsdaten wurde von 'sudo' abgewiesen. Fuer die " - "SSH-Anmeldung hat es gereicht -- 'sudo' auf diesem Geraet verlangt also ein " + "Das Passwort aus den Zugangsdaten wurde von 'sudo' abgewiesen. Für die " + "SSH-Anmeldung hat es gereicht – 'sudo' auf diesem Gerät verlangt also ein " "anderes Passwort, oder das Konto steht nicht in der sudoers-Datei.", ), ( "is not in the sudoers file", - "Das Wartungskonto darf auf diesem Geraet kein 'sudo' ausfuehren. Auf dem " - "Zielgeraet eintragen, z. B. in /etc/sudoers.d/tesm-wartung: " + "Das Wartungskonto darf auf diesem Gerät kein 'sudo' ausführen. Auf dem " + "Zielgerät eintragen, z. B. in /etc/sudoers.d/tesm-wartung: " "' 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", - "'sudo' verlangt ein Passwort, und in den Zugangsdaten dieses Geraets ist keines " - "hinterlegt. Tragen Sie dort das Passwort des Wartungskontos ein -- TESM reicht " - "es ueber die bereits verschluesselte SSH-Verbindung an 'sudo' weiter.", + "'sudo' verlangt ein Passwort, und in den Zugangsdaten dieses Geräts ist keines " + "hinterlegt. Tragen Sie dort das Passwort des Wartungskontos ein | TESM reicht " + "es über die bereits verschlüsselte SSH-Verbindung an 'sudo' weiter.", ), ( "no tty present", - "Das Wartungskonto verlangt fuer 'sudo' ein Terminal. In der sudoers-Datei des " - "Zielgeraets 'Defaults requiretty' entfernen oder fuer dieses Konto abschalten.", + "Das Wartungskonto verlangt für 'sudo' ein Terminal. In der sudoers-Datei des " + "Zielgeräts 'Defaults requiretty' entfernen oder für dieses Konto abschalten.", ), ( "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.", ), ( "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.", ), ( "is not recognized as an internal or external command", - "Das Kommando gibt es auf diesem Geraet nicht. Ist bei den Zugangsdaten die " - "richtige Kategorie gewaehlt (Linux-Client bzw. Windows-Client)?", + "Das Kommando gibt es auf diesem Gerät nicht. Ist bei den Zugangsdaten die " + "richtige Kategorie gewählt (Linux-Client bzw. Windows-Client)?", ), ( "Could not get lock", - "Ein anderer Paketvorgang laeuft bereits auf dem Zielgeraet (etwa " - "unattended-upgrades). Spaeter erneut versuchen.", + "Ein anderer Paketvorgang läuft bereits auf dem Zielgerät (etwa " + "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=? " "WHERE state IN ('pending','running')", ( - "Der Dienst wurde waehrend des Laufs neu gestartet. Was auf dem Zielgeraet " - "passiert ist, laesst sich von hier nicht mehr feststellen.", + "Der Dienst wurde während des Laufs neu gestartet. Was auf dem Zielgerät " + "passiert ist, lässt sich von hier nicht mehr feststellen.", _now(), _now(), ), @@ -293,7 +293,7 @@ def _run_remote_command( channel.sendall(stdin_text.encode("utf-8")) channel.shutdown_write() 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 pending = b"" while True: @@ -308,7 +308,7 @@ def _run_remote_command( elif channel.exit_status_ready(): break elif time.monotonic() > deadline: - on_line("[Zeitlimit ueberschritten -- Verbindung wird getrennt]") + on_line("[Zeitlimit überschritten | Verbindung wird getrennt]") channel.close() return 124 else: @@ -341,14 +341,14 @@ def _sudo_mode( except Exception: # noqa: BLE001 - dann eben der Weg mit Passwort status = 1 if status == 0: - on_line("sudo ist ohne Passwort moeglich.") + on_line("sudo ist ohne Passwort möglich.") return SUDO_PASSWORDLESS, "" if not password: on_line( "sudo verlangt ein Passwort, in den Zugangsdaten ist keines hinterlegt." ) 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" @@ -411,7 +411,7 @@ def run_update( ) if status != 0: 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)) if problem: emit("") @@ -465,7 +465,7 @@ def _reboot_windows( Rueckkehr danach ist dasselbe -- und das steht beim Aufrufer. """ 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) for line in result.output.splitlines(): @@ -582,13 +582,13 @@ def run_reboot( sudo, sudo_stdin = _sudo_mode(transport, password, emit) command = REBOOT_COMMAND.format(sudo=sudo) try: - emit(f"Neustart wird ausgeloest ({command})") + emit(f"Neustart wird ausgelöst ({command})") try: _run_remote_command( transport, command, timeout=20, on_line=capture, stdin_text=sudo_stdin ) 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: try: transport.close() @@ -621,7 +621,7 @@ def run_reboot( ) 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) deadline = time.monotonic() + wait_seconds ssh_port = probe_port @@ -632,7 +632,7 @@ def run_reboot( # Zeitueberschreitung, obwohl das Geraet zurueck war. Ausserdem ist # "SSH antwortet wieder" die Aussage, auf die es hier ankommt. 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( database, job_id, @@ -656,7 +656,7 @@ def run_reboot( result="ok", actor=actor, duration_ms=elapsed(), - detail="Neustart ueber SSH, Geraet ist zurueckgekehrt.", + detail="Neustart über SSH, Gerät ist zurückgekehrt.", method=method, ) return @@ -668,7 +668,7 @@ def run_reboot( job_id, state="failed", 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, ) _log_restart( @@ -678,7 +678,7 @@ def run_reboot( actor=actor, duration_ms=elapsed(), 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." ), method=method, @@ -718,7 +718,7 @@ def _log_restart( method=method, ) 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: diff --git a/apps/tesm/src/tesm/services/monitor.py b/apps/tesm/src/tesm/services/monitor.py index 755b09c..e08bb5e 100644 --- a/apps/tesm/src/tesm/services/monitor.py +++ b/apps/tesm/src/tesm/services/monitor.py @@ -115,7 +115,7 @@ def ping(host: str, *, timeout: int = PING_TIMEOUT_SECONDS) -> PingResult: check=False, ) except subprocess.TimeoutExpired: - return PingResult(False, detail="Zeitlimit ueberschritten") + return PingResult(False, detail="Zeitlimit überschritten") except OSError as exc: return PingResult(False, detail=str(exc)) @@ -274,8 +274,8 @@ METHOD_SSH = "ssh" METHOD_RPC = "rpc" METHOD_LABELS = { METHOD_POE: "PoE-Port", - METHOD_SSH: "Neustart ueber SSH", - METHOD_RPC: "Neustart ueber RPC", + METHOD_SSH: "Neustart über SSH", + METHOD_RPC: "Neustart über RPC", } #: Zustand zwischen ausgeloestem Neustart und Rueckkehr. @@ -406,7 +406,7 @@ def restart_device_poe( if not port or not switch_ip: 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(): record_restart( @@ -422,7 +422,7 @@ def restart_device_poe( return {"ok": False, "message": message, "transcript": ""} 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(): record_restart( conn, @@ -486,7 +486,7 @@ def restart_device_poe( live.error("poe", f"{name}: PoE-Neustart fehlgeschlagen -- {outcome.error}") return { "ok": outcome.ok, - "message": outcome.error or "PoE-Neustart durchgefuehrt.", + "message": outcome.error or "PoE-Neustart durchgeführt.", "transcript": outcome.transcript, } @@ -567,7 +567,7 @@ def run_sweep( report.skipped_unlicensed += 1 live.warning( "poe", - f"{name}: automatischer PoE-Neustart uebersprungen -- keine gueltige Lizenz.", + f"{name}: automatischer PoE-Neustart übersprungen – keine gültige Lizenz.", ) continue report.restarts_triggered += 1 diff --git a/apps/tesm/src/tesm/services/restart.py b/apps/tesm/src/tesm/services/restart.py index d1cd805..245dc28 100644 --- a/apps/tesm/src/tesm/services/restart.py +++ b/apps/tesm/src/tesm/services/restart.py @@ -54,24 +54,24 @@ DEFINITIONS: dict[str, Method] = { POWER_ON: Method( key=METHOD_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.", ), METHOD_POE: Method( key=METHOD_POE, 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( key=METHOD_SSH, - label="Neustart ueber SSH", - hint="Faehrt das Betriebssystem sauber herunter. Setzt ein antwortendes Geraet " - "und einen freigegebenen Host-Schluessel voraus.", + label="Neustart über SSH", + hint="Fährt das Betriebssystem sauber herunter. Setzt ein antwortendes Gerät " + "und einen freigegebenen Host-Schlüssel voraus.", licensed=True, ), METHOD_RPC: Method( key=METHOD_RPC, - label="Neustart ueber RPC", + label="Neustart über RPC", hint="Windows: wie 'shutdown /m'. Setzt offene Datei- und Druckerfreigabe voraus.", licensed=True, ), diff --git a/apps/tesm/src/tesm/services/shellcli.py b/apps/tesm/src/tesm/services/shellcli.py index b85d2c8..06fc2a8 100644 --- a/apps/tesm/src/tesm/services/shellcli.py +++ b/apps/tesm/src/tesm/services/shellcli.py @@ -128,7 +128,7 @@ class ShellSession: elif time.monotonic() > deadline: channel.close() raise SshError( - f"Das Kommando hat das Zeitlimit von {timeout}s ueberschritten." + f"Das Kommando hat das Zeitlimit von {timeout}s überschritten." ) else: time.sleep(0.05) diff --git a/apps/tesm/src/tesm/services/ssh.py b/apps/tesm/src/tesm/services/ssh.py index ddaf015..7c2e18c 100644 --- a/apps/tesm/src/tesm/services/ssh.py +++ b/apps/tesm/src/tesm/services/ssh.py @@ -48,8 +48,8 @@ class UnknownHostKey(SshError): def __init__(self, host: str, port: int, key_type: str, fingerprint: str) -> None: super().__init__( - f"Der SSH-Host-Schluessel von {host}:{port} ist unbekannt " - f"({key_type}, {fingerprint}). Bitte pruefen und ausdruecklich freigeben." + f"Der SSH-Host-Schlüssel von {host}:{port} ist unbekannt " + f"({key_type}, {fingerprint}). Bitte prüfen und ausdrücklich freigeben." ) self.host = host self.port = port @@ -62,9 +62,9 @@ class HostKeyChanged(SshError): def __init__(self, host: str, port: int, expected: str, received: str) -> None: 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. " - "Das kann ein Geraetetausch sein -- oder ein Angriff." + "Das kann ein Gerätetausch sein – oder ein Angriff." ) self.host = host self.port = port @@ -187,17 +187,17 @@ def describe_probe( if known is None: return ( "info", - f"Host-Schluessel gelesen: {record.key_type} {record.fingerprint}. " - "Bitte gegen eine unabhaengige Quelle pruefen und dann freigeben.", + f"Host-Schlüssel gelesen: {record.key_type} {record.fingerprint}. " + "Bitte gegen eine unabhängige Quelle prüfen und dann freigeben.", ) if known.fingerprint != record.fingerprint: return ( "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 " - "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: @@ -281,8 +281,8 @@ def open_transport( raise SshError( f"SSH-Handshake mit {host}:{port} fehlgeschlagen ({exc}). " f"Freigegeben sind nur {', '.join(sorted(set(trusted_types)))} -- " - "bietet das Geraet inzwischen einen anderen Schluesseltyp an, muss der " - "Schluessel neu ausgelesen und freigegeben werden." + "bietet das Gerät inzwischen einen anderen Schlüsseltyp an, muss der " + "Schlüssel neu ausgelesen und freigegeben werden." ) from exc raise SshError(f"SSH-Handshake mit {host}:{port} fehlgeschlagen ({exc}).") from exc diff --git a/apps/tesm/src/tesm/services/switchcli.py b/apps/tesm/src/tesm/services/switchcli.py index e95f141..09d4edf 100644 --- a/apps/tesm/src/tesm/services/switchcli.py +++ b/apps/tesm/src/tesm/services/switchcli.py @@ -149,7 +149,7 @@ class SwitchCli: ``tolerate_error`` (z. B. ``no page``, das nicht jede Firmware kennt). """ 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._channel.send(command + "\r\n") output = self._read_until_prompt(timeout=timeout) @@ -207,7 +207,7 @@ class SwitchCli: off_state = self.poe_state(port) if off_state == "enabled": 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." ) result.transcript = self.full_transcript @@ -245,11 +245,11 @@ class SwitchCli: #: Kommandos, die die Oberflaeche ohne freies Eingabefeld anbietet. SAFE_COMMANDS: tuple[tuple[str, str], ...] = ( ("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 vlans", "VLAN-Uebersicht"), ("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 logging", "Switch-Protokoll"), ) diff --git a/apps/tesm/src/tesm/services/transfer.py b/apps/tesm/src/tesm/services/transfer.py index b640f90..e6aaa79 100644 --- a/apps/tesm/src/tesm/services/transfer.py +++ b/apps/tesm/src/tesm/services/transfer.py @@ -50,13 +50,13 @@ def unseal(raw: bytes | str, passphrase: str) -> tuple[dict[str, Any], dict[str, 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( "credentials", "Zugangsdaten", - "SSH-Logins. Die Passwoerter liegen im Export im Klartext -- geschuetzt " - "ausschliesslich durch die Passphrase.", + "SSH-Logins. Die Passwörter liegen im Export im Klartext – geschützt " + "ausschließlich durch die Passphrase.", sensitive=True, ), Category("dhcp", "DHCP-Konfiguration", "Subnetze, Optionen und manuelle Reservierungen"), @@ -123,7 +123,7 @@ def build_export( str(row["secret_encrypted"]), aad=aad_for_credential(int(row["id"])) ) except Exception: # noqa: BLE001 - entry["secret_error"] = "nicht entschluesselbar" + entry["secret_error"] = "nicht entschlüsselbar" entries.append(entry) payload["credentials"] = entries if "dhcp" in wanted: diff --git a/apps/tesm/src/tesm/services/windowsops.py b/apps/tesm/src/tesm/services/windowsops.py index 1185abe..0f0741b 100644 --- a/apps/tesm/src/tesm/services/windowsops.py +++ b/apps/tesm/src/tesm/services/windowsops.py @@ -51,28 +51,28 @@ def available() -> bool: HINTS: tuple[tuple[str, str], ...] = ( ( "NT_STATUS_LOGON_FAILURE", - "Benutzername oder Passwort wurden vom Windows-Geraet abgewiesen. Bei einem " - "Domaenenkonto die Form DOMAENE\\benutzer verwenden -- mit *einem* " + "Benutzername oder Passwort wurden vom Windows-Gerät abgewiesen. Bei einem " + "Domänenkonto die Form DOMAENE\\benutzer verwenden – mit *einem* " "Backslash.", ), ( "NT_STATUS_ACCESS_DENIED", - "Das Konto darf auf diesem Geraet keinen Neustart ausloesen. Es braucht das Recht " - "'Herunterfahren von einem Remotesystem aus' -- Mitglied der lokalen " - "Administratoren genuegt dafuer in der Regel.", + "Das Konto darf auf diesem Gerät keinen Neustart auslösen. Es braucht das Recht " + "'Herunterfahren von einem Remotesystem aus' | Mitglied der lokalen " + "Administratoren genügt dafür in der Regel.", ), ( "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.", ), ( "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", - "Das Geraet ist nicht erreichbar.", + "Das Gerät ist nicht erreichbar.", ), # Diese Meldung liest sich wie ein technisches Detail, hat aber fast immer # 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. ( "pipe winreg", - "Das Konto hat auf diesem Geraet keine Administratorrechte. Ein Neustart ueber " - "RPC braucht sie: die Pipe, ueber die er laeuft, ist fuer Standardkonten nicht " - "sichtbar. Ein Konto der lokalen Administratoren hinterlegen -- pruefen laesst " + "Das Konto hat auf diesem Gerät keine Administratorrechte. Ein Neustart über " + "RPC braucht sie: die Pipe, über die er läuft, ist für Standardkonten nicht " + "sichtbar. Ein Konto der lokalen Administratoren hinterlegen – prüfen lässt " "sich das mit 'net rpc service status RemoteRegistry'.", ), ( "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' " "gestoppt.", ), @@ -143,8 +143,8 @@ def _net_with_credentials( return RemoteResult( False, "", - f"Auf diesem Server fehlt das Paket {REQUIRED_PACKAGE}. Ohne 'net' laesst sich " - "ein Windows-Geraet von hier nicht ansprechen.", + f"Auf diesem Server fehlt das Paket {REQUIRED_PACKAGE}. Ohne 'net' lässt sich " + "ein Windows-Gerät von hier nicht ansprechen.", ) if not username: return RemoteResult(False, "", "Es sind keine Zugangsdaten hinterlegt.") @@ -176,7 +176,7 @@ def _net_with_credentials( check=False, ) 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: return RemoteResult(False, "", str(exc)) return completed.returncode, (completed.stdout + completed.stderr).strip() @@ -190,7 +190,7 @@ def _net_with_credentials( stream.flush() os.fsync(stream.fileno()) 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: os.unlink(path) except OSError: # pragma: no cover - defensiv @@ -236,10 +236,10 @@ def check( return RemoteResult( False, output, - "Anmeldung erfolgreich, aber das Konto hat auf diesem Geraet keine " - "Administratorrechte. Fuer einen Neustart ueber RPC braucht es sie.", + "Anmeldung erfolgreich, aber das Konto hat auf diesem Gerät keine " + "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( @@ -275,5 +275,5 @@ def reboot( return ergebnis code, output = ergebnis 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)) diff --git a/apps/tesm/src/tesm/settings_spec.py b/apps/tesm/src/tesm/settings_spec.py index 4955361..499dc86 100644 --- a/apps/tesm/src/tesm/settings_spec.py +++ b/apps/tesm/src/tesm/settings_spec.py @@ -12,8 +12,8 @@ TESM_SETTINGS: tuple[SettingSpec, ...] = ( "monitor_interval_seconds", 300, "int", - "Pruefintervall (Sekunden)", - hint="Abstand zwischen zwei Erreichbarkeitspruefungen. Lizenzpflichtig.", + "Prüfintervall (Sekunden)", + hint="Abstand zwischen zwei Erreichbarkeitsprüfungen. Lizenzpflichtig.", section="monitoring", minimum=30, maximum=86400, @@ -23,8 +23,8 @@ TESM_SETTINGS: tuple[SettingSpec, ...] = ( 2, "int", "Fehlversuche bis zum automatischen PoE-Neustart", - hint="Erst nach so vielen aufeinanderfolgenden Fehlschlaegen wird geschaltet. " - "Der Vorgaenger schaltete sofort beim ersten Ausfall.", + hint="Erst nach so vielen aufeinanderfolgenden Fehlschlägen wird geschaltet. " + "Der Vorgänger schaltete sofort beim ersten Ausfall.", section="monitoring", minimum=1, maximum=10, @@ -40,8 +40,8 @@ TESM_SETTINGS: tuple[SettingSpec, ...] = ( "dashboard_public", True, "bool", - "Statusuebersicht ohne Anmeldung erreichbar", - hint="Zeigt ausschliesslich Name und Erreichbarkeit aktiver Geraete -- keine " + "Statusübersicht ohne Anmeldung erreichbar", + hint="Zeigt ausschließlich Name und Erreichbarkeit aktiver Geräte – keine " "IP-Adressen, keine Switch-Zuordnung, keine Verwaltungsfunktionen.", section="monitoring", ), @@ -74,7 +74,7 @@ TESM_SETTINGS: tuple[SettingSpec, ...] = ( "fileshare_max_age_hours", 12, "int", - "Einbindungen automatisch loesen nach (Stunden)", + "Einbindungen automatisch lösen nach (Stunden)", section="fileshare", minimum=1, maximum=168, @@ -93,7 +93,7 @@ TESM_SETTINGS: tuple[SettingSpec, ...] = ( "maintenance_reboot_wait", 300, "int", - "Wartezeit auf die Rueckkehr nach einem Neustart (Sekunden)", + "Wartezeit auf die Rückkehr nach einem Neustart (Sekunden)", section="maintenance", minimum=30, maximum=1800, @@ -105,8 +105,8 @@ TESM_SETTINGS: tuple[SettingSpec, ...] = ( "bool", "Unterschriften mit dem ablegenden Konto bezeugen", hint="Unter jede Unterschrift kommt eine Zeile, wer das Dokument in TESM abgelegt " - "hat. Der Name in der Unterschrift ist eine Angabe des Unterschreibenden -- wie " - "auf Papier; das Konto daneben ist belegt. Eine Passwortabfrage gibt es dafuer " + "hat. Der Name in der Unterschrift ist eine Angabe des Unterschreibenden – wie " + "auf Papier; das Konto daneben ist belegt. Eine Passwortabfrage gibt es dafür " "nicht.", section="fileshare", ), @@ -115,10 +115,10 @@ TESM_SETTINGS: tuple[SettingSpec, ...] = ( "terminal_switches_enabled", True, "bool", - "Browser-Konsole fuer Switche zulassen", - hint="Auch wenn aktiv: erfordert das Recht 'Ausfuehren' auf Switche, eine frische " - "Passwortbestaetigung und wird vollstaendig protokolliert. Das Freigeben von " - "Host-Schluesseln bleibt davon unberuehrt -- es wird fuer den PoE-Neustart " + "Browser-Konsole für Switche zulassen", + hint="Auch wenn aktiv: erfordert das Recht 'Ausführen' auf Switche, eine frische " + "Passwortbestätigung und wird vollständig protokolliert. Das Freigeben von " + "Host-Schlüsseln bleibt davon unberührt – es wird für den PoE-Neustart " "gebraucht.", section="monitoring", ), @@ -126,16 +126,16 @@ TESM_SETTINGS: tuple[SettingSpec, ...] = ( "terminal_clients_enabled", True, "bool", - "Browser-Konsole fuer Clients zulassen", - hint="Braucht zusaetzlich das Modul Wartung und das Recht 'Ausfuehren' auf Wartung. " - "Das Freigeben von Host-Schluesseln bleibt davon unberuehrt.", + "Browser-Konsole für Clients zulassen", + hint="Braucht zusätzlich das Modul Wartung und das Recht 'Ausführen' auf Wartung. " + "Das Freigeben von Host-Schlüsseln bleibt davon unberührt.", section="monitoring", ), SettingSpec( "terminal_idle_timeout", 300, "int", - "Terminal bei Untaetigkeit trennen nach (Sekunden)", + "Terminal bei Untätigkeit trennen nach (Sekunden)", section="monitoring", minimum=60, maximum=3600, diff --git a/apps/tesm/src/tesm/templates/tesm/backup.html b/apps/tesm/src/tesm/templates/tesm/backup.html index c6a23a1..ebed4f2 100644 --- a/apps/tesm/src/tesm/templates/tesm/backup.html +++ b/apps/tesm/src/tesm/templates/tesm/backup.html @@ -149,10 +149,10 @@ sie getrennt von der Sicherung auf.', 'warning', 'Passphrase') }}
    -
  • Verzeichniskonten werden nie exportiert -- sie entstehen am Ziel bei der ersten Anmeldung neu.
  • +
  • Verzeichniskonten werden nie exportiert – sie entstehen am Ziel bei der ersten Anmeldung neu.
  • Lizenz- und Aktivierungszustand bleiben ausgenommen: sie gehören zur Maschine, nicht zur Sicherung.
  • Sitzungen, Ratenbegrenzung und das Änderungsprotokoll sind nicht Teil der Sicherung.
  • -
  • Der Zwischenstand des Imports liegt in der Datenbank -- ein Dienst-Neustall verliert ihn nicht.
  • +
  • Der Zwischenstand des Imports liegt in der Datenbank – ein Dienst-Neustall verliert ihn nicht.
diff --git a/apps/tesm/src/tesm/templates/tesm/device_detail.html b/apps/tesm/src/tesm/templates/tesm/device_detail.html index bf41cbf..4025b69 100644 --- a/apps/tesm/src/tesm/templates/tesm/device_detail.html +++ b/apps/tesm/src/tesm/templates/tesm/device_detail.html @@ -88,7 +88,7 @@
{{ icon('power', size=17) }} Neustarts
- Die letzten {{ events | length }} Neustarts -- über den PoE-Port wie per SSH + Die letzten {{ events | length }} Neustarts – über den PoE-Port wie per SSH
diff --git a/apps/tesm/src/tesm/templates/tesm/dhcp.html b/apps/tesm/src/tesm/templates/tesm/dhcp.html index f624765..836ce74 100644 --- a/apps/tesm/src/tesm/templates/tesm/dhcp.html +++ b/apps/tesm/src/tesm/templates/tesm/dhcp.html @@ -72,7 +72,7 @@
{{ icon('network', size=17) }} Subnetze
- Subnetz und Maske kommen vom laufenden System -- eingegeben wird nur der Adressbereich. + Subnetz und Maske kommen vom laufenden System – eingegeben wird nur der Adressbereich.
diff --git a/apps/tesm/src/tesm/templates/tesm/fileshare.html b/apps/tesm/src/tesm/templates/tesm/fileshare.html index 5f78646..d9e3443 100644 --- a/apps/tesm/src/tesm/templates/tesm/fileshare.html +++ b/apps/tesm/src/tesm/templates/tesm/fileshare.html @@ -229,7 +229,8 @@ 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-zeit="{{ serverzeit }}" + data-viewer-ordnerliste="{{ verzeichnisse | tojson }}">
{% endcall %} - {# Die Ordner der Freigabe als Vorschlag; getippt werden darf trotzdem. #} - - {% for eintrag in verzeichnisse %}{% endfor %} - {% endif %} {% if listing and current_user.has('fileshare.edit') %} diff --git a/apps/tesm/src/tesm/templates/tesm/hostkeys.html b/apps/tesm/src/tesm/templates/tesm/hostkeys.html index 174515d..ac39d80 100644 --- a/apps/tesm/src/tesm/templates/tesm/hostkeys.html +++ b/apps/tesm/src/tesm/templates/tesm/hostkeys.html @@ -37,7 +37,7 @@ wird nie stillschweigend übernommen: das ist der Fall, der bei einem Angriff au
Gelesener Schlüssel von {{ probed_host }}:{{ probed_port }}
{{ probed_type }} {{ probed_fingerprint }}

- 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.

@@ -145,7 +145,7 @@ wird nie stillschweigend übernommen: das ist der Fall, der bei einem Angriff au
{{ icon('alert', size=17) }} Verwaiste Freigaben
- 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.
diff --git a/apps/tesm/src/tesm/templates/tesm/license.html b/apps/tesm/src/tesm/templates/tesm/license.html index 49622d5..a91d65f 100644 --- a/apps/tesm/src/tesm/templates/tesm/license.html +++ b/apps/tesm/src/tesm/templates/tesm/license.html @@ -125,7 +125,7 @@ {% if pending.code %}
- Anfragecode -- an den Anbieter übermitteln + Anfragecode – an den Anbieter übermitteln
{{ pending.code }}
@@ -234,7 +234,7 @@

Der Fingerprint stammt aus der stabilen Maschinenidentität. Eine Umbenennung des Hosts - ändert ihn nicht mehr -- im Vorgänger führte genau das zu + ändert ihn nicht mehr – im Vorgänger führte genau das zu „bereits anderweitig gebunden“.

diff --git a/apps/tesm/src/tesm/templates/tesm/maintenance.html b/apps/tesm/src/tesm/templates/tesm/maintenance.html index 56f8c1e..4293379 100644 --- a/apps/tesm/src/tesm/templates/tesm/maintenance.html +++ b/apps/tesm/src/tesm/templates/tesm/maintenance.html @@ -151,7 +151,7 @@ Druckerfreigabe. Der PoE-Neustart ist davon getrennt: er schaltet den Switch-Por
{{ icon('clock', size=17) }} Aufträge
- Zustand liegt in der Datenbank -- ein Dienst-Neustart verliert ihn nicht mehr + Zustand liegt in der Datenbank – ein Dienst-Neustart verliert ihn nicht mehr
@@ -194,7 +194,7 @@ Druckerfreigabe. Der PoE-Neustart ist davon getrennt: er schaltet den Switch-Por {% block scripts %} diff --git a/packages/tesm-core/src/tesm_core/templates/tesm_core/users.html b/packages/tesm-core/src/tesm_core/templates/tesm_core/users.html index 422ddb0..9e9d6cb 100644 --- a/packages/tesm-core/src/tesm_core/templates/tesm_core/users.html +++ b/packages/tesm-core/src/tesm_core/templates/tesm_core/users.html @@ -2,7 +2,7 @@ {% from "tesm_core/macros.html" import field, select_field, checkbox, pill, search_box, modal, empty_state, alert %} {% block page_title %}Benutzer{% endblock %} -{% block page_sub %}{{ users | length }} Konten · Rechte kommen ausschliesslich aus Gruppen{% endblock %} +{% block page_sub %}{{ users | length }} Konten · Rechte kommen ausschließlich aus Gruppen{% endblock %} {% block topbar_extra %} {{ search_box('users-table', 'Benutzer suchen ...', count_id='users-visible') }} diff --git a/packages/tesm-core/src/tesm_core/transfer.py b/packages/tesm-core/src/tesm_core/transfer.py index 68d148c..3fce55f 100644 --- a/packages/tesm-core/src/tesm_core/transfer.py +++ b/packages/tesm-core/src/tesm_core/transfer.py @@ -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: if len(passphrase or "") < MIN_PASSPHRASE_LENGTH: 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." ) salt = secrets.token_bytes(KDF_SALT_BYTES) @@ -116,21 +116,21 @@ def unseal( try: envelope = json.loads(text) 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): - raise TransferError("Die Datei enthaelt kein Objekt.") + raise TransferError("Die Datei enthält kein Objekt.") if envelope.get("app") != app_tag: 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: raise TransferError( - f"Nicht unterstuetztes Sicherungsformat ({envelope.get('format')!r}, " + f"Nicht unterstütztes Sicherungsformat ({envelope.get('format')!r}, " f"erwartet {FORMAT_VERSION})." ) kdf = envelope.get("kdf") or {} if kdf.get("name") != "argon2id": - raise TransferError("Unbekanntes Schluesselableitungsverfahren.") + raise TransferError("Unbekanntes Schlüsselableitungsverfahren.") header = { key: envelope[key] @@ -153,7 +153,7 @@ def unseal( ) except (InvalidTag, KeyError, ValueError) as exc: 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 return json.loads(plain.decode("utf-8")), header diff --git a/packages/tesm-core/src/tesm_core/ui.py b/packages/tesm-core/src/tesm_core/ui.py index 5076181..ccbcd7c 100644 --- a/packages/tesm-core/src/tesm_core/ui.py +++ b/packages/tesm-core/src/tesm_core/ui.py @@ -197,7 +197,7 @@ def yes_no(value: Any) -> str: #: Tippfehler. Uebersetzt wird an genau einer Stelle, damit die beiden #: Anwendungen nicht auseinanderlaufen. ISSUER_SOURCES: dict[str, tuple[str, str]] = { - "tofu": ("Erstkontakt", "beim ersten Einspielen automatisch uebernommen"), + "tofu": ("Erstkontakt", "beim ersten Einspielen automatisch übernommen"), "admin-confirmed": ( "Bestaetigt", "Ausstellerwechsel von einem Administrator freigegeben", diff --git a/packages/tesm-core/src/tesm_core/views/account.py b/packages/tesm-core/src/tesm_core/views/account.py index d1881e2..606aed1 100644 --- a/packages/tesm-core/src/tesm_core/views/account.py +++ b/packages/tesm-core/src/tesm_core/views/account.py @@ -99,7 +99,7 @@ def update_profile(): if current_user.is_directory_account: flash( "Ihre Stammdaten kommen aus dem Verzeichnisdienst und werden bei jeder " - "Anmeldung uebernommen.", + "Anmeldung übernommen.", "info", ) return redirect(url_for("account.overview")) @@ -123,7 +123,7 @@ def update_profile(): def upload_avatar(): upload = request.files.get("avatar") 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")) suffix = Path(upload.filename).suffix.lower() @@ -139,7 +139,7 @@ def upload_avatar(): data = upload.stream.read(MAX_AVATAR_BYTES + 1) 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")) filename = f"{current_user.id}-{secrets.token_hex(8)}{suffix}" @@ -215,7 +215,7 @@ def security(): def change_password(): conn = db() 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")) current = request.form.get("current_password") or "" @@ -226,7 +226,7 @@ def change_password(): flash("Das aktuelle Passwort ist nicht korrekt.", "danger") return redirect(url_for("account.security")) 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")) if passwords.constant_time_equals(current, new): flash("Das neue Passwort muss sich vom alten unterscheiden.", "danger") @@ -244,7 +244,7 @@ def change_password(): flash(str(exc), "danger") 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")) @@ -253,7 +253,7 @@ def change_password(): @reauth_required def totp_start(): 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")) @@ -263,7 +263,7 @@ def totp_start(): def totp_confirm(): secret = session.get("_totp_setup", "") 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")) if not totp.verify_code(secret, request.form.get("code") or ""): flash("Der Code ist nicht korrekt. Bitte erneut versuchen.", "danger") @@ -306,7 +306,7 @@ def totp_confirm(): def totp_disable(): if core().config.security.require_totp_for_admins and current_user.is_admin: flash( - "Fuer Administratoren ist die Zwei-Faktor-Authentisierung in dieser " + "Für Administratoren ist die Zwei-Faktor-Authentisierung in dieser " "Installation verpflichtend.", "danger", ) diff --git a/packages/tesm-core/src/tesm_core/views/admin.py b/packages/tesm-core/src/tesm_core/views/admin.py index b25d175..20e8027 100644 --- a/packages/tesm-core/src/tesm_core/views/admin.py +++ b/packages/tesm-core/src/tesm_core/views/admin.py @@ -120,7 +120,7 @@ def user_update(user_id: int): policy=core().password_policy, must_change=True, ) - changes.append("Passwort zurueckgesetzt") + changes.append("Passwort zurückgesetzt") session_store.revoke_all_for_user(conn, user_id, reason="password-reset") target = rbac.get_user(conn, user_id) or {} if changes: @@ -131,7 +131,7 @@ def user_update(user_id: int): "; ".join(changes), 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: flash(str(exc), "danger") return redirect(url_for("admin.users")) @@ -153,7 +153,7 @@ def user_delete(user_id: int): severity="warning", ) flash( - "Konto endgueltig entfernt (Verzeichniskonto)." + "Konto endgültig entfernt (Verzeichniskonto)." if mode == "endgueltig" else "Konto in den Papierkorb verschoben.", "success", @@ -328,7 +328,7 @@ def group_update(group_id: int): "; ".join(changes), 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: flash(str(exc), "danger") return redirect(url_for("admin.group_detail", group_id=group_id)) @@ -364,7 +364,7 @@ def group_apply_preset(group_id: int): abort(404) preset = next((p for p in core().role_presets if p.key == group.preset_key), 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)) tree = core().permissions with transaction(): @@ -377,7 +377,7 @@ def group_apply_preset(group_id: int): unlock_system=True, ) _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)) diff --git a/packages/tesm-core/src/tesm_core/views/diagnostics.py b/packages/tesm-core/src/tesm_core/views/diagnostics.py index b113592..fd02a21 100644 --- a/packages/tesm-core/src/tesm_core/views/diagnostics.py +++ b/packages/tesm-core/src/tesm_core/views/diagnostics.py @@ -82,7 +82,7 @@ def overview(): "Debug": "ja" if config.debug else "nein", "Cookie Secure": "ja" if config.security.cookie_secure 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), ) @@ -109,7 +109,7 @@ def housekeeping(): severity="notice", ) flash( - f"Aufgeraeumt: {sessions_removed} Sitzungen, {rate_removed} Ratenereignisse, " + f"Aufgeräumt: {sessions_removed} Sitzungen, {rate_removed} Ratenereignisse, " f"{len(archived)} archivierte Protokolltage.", "success", ) @@ -139,12 +139,12 @@ def rotate_keys(): actor_id=current_user.id, action="system.data_key_rotated", target=new_id, - detail=f"{migrated} Werte neu verschluesselt", + detail=f"{migrated} Werte neu verschlüsselt", severity="notice", ) flash( - f"Neuer Datenschluessel {new_id} aktiv, {migrated} gespeicherte Geheimnisse " - "wurden neu verschluesselt.", + f"Neuer Datenschlüssel {new_id} aktiv, {migrated} gespeicherte Geheimnisse " + "wurden neu verschlüsselt.", "success", ) return redirect(url_for("diagnostics.overview")) diff --git a/packages/tesm-core/src/tesm_core/views/hostsettings.py b/packages/tesm-core/src/tesm_core/views/hostsettings.py index 401b20d..33a93ad 100644 --- a/packages/tesm-core/src/tesm_core/views/hostsettings.py +++ b/packages/tesm-core/src/tesm_core/views/hostsettings.py @@ -86,7 +86,7 @@ def network_apply(): extension = core() sysops = extension.sysops 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")) interface = (request.form.get("interface") or "").strip() @@ -127,7 +127,7 @@ def network_apply(): sysops.write_netplan(previous.stdout if previous.ok else "") sysops.apply_netplan() flash( - f"Uebernahme fehlgeschlagen, alter Zustand wiederhergestellt: {applied.message}", + f"Übernahme fehlgeschlagen, alter Zustand wiederhergestellt: {applied.message}", "danger", ) return redirect(url_for("hostsettings.network")) @@ -140,8 +140,8 @@ def network_apply(): _start_revert_timer(extension) _audit("settings.network_applied", interface, severity="warning") flash( - f"Uebernommen. Bitte innerhalb von {hostnet.REVERT_SECONDS} Sekunden bestaetigen -- " - "sonst wird automatisch zurueckgerollt.", + f"Übernommen. Bitte innerhalb von {hostnet.REVERT_SECONDS} Sekunden bestätigen – " + "sonst wird automatisch zurückgerollt.", "warning", ) return redirect(url_for("hostsettings.network")) @@ -151,10 +151,10 @@ def network_apply(): @permission_required("settings_network.edit") def network_confirm(): 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: _audit("settings.network_confirmed", severity="notice") - flash("Netzwerkeinstellungen bestaetigt.", "success") + flash("Netzwerkeinstellungen bestätigt.", "success") return redirect(url_for("hostsettings.network")) @@ -173,7 +173,7 @@ def _start_revert_timer(extension: Any) -> None: conn, actor="system", 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", ) @@ -281,7 +281,7 @@ def web_save(): cert_path = (request.form.get("web_cert_path") or "").strip() key_path = (request.form.get("web_key_path") or "").strip() 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")) values = { @@ -298,7 +298,7 @@ def web_save(): with transaction(): changed = extension.settings.set_many(conn, values, actor=current_user.username) _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")) @@ -311,7 +311,7 @@ def web_apply(): return redirect(url_for("hostsettings.web")) sysops = core().sysops 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")) result = webserver.apply_config(sysops, webserver.render(_web_config())) @@ -338,7 +338,7 @@ def web_certificate(): sysops = extension.sysops conn = db() 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")) config = _web_config() @@ -381,7 +381,7 @@ def web_self_signed(): sysops = extension.sysops conn = db() 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")) config = _web_config() @@ -454,8 +454,8 @@ def directory_save(): if values.get("ldap_security") == "none" and not values.get("ldap_allow_insecure"): flash( - "Eine unverschluesselte Verbindung muss ausdruecklich erlaubt werden -- das Passwort " - "geht dabei im Klartext ueber das Netz.", + "Eine unverschlüsselte Verbindung muss ausdrücklich erlaubt werden – das Passwort " + "geht dabei im Klartext über das Netz.", "danger", ) return redirect(url_for("hostsettings.directory_settings")) diff --git a/packages/tesm-core/src/tesm_core/views/logs.py b/packages/tesm-core/src/tesm_core/views/logs.py index 427f69f..276d983 100644 --- a/packages/tesm-core/src/tesm_core/views/logs.py +++ b/packages/tesm-core/src/tesm_core/views/logs.py @@ -235,7 +235,7 @@ def audit_export(): mimetype="text/csv; charset=utf-8", headers={ "Content-Disposition": ( - f'attachment; filename="{core().config.app_key}-aenderungsprotokoll.csv"' + f'attachment; filename="{core().config.app_key}-änderungsprotokoll.csv"' ) }, ) diff --git a/packages/tesm-core/src/tesm_core/views/trash.py b/packages/tesm-core/src/tesm_core/views/trash.py index 03ae60d..cded0ee 100644 --- a/packages/tesm-core/src/tesm_core/views/trash.py +++ b/packages/tesm-core/src/tesm_core/views/trash.py @@ -100,7 +100,7 @@ def register_core_providers() -> None: list_fn=_users_in_trash, restore_fn=lambda conn, ident: rbac.restore_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.", ) ) @@ -112,7 +112,7 @@ def register_core_providers() -> None: list_fn=_groups_in_trash, restore_fn=lambda conn, ident: rbac.restore_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, actor="system", action="trash.auto_purged", - detail=f"{removed} abgelaufene Eintraege", + detail=f"{removed} abgelaufene Einträge", severity="notice", ) if removed: - flash(f"{removed} abgelaufene Eintraege wurden endgueltig entfernt.", "info") + flash(f"{removed} abgelaufene Einträge wurden endgültig entfernt.", "info") sections = [] for provider in providers().values(): @@ -197,8 +197,8 @@ def restore(kind: str, ident: str): extension = core() if extension.license is not None and not extension.license.feature_allowed("trash_restore"): flash( - "Das Wiederherstellen aus dem Papierkorb erfordert eine gueltige, " - "aktivierte Lizenz. Ansehen bleibt jederzeit moeglich.", + "Das Wiederherstellen aus dem Papierkorb erfordert eine gültige, " + "aktivierte Lizenz. Ansehen bleibt jederzeit möglich.", "danger", ) return redirect(url_for("trash.overview")) @@ -228,7 +228,7 @@ def purge(kind: str, ident: str): extension = core() if extension.license is not None and not extension.license.feature_allowed("trash_restore"): flash( - "Das endgueltige Loeschen erfordert eine gueltige, aktivierte Lizenz.", + "Das endgültige Löschen erfordert eine gültige, aktivierte Lizenz.", "danger", ) return redirect(url_for("trash.overview")) @@ -246,7 +246,7 @@ def purge(kind: str, ident: str): ip=client_ip(), severity="warning", ) - flash("Eintrag endgueltig entfernt.", "success") + flash("Eintrag endgültig entfernt.", "success") except (rbac.ValidationError, rbac.PermissionDenied) as exc: flash(str(exc), "danger") return redirect(url_for("trash.overview")) @@ -257,7 +257,7 @@ def purge(kind: str, ident: str): def purge_all(): extension = core() 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")) kind = request.form.get("kind") or "" @@ -274,9 +274,9 @@ def purge_all(): actor_id=current_user.id, action="trash.emptied", target=kind or "alle", - detail=f"{removed} Eintraege", + detail=f"{removed} Einträge", ip=client_ip(), severity="warning", ) - flash(f"{removed} Eintraege endgueltig entfernt.", "success") + flash(f"{removed} Einträge endgültig entfernt.", "success") return redirect(url_for("trash.overview")) diff --git a/packages/tesm-licensing/pyproject.toml b/packages/tesm-licensing/pyproject.toml index 46e139e..4db17e5 100644 --- a/packages/tesm-licensing/pyproject.toml +++ b/packages/tesm-licensing/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "tesm-licensing" -version = "2.7.1" +version = "2.7.2" description = "Ed25519-basiertes Lizenz- und Aktivierungsprotokoll fuer TESM und TESM-Lizenzserver" requires-python = ">=3.11" dependencies = ["cryptography>=42"] diff --git a/packages/tesm-licensing/src/tesm_licensing/canonical.py b/packages/tesm-licensing/src/tesm_licensing/canonical.py index 11c50c6..bcf0bd7 100644 --- a/packages/tesm-licensing/src/tesm_licensing/canonical.py +++ b/packages/tesm-licensing/src/tesm_licensing/canonical.py @@ -42,7 +42,7 @@ def _b64d(value: str, *, field: str) -> bytes: try: return base64.b64decode(value, validate=True) 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: data = json.loads(base64.urlsafe_b64decode(cleaned.encode("ascii")).decode("utf-8")) 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): - raise ValueError("Code enthaelt kein Objekt") + raise ValueError("Code enthält kein Objekt") return data diff --git a/packages/tesm-licensing/src/tesm_licensing/license.py b/packages/tesm-licensing/src/tesm_licensing/license.py index c4b24ac..7ebd714 100644 --- a/packages/tesm-licensing/src/tesm_licensing/license.py +++ b/packages/tesm-licensing/src/tesm_licensing/license.py @@ -161,7 +161,7 @@ def split_bundle(bundle: Mapping[str, Any]) -> tuple[dict[str, Any], str]: eingebettetem ``license_privkey``. """ if not isinstance(bundle, Mapping): - raise InvalidLicenseFile("Lizenzdatei enthaelt kein Objekt") + raise InvalidLicenseFile("Lizenzdatei enthält kein Objekt") inner = bundle.get("license") if isinstance(inner, Mapping): @@ -194,10 +194,10 @@ def verify_license( missing = [name for name in _PAYLOAD_FIELDS if name not in document] 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: raise InvalidLicenseFile( - f"Nicht unterstuetzte Formatversion: {document.get('fmt')!r}" + f"Nicht unterstützte Formatversion: {document.get('fmt')!r}" f" (erwartet {FORMAT_VERSION})" ) if document.get("type") not in LICENSE_TYPES: @@ -207,17 +207,17 @@ def verify_license( master_pubkey = str(document.get("master_pubkey") 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"} if not verify( 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): 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")) warnings: list[str] = [] 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 expires_at: datetime | None = None @@ -248,7 +248,7 @@ def license_status( try: parsed = parse_iso(str(raw)) except ValueError: - warnings.append(f"Feld {name} ist kein gueltiges Datum") + warnings.append(f"Feld {name} ist kein gültiges Datum") continue if name == "issued_at": issued_at = parsed diff --git a/packages/tesm-licensing/src/tesm_licensing/protocol.py b/packages/tesm-licensing/src/tesm_licensing/protocol.py index ee8d8b7..76f8f33 100644 --- a/packages/tesm-licensing/src/tesm_licensing/protocol.py +++ b/packages/tesm-licensing/src/tesm_licensing/protocol.py @@ -77,7 +77,7 @@ def build_client_request( if action not in ACTIONS: raise ProtocolViolation(f"Unbekannte Aktion: {action!r}") 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] = { "fmt": FORMAT_VERSION, @@ -130,22 +130,22 @@ def verify_master_response( if not isinstance(response, Mapping): raise ProtocolViolation("Antwort ist kein Objekt") 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"} if not verify( 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: - 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: - 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: - 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: - 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: raise ProtocolViolation("Unbekannter Antwortstatus") @@ -156,4 +156,4 @@ def verify_master_response( if not isinstance(update, Mapping): raise ProtocolViolation("license_update ist kein Objekt") 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")