Version 2.7.2: Umlaute in der Oberflaeche, Einbindungen an der Sitzung
This commit is contained in:
1 parent
11a55b20f8
commit
dcb4f57eeb
83 files changed
+1224
-658
No files matched your search
@@ -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 = [
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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"))
|
||||
|
||||
|
||||
|
||||
@@ -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"))
|
||||
|
||||
@@ -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"],
|
||||
},
|
||||
|
||||
@@ -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))
|
||||
@@ -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":
|
||||
|
||||
@@ -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"))
|
||||
|
||||
@@ -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"))
|
||||
@@ -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"))
|
||||
@@ -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)
|
||||
|
||||
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
+52
-24
@@ -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")
|
||||
|
||||
@@ -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", "*"),
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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."),
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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():
|
||||
|
||||
@@ -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: "
|
||||
"'<benutzer> ALL=(root) /usr/bin/apt-get, /sbin/reboot'. Mit vorangestelltem "
|
||||
"NOPASSWD: entfaellt die Passwortabfrage ganz.",
|
||||
"NOPASSWD: entfällt die Passwortabfrage ganz.",
|
||||
),
|
||||
(
|
||||
"a password is required",
|
||||
"'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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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"),
|
||||
)
|
||||
@@ -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:
|
||||
|
||||
@@ -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))
|
||||
@@ -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,
|
||||
|
||||
@@ -149,10 +149,10 @@ sie getrennt von der Sicherung auf.', 'warning', 'Passphrase') }}
|
||||
</div>
|
||||
<div class="card__body">
|
||||
<ul class="stack stack--tight fs-sm muted">
|
||||
<li>Verzeichniskonten werden nie exportiert -- sie entstehen am Ziel bei der ersten Anmeldung neu.</li>
|
||||
<li>Verzeichniskonten werden nie exportiert – sie entstehen am Ziel bei der ersten Anmeldung neu.</li>
|
||||
<li>Lizenz- und Aktivierungszustand bleiben ausgenommen: sie gehören zur Maschine, nicht zur Sicherung.</li>
|
||||
<li>Sitzungen, Ratenbegrenzung und das Änderungsprotokoll sind nicht Teil der Sicherung.</li>
|
||||
<li>Der Zwischenstand des Imports liegt in der Datenbank -- ein Dienst-Neustall verliert ihn nicht.</li>
|
||||
<li>Der Zwischenstand des Imports liegt in der Datenbank – ein Dienst-Neustall verliert ihn nicht.</li>
|
||||
</ul>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -88,7 +88,7 @@
|
||||
<div class="flex-1">
|
||||
<div class="card__title">{{ icon('power', size=17) }} Neustarts</div>
|
||||
<div class="card__sub">
|
||||
Die letzten {{ events | length }} Neustarts -- über den PoE-Port wie per SSH
|
||||
Die letzten {{ events | length }} Neustarts – über den PoE-Port wie per SSH
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -72,7 +72,7 @@
|
||||
<div class="flex-1">
|
||||
<div class="card__title">{{ icon('network', size=17) }} Subnetze</div>
|
||||
<div class="card__sub">
|
||||
Subnetz und Maske kommen vom laufenden System -- eingegeben wird nur der Adressbereich.
|
||||
Subnetz und Maske kommen vom laufenden System – eingegeben wird nur der Adressbereich.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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 }}">
|
||||
<div class="viewer">
|
||||
<div class="viewer__stage" data-viewer-bildbuehne hidden>
|
||||
<img class="viewer__img" alt="" data-viewer-bild>
|
||||
@@ -293,8 +294,15 @@
|
||||
</label>
|
||||
<label class="viewer__feld">
|
||||
<span class="field__label">Ordner</span>
|
||||
<input class="input" list="freigabe-ordner" data-viewer-ordner
|
||||
placeholder="Wurzelverzeichnis der Freigabe" autocomplete="off">
|
||||
{# Auswahl statt Eingabe mit Vorschlagsliste: auf einem iPad laesst
|
||||
sich ein <datalist> kaum bedienen -- das Freigabefeld daneben
|
||||
zeigt, wie es sein soll. #}
|
||||
<select class="select" data-viewer-ordner>
|
||||
<option value="">Wurzelverzeichnis der Freigabe</option>
|
||||
{% for eintrag in verzeichnisse %}
|
||||
<option value="{{ eintrag }}">{{ eintrag }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</label>
|
||||
<label class="viewer__feld">
|
||||
<span class="field__label">Dateiname (ohne Endung)</span>
|
||||
@@ -362,13 +370,9 @@
|
||||
{{ icon('save', size=15) }} Ausgefüllt speichern
|
||||
</button>
|
||||
{% endif %}
|
||||
<button class="btn btn--primary" type="button" data-dialog-close>Schliessen</button>
|
||||
<button class="btn btn--primary" type="button" data-dialog-close>Schließen</button>
|
||||
</div>
|
||||
{% endcall %}
|
||||
{# Die Ordner der Freigabe als Vorschlag; getippt werden darf trotzdem. #}
|
||||
<datalist id="freigabe-ordner">
|
||||
{% for eintrag in verzeichnisse %}<option value="{{ eintrag }}"></option>{% endfor %}
|
||||
</datalist>
|
||||
{% endif %}
|
||||
|
||||
{% if listing and current_user.has('fileshare.edit') %}
|
||||
|
||||
@@ -37,7 +37,7 @@ wird nie stillschweigend übernommen: das ist der Fall, der bei einem Angriff au
|
||||
<div class="alert__title">Gelesener Schlüssel von {{ probed_host }}:{{ probed_port }}</div>
|
||||
<div class="mono fs-sm break-all">{{ probed_type }} {{ probed_fingerprint }}</div>
|
||||
<p class="fs-sm mt-2">
|
||||
Vergleichen Sie den Fingerprint mit einer unabhängigen Quelle -- Konsolenzugang am
|
||||
Vergleichen Sie den Fingerprint mit einer unabhängigen Quelle | Konsolenzugang am
|
||||
Gerät, Inventardokumentation --, bevor Sie ihn unten freigeben.
|
||||
</p>
|
||||
</div>
|
||||
@@ -145,7 +145,7 @@ wird nie stillschweigend übernommen: das ist der Fall, der bei einem Angriff au
|
||||
<div class="flex-1">
|
||||
<div class="card__title">{{ icon('alert', size=17) }} Verwaiste Freigaben</div>
|
||||
<div class="card__sub">
|
||||
Zu diesen Einträgen gibt es kein Ziel mehr -- gelöscht oder mit neuer IP.
|
||||
Zu diesen Einträgen gibt es kein Ziel mehr – gelöscht oder mit neuer IP.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -125,7 +125,7 @@
|
||||
{% if pending.code %}
|
||||
<div class="field">
|
||||
<span class="field__label">
|
||||
Anfragecode -- an den Anbieter übermitteln
|
||||
Anfragecode – an den Anbieter übermitteln
|
||||
</span>
|
||||
<pre class="log-view" id="offline-request" style="max-height:14dvh">{{ pending.code }}</pre>
|
||||
<div class="row row--end mt-2">
|
||||
@@ -234,7 +234,7 @@
|
||||
</dl>
|
||||
<p class="fs-xs faint mt-3">
|
||||
Der Fingerprint stammt aus der stabilen Maschinenidentität. Eine Umbenennung des Hosts
|
||||
ändert ihn <strong>nicht</strong> mehr -- im Vorgänger führte genau das zu
|
||||
ändert ihn <strong>nicht</strong> mehr – im Vorgänger führte genau das zu
|
||||
„bereits anderweitig gebunden“.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -151,7 +151,7 @@ Druckerfreigabe. Der PoE-Neustart ist davon getrennt: er schaltet den Switch-Por
|
||||
<div class="flex-1">
|
||||
<div class="card__title">{{ icon('clock', size=17) }} Aufträge</div>
|
||||
<div class="card__sub">
|
||||
Zustand liegt in der Datenbank -- ein Dienst-Neustart verliert ihn nicht mehr
|
||||
Zustand liegt in der Datenbank – ein Dienst-Neustart verliert ihn nicht mehr
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -194,7 +194,7 @@ Druckerfreigabe. Der PoE-Neustart ist davon getrennt: er schaltet den Switch-Por
|
||||
{% block scripts %}
|
||||
<script type="module" nonce="{{ csp_nonce }}">
|
||||
// Auswahl beim Laden bewusst zurücksetzen: Browser stellen den Checked-Zustand
|
||||
// sonst aus dem Verlauf wieder her -- und dann wird bei "Neustarten"
|
||||
// sonst aus dem Verlauf wieder her – und dann wird bei "Neustarten"
|
||||
// versehentlich das falsche Gerät erwischt.
|
||||
const boxes = Array.from(document.querySelectorAll(".device-select"));
|
||||
boxes.forEach((box) => (box.checked = false));
|
||||
@@ -211,7 +211,7 @@ Druckerfreigabe. Der PoE-Neustart ist davon getrennt: er schaltet den Switch-Por
|
||||
const state = await window.tesm.api("{{ url_for('maintenance.status') }}");
|
||||
if (state.running) window.setTimeout(() => location.reload(), state.poll_ms);
|
||||
} catch {
|
||||
/* stumm bleiben -- die Seite ist auch ohne Aktualisierung nutzbar */
|
||||
/* stumm bleiben – die Seite ist auch ohne Aktualisierung nutzbar */
|
||||
}
|
||||
}
|
||||
poll();
|
||||
|
||||
@@ -108,7 +108,7 @@
|
||||
<div class="card__title">{{ icon('clock', size=17) }} Sitzungen</div>
|
||||
<div class="card__sub">
|
||||
Wie lange eine Anmeldung gilt. Beides bestimmt auch, wie lange eine
|
||||
eingebundene Dateifreigabe bestehen bleibt -- sie endet mit der Sitzung.
|
||||
eingebundene Dateifreigabe bestehen bleibt – sie endet mit der Sitzung.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -36,7 +36,7 @@ class MonitorDaemon:
|
||||
self.running = True
|
||||
|
||||
def stop(self, signum: int, _frame: FrameType | None) -> None:
|
||||
logger.info("Signal %s empfangen -- beende nach dem laufenden Durchlauf.", signum)
|
||||
logger.info("Signal %s empfangen – beende nach dem laufenden Durchlauf.", signum)
|
||||
self.running = False
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
@@ -68,8 +68,8 @@ class MonitorDaemon:
|
||||
|
||||
def run(self) -> int:
|
||||
live = self.extension.registry["live_log"]
|
||||
live.info("monitor", "Ueberwachungsprozess gestartet")
|
||||
logger.info("Ueberwachung laeuft, Datenbank: %s", self.extension.config.db_path)
|
||||
live.info("monitor", "Überwachungsprozess gestartet")
|
||||
logger.info("Überwachung läuft, Datenbank: %s", self.extension.config.db_path)
|
||||
|
||||
while self.running:
|
||||
interval = self._interval()
|
||||
@@ -84,8 +84,8 @@ class MonitorDaemon:
|
||||
failures_before_restart=self._failures_before_restart(),
|
||||
)
|
||||
logger.info(
|
||||
"Durchlauf: %s geprueft, %s online, %s offline, %s Neustarts (%s erfolgreich), "
|
||||
"%s ohne Lizenz uebersprungen, %sms",
|
||||
"Durchlauf: %s geprüft, %s online, %s offline, %s Neustarts (%s erfolgreich), "
|
||||
"%s ohne Lizenz übersprungen, %sms",
|
||||
report.checked,
|
||||
report.online,
|
||||
report.offline,
|
||||
@@ -96,7 +96,7 @@ class MonitorDaemon:
|
||||
)
|
||||
except Exception: # noqa: BLE001 - der Dienst darf nie sterben
|
||||
logger.exception("Durchlauf fehlgeschlagen")
|
||||
live.error("monitor", "Durchlauf fehlgeschlagen -- siehe app.log")
|
||||
live.error("monitor", "Durchlauf fehlgeschlagen – siehe app.log")
|
||||
|
||||
# Das Aufraeumen abgelaufener Freigabe-Einbindungen laeuft **nicht**
|
||||
# hier, sondern im Webprozess: eingebunden wird in dessen
|
||||
@@ -112,7 +112,7 @@ class MonitorDaemon:
|
||||
time.sleep(min(1.0, remaining))
|
||||
remaining -= 1.0
|
||||
|
||||
live.info("monitor", "Ueberwachungsprozess beendet")
|
||||
live.info("monitor", "Überwachungsprozess beendet")
|
||||
return 0
|
||||
|
||||
|
||||
|
||||
Reference in new issue
Block a user