Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5dcb87d6ec | ||
|
|
dc94a8e666 | ||
|
|
c639f4f507 | ||
|
|
ad0195bab0 | ||
|
|
ce22239d78 | ||
|
|
92c8abb79b | ||
|
|
dcb4f57eeb | ||
|
|
11a55b20f8 | ||
|
|
ed17bce40a | ||
|
|
5282cd8f2d | ||
|
|
754cbea6bb | ||
|
|
07ae69e3b7 | ||
|
|
d3c700283a | ||
|
|
61a47debdc | ||
|
|
bec74e6936 | ||
|
|
d2679361bc |
No files matched your search
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "tesm"
|
name = "tesm"
|
||||||
version = "2.3.3"
|
version = "2.7.6"
|
||||||
description = "TESM -- Verwaltung und Ueberwachung von PoE-Endgeraeten und Aruba-Switchen"
|
description = "TESM -- Verwaltung und Ueberwachung von PoE-Endgeraeten und Aruba-Switchen"
|
||||||
requires-python = ">=3.11"
|
requires-python = ">=3.11"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
|||||||
@@ -164,7 +164,7 @@ def _mount_shares_after_login(conn: Any, user: Any, secret: str, group_dns: tupl
|
|||||||
group_dns=group_dns,
|
group_dns=group_dns,
|
||||||
)
|
)
|
||||||
for message in messages:
|
for message in messages:
|
||||||
current_app.logger.info("Dateifreigabe fuer %s -- %s", user.username, message)
|
current_app.logger.info("Dateifreigabe für %s | %s", user.username, message)
|
||||||
|
|
||||||
|
|
||||||
def _release_shares_on_logout(conn: Any, user: Any) -> None:
|
def _release_shares_on_logout(conn: Any, user: Any) -> None:
|
||||||
@@ -174,14 +174,19 @@ def _release_shares_on_logout(conn: Any, user: Any) -> None:
|
|||||||
dem Abmelden bestehen, waere sein Zugriff weiter offen -- bis irgendwann
|
dem Abmelden bestehen, waere sein Zugriff weiter offen -- bis irgendwann
|
||||||
die Zeitgrenze greift. Das ist zu lange.
|
die Zeitgrenze greift. Das ist zu lange.
|
||||||
"""
|
"""
|
||||||
|
from flask import session
|
||||||
|
|
||||||
from .services import fileshare
|
from .services import fileshare
|
||||||
|
|
||||||
extension = core()
|
extension = core()
|
||||||
|
# Nur die Einbindungen dieser Sitzung: wer an zwei Geraeten angemeldet ist,
|
||||||
|
# soll am zweiten weiterarbeiten koennen.
|
||||||
|
sitzung = str(getattr(session, "sid", "") or "")
|
||||||
with extension.database.transaction(conn):
|
with extension.database.transaction(conn):
|
||||||
released = fileshare.release_all_for_user(conn, user.id)
|
released = fileshare.release_all_for_user(conn, user.id, sitzung)
|
||||||
if released:
|
if released:
|
||||||
current_app.logger.info(
|
current_app.logger.info(
|
||||||
"Dateifreigaben fuer %s geloest: %d", user.username, released
|
"Dateifreigaben für %s gelöst: %d", user.username, released
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -205,19 +210,19 @@ def _status_pills(user: Any) -> list[dict[str, str]]:
|
|||||||
faellig = monitor.next_check_at(verbindung, intervall)
|
faellig = monitor.next_check_at(verbindung, intervall)
|
||||||
if faellig is not None:
|
if faellig is not None:
|
||||||
eintrag = {
|
eintrag = {
|
||||||
"label": "Naechste Pruefung in",
|
"label": "Nächste Prüfung in",
|
||||||
"countdown_to": faellig.isoformat(),
|
"countdown_to": faellig.isoformat(),
|
||||||
"countdown_done": "jetzt",
|
"countdown_done": "jetzt",
|
||||||
"css_class": "",
|
"css_class": "",
|
||||||
"dot": "1",
|
"dot": "1",
|
||||||
"dot_class": "",
|
"dot_class": "",
|
||||||
"title": f"Der Ueberwachungsdienst prueft alle {intervall} Sekunden.",
|
"title": f"Der Überwachungsdienst prüft alle {intervall} Sekunden.",
|
||||||
}
|
}
|
||||||
if user.has("settings_system.execute"):
|
if user.has("settings_system.execute"):
|
||||||
from flask import url_for
|
from flask import url_for
|
||||||
|
|
||||||
eintrag["action"] = url_for("settings.run_check_now")
|
eintrag["action"] = url_for("settings.run_check_now")
|
||||||
eintrag["action_title"] = "Alle Clients sofort pruefen"
|
eintrag["action_title"] = "Alle Clients sofort prüfen"
|
||||||
pills.append(eintrag)
|
pills.append(eintrag)
|
||||||
|
|
||||||
counts = monitor.dashboard_counts(verbindung)
|
counts = monitor.dashboard_counts(verbindung)
|
||||||
@@ -228,7 +233,7 @@ def _status_pills(user: Any) -> list[dict[str, str]]:
|
|||||||
"css_class": "status-pill--danger",
|
"css_class": "status-pill--danger",
|
||||||
"dot": "1",
|
"dot": "1",
|
||||||
"dot_class": "status-pill__dot--pulse",
|
"dot_class": "status-pill__dot--pulse",
|
||||||
"title": "Geraete, die beim letzten Durchlauf nicht geantwortet haben.",
|
"title": "Geräte, die beim letzten Durchlauf nicht geantwortet haben.",
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -265,7 +270,7 @@ def _register_trash_providers(app: Flask) -> None:
|
|||||||
conn, "devices", int(ident), unique_column="mac"
|
conn, "devices", int(ident), unique_column="mac"
|
||||||
),
|
),
|
||||||
purge_fn=lambda conn, ident: inventory.purge_row(conn, "devices", int(ident)),
|
purge_fn=lambda conn, ident: inventory.purge_row(conn, "devices", int(ident)),
|
||||||
hint="Die MAC-Adresse bleibt reserviert, solange das Geraet hier liegt.",
|
hint="Die MAC-Adresse bleibt reserviert, solange das Gerät hier liegt.",
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
trash_views.register(
|
trash_views.register(
|
||||||
@@ -294,7 +299,7 @@ def _register_trash_providers(app: Flask) -> None:
|
|||||||
conn, "credentials", int(ident), unique_column="name"
|
conn, "credentials", int(ident), unique_column="name"
|
||||||
),
|
),
|
||||||
purge_fn=lambda conn, ident: inventory.purge_row(conn, "credentials", int(ident)),
|
purge_fn=lambda conn, ident: inventory.purge_row(conn, "credentials", int(ident)),
|
||||||
hint="Endgueltiges Loeschen entfernt auch das gespeicherte Passwort.",
|
hint="Endgültiges Löschen entfernt auch das gespeicherte Passwort.",
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -310,7 +315,7 @@ def _startup_tasks(app: Flask) -> None:
|
|||||||
extension = core(app)
|
extension = core(app)
|
||||||
if not extension.config.web_process:
|
if not extension.config.web_process:
|
||||||
logger.info(
|
logger.info(
|
||||||
"Startaufgaben uebersprungen -- dieser Prozess ist nicht als Webprozess markiert."
|
"Startaufgaben übersprungen – dieser Prozess ist nicht als Webprozess markiert."
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
|
|
||||||
@@ -323,7 +328,7 @@ def _startup_tasks(app: Flask) -> None:
|
|||||||
# die Oberflaeche Freigaben, die es nicht gibt.
|
# die Oberflaeche Freigaben, die es nicht gibt.
|
||||||
vergessen = fileshare.forget_all_mounts(conn)
|
vergessen = fileshare.forget_all_mounts(conn)
|
||||||
if orphaned:
|
if orphaned:
|
||||||
logger.warning("%s unterbrochene Wartungsauftraege als unklar markiert", orphaned)
|
logger.warning("%s unterbrochene Wartungsaufträge als unklar markiert", orphaned)
|
||||||
if vergessen:
|
if vergessen:
|
||||||
logger.info(
|
logger.info(
|
||||||
"%s Freigabe-Einbindungen eines vorherigen Prozesses vergessen "
|
"%s Freigabe-Einbindungen eines vorherigen Prozesses vergessen "
|
||||||
@@ -361,7 +366,7 @@ def _start_fileshare_sweep(app: Flask) -> None:
|
|||||||
with app.app_context():
|
with app.app_context():
|
||||||
geloest = fileshare.sweep_expired(core(app).database)
|
geloest = fileshare.sweep_expired(core(app).database)
|
||||||
if geloest:
|
if geloest:
|
||||||
logger.info("%s abgelaufene Freigabe-Einbindungen geloest", geloest)
|
logger.info("%s abgelaufene Freigabe-Einbindungen gelöst", geloest)
|
||||||
|
|
||||||
def schleife() -> None:
|
def schleife() -> None:
|
||||||
# Zwei Netze: das innere haelt den Faden am Leben, wenn ein einzelner
|
# Zwei Netze: das innere haelt den Faden am Leben, wenn ein einzelner
|
||||||
@@ -375,9 +380,9 @@ def _start_fileshare_sweep(app: Flask) -> None:
|
|||||||
try:
|
try:
|
||||||
einmal()
|
einmal()
|
||||||
except Exception: # noqa: BLE001 - der Webprozess darf nie sterben
|
except Exception: # noqa: BLE001 - der Webprozess darf nie sterben
|
||||||
logger.exception("Aufraeumen der Dateifreigaben fehlgeschlagen")
|
logger.exception("Aufräumen der Dateifreigaben fehlgeschlagen")
|
||||||
except BaseException: # noqa: BLE001 - auch ein Abbruch gehoert protokolliert
|
except BaseException: # noqa: BLE001 - auch ein Abbruch gehoert protokolliert
|
||||||
logger.exception("Der Aufraeumfaden der Dateifreigaben ist beendet")
|
logger.exception("Der Aufräumfaden der Dateifreigaben ist beendet")
|
||||||
raise
|
raise
|
||||||
|
|
||||||
threading.Thread(target=schleife, name="fileshare-sweep", daemon=True).start()
|
threading.Thread(target=schleife, name="fileshare-sweep", daemon=True).start()
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ from tesm_core.licensing_client import LicenseError
|
|||||||
|
|
||||||
from ..services import transfer
|
from ..services import transfer
|
||||||
|
|
||||||
bp = Blueprint("backup", __name__, url_prefix="/sicherung")
|
bp = Blueprint("backup", __name__, url_prefix="/administration/backup")
|
||||||
|
|
||||||
MAX_IMPORT_BYTES = 64 * 1024 * 1024
|
MAX_IMPORT_BYTES = 64 * 1024 * 1024
|
||||||
PREVIEW_SESSION_KEY = "_import_token"
|
PREVIEW_SESSION_KEY = "_import_token"
|
||||||
@@ -78,7 +78,7 @@ def overview():
|
|||||||
|
|
||||||
@bp.post("/export")
|
@bp.post("/export")
|
||||||
@permission_required("backup.export")
|
@permission_required("backup.export")
|
||||||
@reauth_required
|
# Die frische Bestaetigung verlangt das Recht selbst (SENSITIVE_ACTIONS).
|
||||||
def export():
|
def export():
|
||||||
extension = core()
|
extension = core()
|
||||||
conn = db()
|
conn = db()
|
||||||
@@ -92,7 +92,7 @@ def export():
|
|||||||
allowed = {category.key for category in _allowed_categories()}
|
allowed = {category.key for category in _allowed_categories()}
|
||||||
chosen = [key for key in request.form.getlist("categories") if key in allowed]
|
chosen = [key for key in request.form.getlist("categories") if key in allowed]
|
||||||
if not chosen:
|
if not chosen:
|
||||||
flash("Es wurde keine Kategorie ausgewaehlt.", "warning")
|
flash("Es wurde keine Kategorie ausgewählt.", "warning")
|
||||||
return redirect(url_for("backup.overview"))
|
return redirect(url_for("backup.overview"))
|
||||||
|
|
||||||
include_secrets = (
|
include_secrets = (
|
||||||
@@ -101,7 +101,7 @@ def export():
|
|||||||
passphrase = request.form.get("passphrase") or ""
|
passphrase = request.form.get("passphrase") or ""
|
||||||
repeat = request.form.get("passphrase_repeat") or ""
|
repeat = request.form.get("passphrase_repeat") or ""
|
||||||
if passphrase != repeat:
|
if passphrase != repeat:
|
||||||
flash("Die beiden Passphrasen stimmen nicht ueberein.", "danger")
|
flash("Die beiden Passphrasen stimmen nicht überein.", "danger")
|
||||||
return redirect(url_for("backup.overview"))
|
return redirect(url_for("backup.overview"))
|
||||||
|
|
||||||
try:
|
try:
|
||||||
@@ -129,18 +129,18 @@ def export():
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@bp.post("/import/vorschau")
|
@bp.post("/import/preview")
|
||||||
@permission_required("backup.execute")
|
@permission_required("backup.execute")
|
||||||
@reauth_required
|
@reauth_required
|
||||||
def import_preview():
|
def import_preview():
|
||||||
conn = db()
|
conn = db()
|
||||||
upload = request.files.get("backup_file")
|
upload = request.files.get("backup_file")
|
||||||
if upload is None or not upload.filename:
|
if upload is None or not upload.filename:
|
||||||
flash("Keine Datei ausgewaehlt.", "danger")
|
flash("Keine Datei ausgewählt.", "danger")
|
||||||
return redirect(url_for("backup.overview"))
|
return redirect(url_for("backup.overview"))
|
||||||
raw = upload.stream.read(MAX_IMPORT_BYTES + 1)
|
raw = upload.stream.read(MAX_IMPORT_BYTES + 1)
|
||||||
if len(raw) > MAX_IMPORT_BYTES:
|
if len(raw) > MAX_IMPORT_BYTES:
|
||||||
flash("Die Datei ist zu gross.", "danger")
|
flash("Die Datei ist zu groß.", "danger")
|
||||||
return redirect(url_for("backup.overview"))
|
return redirect(url_for("backup.overview"))
|
||||||
|
|
||||||
try:
|
try:
|
||||||
@@ -154,14 +154,14 @@ def import_preview():
|
|||||||
session[PREVIEW_SESSION_KEY] = token
|
session[PREVIEW_SESSION_KEY] = token
|
||||||
_audit("backup.import_previewed", f"Erstellt {header.get('created_at')}", severity="notice")
|
_audit("backup.import_previewed", f"Erstellt {header.get('created_at')}", severity="notice")
|
||||||
flash(
|
flash(
|
||||||
f"Datei geoeffnet. Sie haben {transfer.PREVIEW_TTL_MINUTES} Minuten Zeit, die "
|
f"Datei geöffnet. Sie haben {transfer.PREVIEW_TTL_MINUTES} Minuten Zeit, die "
|
||||||
"Uebernahme zu bestaetigen.",
|
"Übernahme zu bestätigen.",
|
||||||
"success",
|
"success",
|
||||||
)
|
)
|
||||||
return redirect(url_for("backup.overview"))
|
return redirect(url_for("backup.overview"))
|
||||||
|
|
||||||
|
|
||||||
@bp.post("/import/uebernehmen")
|
@bp.post("/import/apply")
|
||||||
@permission_required("backup.execute")
|
@permission_required("backup.execute")
|
||||||
@reauth_required
|
@reauth_required
|
||||||
def import_apply():
|
def import_apply():
|
||||||
@@ -169,13 +169,13 @@ def import_apply():
|
|||||||
conn = db()
|
conn = db()
|
||||||
token = session.get(PREVIEW_SESSION_KEY, "")
|
token = session.get(PREVIEW_SESSION_KEY, "")
|
||||||
if not token:
|
if not token:
|
||||||
flash("Es liegt keine geoeffnete Sicherung vor.", "danger")
|
flash("Es liegt keine geöffnete Sicherung vor.", "danger")
|
||||||
return redirect(url_for("backup.overview"))
|
return redirect(url_for("backup.overview"))
|
||||||
|
|
||||||
allowed = {category.key for category in _allowed_categories()}
|
allowed = {category.key for category in _allowed_categories()}
|
||||||
chosen = [key for key in request.form.getlist("categories") if key in allowed]
|
chosen = [key for key in request.form.getlist("categories") if key in allowed]
|
||||||
if not chosen:
|
if not chosen:
|
||||||
flash("Es wurde keine Kategorie ausgewaehlt.", "warning")
|
flash("Es wurde keine Kategorie ausgewählt.", "warning")
|
||||||
return redirect(url_for("backup.overview"))
|
return redirect(url_for("backup.overview"))
|
||||||
|
|
||||||
try:
|
try:
|
||||||
@@ -206,7 +206,7 @@ def import_apply():
|
|||||||
action="backup.imported",
|
action="backup.imported",
|
||||||
detail="; ".join(
|
detail="; ".join(
|
||||||
f"{key}: {counts['created']} neu, {counts['updated']} aktualisiert, "
|
f"{key}: {counts['created']} neu, {counts['updated']} aktualisiert, "
|
||||||
f"{counts['skipped']} uebersprungen"
|
f"{counts['skipped']} übersprungen"
|
||||||
for key, counts in report.items()
|
for key, counts in report.items()
|
||||||
),
|
),
|
||||||
ip=client_ip(),
|
ip=client_ip(),
|
||||||
@@ -217,15 +217,15 @@ def import_apply():
|
|||||||
lines = [
|
lines = [
|
||||||
f"{transfer.CATEGORY_BY_KEY[key].label}: {counts['created']} neu, "
|
f"{transfer.CATEGORY_BY_KEY[key].label}: {counts['created']} neu, "
|
||||||
f"{counts['updated']} aktualisiert"
|
f"{counts['updated']} aktualisiert"
|
||||||
+ (f", {counts['skipped']} uebersprungen" if counts["skipped"] else "")
|
+ (f", {counts['skipped']} übersprungen" if counts["skipped"] else "")
|
||||||
for key, counts in report.items()
|
for key, counts in report.items()
|
||||||
if key in transfer.CATEGORY_BY_KEY
|
if key in transfer.CATEGORY_BY_KEY
|
||||||
]
|
]
|
||||||
flash("Uebernahme abgeschlossen. " + " | ".join(lines), "success")
|
flash("Übernahme abgeschlossen. " + " – ".join(lines), "success")
|
||||||
return redirect(url_for("backup.overview"))
|
return redirect(url_for("backup.overview"))
|
||||||
|
|
||||||
|
|
||||||
@bp.post("/import/verwerfen")
|
@bp.post("/import/discard")
|
||||||
@permission_required("backup.execute")
|
@permission_required("backup.execute")
|
||||||
def import_discard():
|
def import_discard():
|
||||||
token = session.pop(PREVIEW_SESSION_KEY, "")
|
token = session.pop(PREVIEW_SESSION_KEY, "")
|
||||||
|
|||||||
@@ -6,13 +6,13 @@ from typing import Any
|
|||||||
|
|
||||||
from flask import Blueprint, abort, flash, redirect, render_template, request, url_for
|
from flask import Blueprint, abort, flash, redirect, render_template, request, url_for
|
||||||
from tesm_core import audit
|
from tesm_core import audit
|
||||||
from tesm_core.auth.models import current_user, permission_required, reauth_required
|
from tesm_core.auth.models import current_user, permission_required
|
||||||
from tesm_core.auth.service import client_ip
|
from tesm_core.auth.service import client_ip
|
||||||
from tesm_core.extension import core, db, transaction
|
from tesm_core.extension import core, db, transaction
|
||||||
|
|
||||||
from ..services import inventory
|
from ..services import inventory
|
||||||
|
|
||||||
bp = Blueprint("credentials", __name__, url_prefix="/zugangsdaten")
|
bp = Blueprint("credentials", __name__, url_prefix="/monitoring/credentials")
|
||||||
|
|
||||||
|
|
||||||
def _audit(conn: Any, action: str, target: str, detail: str = "", severity: str = "info") -> None:
|
def _audit(conn: Any, action: str, target: str, detail: str = "", severity: str = "info") -> None:
|
||||||
@@ -39,7 +39,7 @@ def overview():
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@bp.post("/neu")
|
@bp.post("/new")
|
||||||
@permission_required("credentials.create")
|
@permission_required("credentials.create")
|
||||||
def create():
|
def create():
|
||||||
try:
|
try:
|
||||||
@@ -85,13 +85,13 @@ def update(credential_id: int):
|
|||||||
"; ".join(changes),
|
"; ".join(changes),
|
||||||
severity="notice",
|
severity="notice",
|
||||||
)
|
)
|
||||||
flash("Aenderungen gespeichert." if changes else "Keine Aenderungen.", "success" if changes else "info")
|
flash("Änderungen gespeichert." if changes else "Keine Änderungen.", "success" if changes else "info")
|
||||||
except inventory.ValidationError as exc:
|
except inventory.ValidationError as exc:
|
||||||
flash(str(exc), "danger")
|
flash(str(exc), "danger")
|
||||||
return redirect(url_for("credentials.overview"))
|
return redirect(url_for("credentials.overview"))
|
||||||
|
|
||||||
|
|
||||||
@bp.post("/<int:credential_id>/loeschen")
|
@bp.post("/<int:credential_id>/delete")
|
||||||
@permission_required("credentials.delete")
|
@permission_required("credentials.delete")
|
||||||
def delete(credential_id: int):
|
def delete(credential_id: int):
|
||||||
try:
|
try:
|
||||||
@@ -109,9 +109,9 @@ def delete(credential_id: int):
|
|||||||
return redirect(url_for("credentials.overview"))
|
return redirect(url_for("credentials.overview"))
|
||||||
|
|
||||||
|
|
||||||
@bp.post("/<int:credential_id>/anzeigen")
|
@bp.post("/<int:credential_id>/reveal")
|
||||||
@permission_required("credentials.secrets")
|
@permission_required("credentials.secrets")
|
||||||
@reauth_required
|
# Die frische Bestaetigung verlangt das Recht selbst (SENSITIVE_ACTIONS).
|
||||||
def reveal(credential_id: int):
|
def reveal(credential_id: int):
|
||||||
"""Zeigt ein gespeichertes Passwort im Klartext.
|
"""Zeigt ein gespeichertes Passwort im Klartext.
|
||||||
|
|
||||||
|
|||||||
@@ -80,7 +80,7 @@ def _tiles(*, authenticated: bool) -> dict[str, Any]:
|
|||||||
# erwartbar weg. Wer die Uebersicht ansieht, will genau das zuerst
|
# erwartbar weg. Wer die Uebersicht ansieht, will genau das zuerst
|
||||||
# wissen -- sonst haelt er einen laufenden Neustart fuer einen Ausfall.
|
# wissen -- sonst haelt er einen laufenden Neustart fuer einen Ausfall.
|
||||||
"key": "restarting",
|
"key": "restarting",
|
||||||
"label": "Neustart laeuft",
|
"label": "Neustart läuft",
|
||||||
"kind": "warning",
|
"kind": "warning",
|
||||||
"tiles": [tile for tile in tiles if tile["state"] == "restarting"],
|
"tiles": [tile for tile in tiles if tile["state"] == "restarting"],
|
||||||
},
|
},
|
||||||
@@ -98,7 +98,7 @@ def _tiles(*, authenticated: bool) -> dict[str, Any]:
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"key": "unknown",
|
"key": "unknown",
|
||||||
"label": "Noch nicht geprueft",
|
"label": "Noch nicht geprüft",
|
||||||
"kind": "warning",
|
"kind": "warning",
|
||||||
"tiles": [tile for tile in tiles if tile["state"] == "unknown"],
|
"tiles": [tile for tile in tiles if tile["state"] == "unknown"],
|
||||||
},
|
},
|
||||||
@@ -121,7 +121,7 @@ def _tiles(*, authenticated: bool) -> dict[str, Any]:
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@bp.get("/uebersicht")
|
@bp.get("/overview")
|
||||||
def index():
|
def index():
|
||||||
from tesm_core.auth.models import current_user
|
from tesm_core.auth.models import current_user
|
||||||
|
|
||||||
@@ -138,7 +138,7 @@ def index_view():
|
|||||||
return index()
|
return index()
|
||||||
|
|
||||||
|
|
||||||
@bp.get("/uebersicht/kacheln")
|
@bp.get("/overview/tiles")
|
||||||
@fragment
|
@fragment
|
||||||
def tiles():
|
def tiles():
|
||||||
"""Teilbereich fuer die automatische Aktualisierung -- liefert nur HTML-Fragment."""
|
"""Teilbereich fuer die automatische Aktualisierung -- liefert nur HTML-Fragment."""
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ from tesm_core.extension import core, db, transaction
|
|||||||
|
|
||||||
from ..services import inventory, monitor, restart, ssh
|
from ..services import inventory, monitor, restart, ssh
|
||||||
|
|
||||||
bp = Blueprint("devices", __name__, url_prefix="/clients")
|
bp = Blueprint("devices", __name__, url_prefix="/monitoring/clients")
|
||||||
|
|
||||||
|
|
||||||
def _audit(conn: Any, action: str, target: str, detail: str = "", severity: str = "info") -> None:
|
def _audit(conn: Any, action: str, target: str, detail: str = "", severity: str = "info") -> None:
|
||||||
@@ -100,7 +100,7 @@ def _credential_from_form(conn: Any) -> int | None:
|
|||||||
)
|
)
|
||||||
name = (request.form.get("new_credential_name") or "").strip()
|
name = (request.form.get("new_credential_name") or "").strip()
|
||||||
if not name:
|
if not name:
|
||||||
raise inventory.ValidationError("Fuer neue Zugangsdaten wird eine Bezeichnung gebraucht.")
|
raise inventory.ValidationError("Für neue Zugangsdaten wird eine Bezeichnung gebraucht.")
|
||||||
return inventory.create_credential(
|
return inventory.create_credential(
|
||||||
conn,
|
conn,
|
||||||
core().keystore,
|
core().keystore,
|
||||||
@@ -108,12 +108,12 @@ def _credential_from_form(conn: Any) -> int | None:
|
|||||||
username=(request.form.get("new_credential_username") or "").strip(),
|
username=(request.form.get("new_credential_username") or "").strip(),
|
||||||
secret=request.form.get("new_credential_secret") or "",
|
secret=request.form.get("new_credential_secret") or "",
|
||||||
category=request.form.get("new_credential_category", "linux"),
|
category=request.form.get("new_credential_category", "linux"),
|
||||||
description="Beim Anlegen eines Geraets erfasst.",
|
description="Beim Anlegen eines Geräts erfasst.",
|
||||||
actor=current_user.username,
|
actor=current_user.username,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@bp.post("/neu")
|
@bp.post("/new")
|
||||||
@permission_required("devices.create")
|
@permission_required("devices.create")
|
||||||
def create():
|
def create():
|
||||||
try:
|
try:
|
||||||
@@ -130,7 +130,7 @@ def create():
|
|||||||
f"MAC {request.form.get('mac')}, IP {request.form.get('ip')}",
|
f"MAC {request.form.get('mac')}, IP {request.form.get('ip')}",
|
||||||
severity="notice",
|
severity="notice",
|
||||||
)
|
)
|
||||||
flash("Geraet angelegt.", "success")
|
flash("Gerät angelegt.", "success")
|
||||||
return redirect(url_for("devices.overview", highlight=device_id))
|
return redirect(url_for("devices.overview", highlight=device_id))
|
||||||
except inventory.ValidationError as exc:
|
except inventory.ValidationError as exc:
|
||||||
flash(str(exc), "danger")
|
flash(str(exc), "danger")
|
||||||
@@ -155,13 +155,13 @@ def update(device_id: int):
|
|||||||
"; ".join(changes),
|
"; ".join(changes),
|
||||||
severity="notice",
|
severity="notice",
|
||||||
)
|
)
|
||||||
flash("Aenderungen gespeichert." if changes else "Keine Aenderungen.", "success" if changes else "info")
|
flash("Änderungen gespeichert." if changes else "Keine Änderungen.", "success" if changes else "info")
|
||||||
except inventory.ValidationError as exc:
|
except inventory.ValidationError as exc:
|
||||||
flash(str(exc), "danger")
|
flash(str(exc), "danger")
|
||||||
return redirect(url_for("devices.overview"))
|
return redirect(url_for("devices.overview"))
|
||||||
|
|
||||||
|
|
||||||
@bp.post("/<int:device_id>/aktiv")
|
@bp.post("/<int:device_id>/active")
|
||||||
@permission_required("devices.edit")
|
@permission_required("devices.edit")
|
||||||
def toggle_active(device_id: int):
|
def toggle_active(device_id: int):
|
||||||
active = request.form.get("active") == "1"
|
active = request.form.get("active") == "1"
|
||||||
@@ -176,11 +176,11 @@ def toggle_active(device_id: int):
|
|||||||
str(device["name"]),
|
str(device["name"]),
|
||||||
severity="notice",
|
severity="notice",
|
||||||
)
|
)
|
||||||
flash("Geraet aktiviert." if active else "Geraet deaktiviert.", "success")
|
flash("Gerät aktiviert." if active else "Gerät deaktiviert.", "success")
|
||||||
return redirect(request.referrer or url_for("devices.overview"))
|
return redirect(request.referrer or url_for("devices.overview"))
|
||||||
|
|
||||||
|
|
||||||
@bp.post("/<int:device_id>/loeschen")
|
@bp.post("/<int:device_id>/delete")
|
||||||
@permission_required("devices.delete")
|
@permission_required("devices.delete")
|
||||||
def delete(device_id: int):
|
def delete(device_id: int):
|
||||||
with transaction() as conn:
|
with transaction() as conn:
|
||||||
@@ -189,11 +189,11 @@ def delete(device_id: int):
|
|||||||
abort(404)
|
abort(404)
|
||||||
inventory.delete_device(conn, device_id)
|
inventory.delete_device(conn, device_id)
|
||||||
_audit(conn, "device.deleted", str(device["name"]), severity="warning")
|
_audit(conn, "device.deleted", str(device["name"]), severity="warning")
|
||||||
flash("Geraet in den Papierkorb verschoben.", "success")
|
flash("Gerät in den Papierkorb verschoben.", "success")
|
||||||
return redirect(url_for("devices.overview"))
|
return redirect(url_for("devices.overview"))
|
||||||
|
|
||||||
|
|
||||||
@bp.post("/<int:device_id>/neustart")
|
@bp.post("/<int:device_id>/restart")
|
||||||
@permission_required("devices.execute")
|
@permission_required("devices.execute")
|
||||||
def restart_device(device_id: int):
|
def restart_device(device_id: int):
|
||||||
"""Startet ein Geraet neu -- ueber den Weg, der dafuer gewaehlt wurde.
|
"""Startet ein Geraet neu -- ueber den Weg, der dafuer gewaehlt wurde.
|
||||||
@@ -219,14 +219,14 @@ def restart_device(device_id: int):
|
|||||||
options = restart.available(dict(device), maintenance_active=maintenance_active)
|
options = restart.available(dict(device), maintenance_active=maintenance_active)
|
||||||
if options:
|
if options:
|
||||||
flash(
|
flash(
|
||||||
"Der gewaehlte Weg steht fuer dieses Geraet nicht zur Verfuegung. "
|
"Der gewählte Weg steht für dieses Gerät nicht zur Verfügung. "
|
||||||
"Moeglich waere: " + ", ".join(entry.label for entry in options),
|
"Möglich wäre: " + ", ".join(entry.label for entry in options),
|
||||||
"warning",
|
"warning",
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
flash(
|
flash(
|
||||||
f"Fuer {device['name']} ist kein Neustart moeglich: es fehlen Switch und Port "
|
f"Für {device['name']} ist kein Neustart möglich: es fehlen Switch und Port "
|
||||||
"fuer den PoE-Weg und passende Zugangsdaten fuer SSH oder RPC.",
|
"für den PoE-Weg und passende Zugangsdaten für SSH oder RPC.",
|
||||||
"warning",
|
"warning",
|
||||||
)
|
)
|
||||||
return redirect(request.referrer or url_for("devices.overview"))
|
return redirect(request.referrer or url_for("devices.overview"))
|
||||||
@@ -262,7 +262,7 @@ def _restart_over_poe(extension: Any, conn: Any, device: Any): # type: ignore[n
|
|||||||
return redirect(request.referrer or url_for("devices.overview"))
|
return redirect(request.referrer or url_for("devices.overview"))
|
||||||
|
|
||||||
flash(
|
flash(
|
||||||
f"PoE-Neustart fuer {device['name']} durchgefuehrt."
|
f"PoE-Neustart für {device['name']} durchgeführt."
|
||||||
if outcome["ok"]
|
if outcome["ok"]
|
||||||
else f"PoE-Neustart fehlgeschlagen: {outcome['message']}",
|
else f"PoE-Neustart fehlgeschlagen: {outcome['message']}",
|
||||||
"success" if outcome["ok"] else "danger",
|
"success" if outcome["ok"] else "danger",
|
||||||
@@ -319,7 +319,7 @@ def _restart_as_job(extension: Any, conn: Any, device: Any, method: Any): # typ
|
|||||||
method=method.key,
|
method=method.key,
|
||||||
)
|
)
|
||||||
flash(
|
flash(
|
||||||
f"{method.label} fuer {device['name']} gestartet. Der Verlauf steht unter Wartung.",
|
f"{method.label} für {device['name']} gestartet. Der Verlauf steht unter Wartung.",
|
||||||
"success",
|
"success",
|
||||||
)
|
)
|
||||||
return redirect(request.referrer or url_for("devices.overview"))
|
return redirect(request.referrer or url_for("devices.overview"))
|
||||||
@@ -351,7 +351,7 @@ def detail(device_id: int):
|
|||||||
categories=inventory.DEVICE_CATEGORIES,
|
categories=inventory.DEVICE_CATEGORIES,
|
||||||
host_keys=host_keys,
|
host_keys=host_keys,
|
||||||
method_labels=monitor.METHOD_LABELS,
|
method_labels=monitor.METHOD_LABELS,
|
||||||
terminal_enabled=bool(extension.settings.get(conn, "terminal_enabled")),
|
terminal_enabled=bool(extension.settings.get(conn, "terminal_clients_enabled")),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -360,7 +360,7 @@ def detail(device_id: int):
|
|||||||
# --------------------------------------------------------------------------- #
|
# --------------------------------------------------------------------------- #
|
||||||
|
|
||||||
|
|
||||||
@bp.post("/<int:device_id>/hostkey/pruefen")
|
@bp.post("/<int:device_id>/host-key/probe")
|
||||||
@permission_required("devices.edit")
|
@permission_required("devices.edit")
|
||||||
def probe_host_key(device_id: int):
|
def probe_host_key(device_id: int):
|
||||||
"""Liest den Host-Schluessel des Clients, ohne sich anzumelden.
|
"""Liest den Host-Schluessel des Clients, ohne sich anzumelden.
|
||||||
@@ -390,7 +390,7 @@ def probe_host_key(device_id: int):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@bp.post("/<int:device_id>/hostkey/freigeben")
|
@bp.post("/<int:device_id>/host-key/trust")
|
||||||
@permission_required("devices.edit")
|
@permission_required("devices.edit")
|
||||||
@reauth_required
|
@reauth_required
|
||||||
def trust_host_key(device_id: int):
|
def trust_host_key(device_id: int):
|
||||||
@@ -401,7 +401,7 @@ def trust_host_key(device_id: int):
|
|||||||
key_type = (request.form.get("key_type") or "").strip()
|
key_type = (request.form.get("key_type") or "").strip()
|
||||||
fingerprint = (request.form.get("fingerprint") or "").strip()
|
fingerprint = (request.form.get("fingerprint") or "").strip()
|
||||||
if not key_type or not ssh.valid_fingerprint(fingerprint):
|
if not key_type or not ssh.valid_fingerprint(fingerprint):
|
||||||
flash("Es wurde kein gueltiger Fingerprint uebergeben.", "danger")
|
flash("Es wurde kein gültiger Fingerprint übergeben.", "danger")
|
||||||
return redirect(url_for("devices.detail", device_id=device_id))
|
return redirect(url_for("devices.detail", device_id=device_id))
|
||||||
|
|
||||||
with transaction():
|
with transaction():
|
||||||
@@ -420,11 +420,11 @@ def trust_host_key(device_id: int):
|
|||||||
f"{key_type} {fingerprint}",
|
f"{key_type} {fingerprint}",
|
||||||
severity="notice",
|
severity="notice",
|
||||||
)
|
)
|
||||||
flash("Host-Schluessel freigegeben.", "success")
|
flash("Host-Schlüssel freigegeben.", "success")
|
||||||
return redirect(url_for("devices.detail", device_id=device_id))
|
return redirect(url_for("devices.detail", device_id=device_id))
|
||||||
|
|
||||||
|
|
||||||
@bp.post("/<int:device_id>/hostkey/verwerfen")
|
@bp.post("/<int:device_id>/host-key/forget")
|
||||||
@permission_required("devices.edit")
|
@permission_required("devices.edit")
|
||||||
def forget_host_key(device_id: int):
|
def forget_host_key(device_id: int):
|
||||||
conn = db()
|
conn = db()
|
||||||
@@ -434,5 +434,5 @@ def forget_host_key(device_id: int):
|
|||||||
with transaction():
|
with transaction():
|
||||||
ssh.forget_key(conn, str(device["ip"]), int(device["ssh_port"] or 22))
|
ssh.forget_key(conn, str(device["ip"]), int(device["ssh_port"] or 22))
|
||||||
_audit(conn, "device.host_key_forgotten", str(device["name"]), severity="warning")
|
_audit(conn, "device.host_key_forgotten", str(device["name"]), severity="warning")
|
||||||
flash("Gespeicherte Host-Schluessel entfernt.", "info")
|
flash("Gespeicherte Host-Schlüssel entfernt.", "info")
|
||||||
return redirect(url_for("devices.detail", device_id=device_id))
|
return redirect(url_for("devices.detail", device_id=device_id))
|
||||||
@@ -105,7 +105,7 @@ def overview():
|
|||||||
# --------------------------------------------------------------------------- #
|
# --------------------------------------------------------------------------- #
|
||||||
|
|
||||||
|
|
||||||
@bp.post("/einstellungen")
|
@bp.post("/settings")
|
||||||
@permission_required("dhcp.edit")
|
@permission_required("dhcp.edit")
|
||||||
def save_settings():
|
def save_settings():
|
||||||
extension = core()
|
extension = core()
|
||||||
@@ -118,11 +118,11 @@ def save_settings():
|
|||||||
with transaction():
|
with transaction():
|
||||||
changed = extension.settings.set_many(conn, values, actor=current_user.username)
|
changed = extension.settings.set_many(conn, values, actor=current_user.username)
|
||||||
_audit("dhcp.settings_saved", detail=", ".join(changed), severity="notice")
|
_audit("dhcp.settings_saved", detail=", ".join(changed), severity="notice")
|
||||||
flash("Einstellungen gespeichert." if changed else "Keine Aenderungen.", "success" if changed else "info")
|
flash("Einstellungen gespeichert." if changed else "Keine Änderungen.", "success" if changed else "info")
|
||||||
return _write_and_redirect()
|
return _write_and_redirect()
|
||||||
|
|
||||||
|
|
||||||
@bp.post("/subnetz")
|
@bp.post("/subnet")
|
||||||
@permission_required("dhcp.edit")
|
@permission_required("dhcp.edit")
|
||||||
def add_subnet():
|
def add_subnet():
|
||||||
extension = core()
|
extension = core()
|
||||||
@@ -158,7 +158,7 @@ def add_subnet():
|
|||||||
return _write_and_redirect()
|
return _write_and_redirect()
|
||||||
|
|
||||||
|
|
||||||
@bp.post("/subnetz/<int:subnet_id>")
|
@bp.post("/subnet/<int:subnet_id>")
|
||||||
@permission_required("dhcp.edit")
|
@permission_required("dhcp.edit")
|
||||||
def update_subnet(subnet_id: int):
|
def update_subnet(subnet_id: int):
|
||||||
extension = core()
|
extension = core()
|
||||||
@@ -173,6 +173,9 @@ def update_subnet(subnet_id: int):
|
|||||||
flash(problem, "danger")
|
flash(problem, "danger")
|
||||||
return redirect(url_for("dhcp.overview"))
|
return redirect(url_for("dhcp.overview"))
|
||||||
|
|
||||||
|
# Vor der Aenderung lesen -- danach ist der alte Stand weg, und "Subnetz
|
||||||
|
# gespeichert" allein sagt nicht, was vorher eingestellt war.
|
||||||
|
vorher = Database.one(conn, "SELECT * FROM dhcp_subnets WHERE id=?", (subnet_id,))
|
||||||
with transaction():
|
with transaction():
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"UPDATE dhcp_subnets SET interface=?, range_start=?, range_end=?, gateway=?, dns=?, "
|
"UPDATE dhcp_subnets SET interface=?, range_start=?, range_end=?, gateway=?, dns=?, "
|
||||||
@@ -189,12 +192,31 @@ def update_subnet(subnet_id: int):
|
|||||||
subnet_id,
|
subnet_id,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
_audit("dhcp.subnet_updated", str(subnet_id), severity="notice")
|
nachher = Database.one(conn, "SELECT * FROM dhcp_subnets WHERE id=?", (subnet_id,))
|
||||||
|
_audit(
|
||||||
|
"dhcp.subnet_updated",
|
||||||
|
f"{interface} {start}-{end}",
|
||||||
|
audit.diff(
|
||||||
|
vorher,
|
||||||
|
nachher,
|
||||||
|
felder=(
|
||||||
|
("interface", "Schnittstelle"),
|
||||||
|
("range_start", "Von"),
|
||||||
|
("range_end", "Bis"),
|
||||||
|
("gateway", "Gateway"),
|
||||||
|
("dns", "DNS"),
|
||||||
|
("comment", "Kommentar"),
|
||||||
|
("enabled", "Aktiv"),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
or "keine inhaltliche Änderung",
|
||||||
|
severity="notice",
|
||||||
|
)
|
||||||
flash("Subnetz gespeichert.", "success")
|
flash("Subnetz gespeichert.", "success")
|
||||||
return _write_and_redirect()
|
return _write_and_redirect()
|
||||||
|
|
||||||
|
|
||||||
@bp.post("/subnetz/<int:subnet_id>/loeschen")
|
@bp.post("/subnet/<int:subnet_id>/delete")
|
||||||
@permission_required("dhcp.edit")
|
@permission_required("dhcp.edit")
|
||||||
def delete_subnet(subnet_id: int):
|
def delete_subnet(subnet_id: int):
|
||||||
with transaction() as conn:
|
with transaction() as conn:
|
||||||
@@ -209,7 +231,7 @@ def delete_subnet(subnet_id: int):
|
|||||||
# --------------------------------------------------------------------------- #
|
# --------------------------------------------------------------------------- #
|
||||||
|
|
||||||
|
|
||||||
@bp.post("/option/wert")
|
@bp.post("/option/value")
|
||||||
@permission_required("dhcp.edit")
|
@permission_required("dhcp.edit")
|
||||||
def set_option_value():
|
def set_option_value():
|
||||||
conn = db()
|
conn = db()
|
||||||
@@ -248,7 +270,7 @@ def _device_option_values(conn) -> dict[int, list[dict[str, Any]]]: # type: ign
|
|||||||
return gruppen
|
return gruppen
|
||||||
|
|
||||||
|
|
||||||
@bp.post("/option/geraet")
|
@bp.post("/option/device")
|
||||||
@permission_required("dhcp.edit")
|
@permission_required("dhcp.edit")
|
||||||
def set_device_option_value():
|
def set_device_option_value():
|
||||||
"""Setzt eine Option fuer ein einzelnes Geraet.
|
"""Setzt eine Option fuer ein einzelnes Geraet.
|
||||||
@@ -289,14 +311,14 @@ def set_device_option_value():
|
|||||||
value or "(geleert)",
|
value or "(geleert)",
|
||||||
)
|
)
|
||||||
flash(
|
flash(
|
||||||
f"Option {option['name']} fuer {device['name']} "
|
f"Option {option['name']} für {device['name']} "
|
||||||
f"{'gespeichert' if value else 'entfernt'}.",
|
f"{'gespeichert' if value else 'entfernt'}.",
|
||||||
"success",
|
"success",
|
||||||
)
|
)
|
||||||
return _write_and_redirect()
|
return _write_and_redirect()
|
||||||
|
|
||||||
|
|
||||||
@bp.post("/option/neu")
|
@bp.post("/option/new")
|
||||||
@permission_required("dhcp.edit")
|
@permission_required("dhcp.edit")
|
||||||
def add_custom_option():
|
def add_custom_option():
|
||||||
conn = db()
|
conn = db()
|
||||||
@@ -327,7 +349,7 @@ def add_custom_option():
|
|||||||
return redirect(url_for("dhcp.overview"))
|
return redirect(url_for("dhcp.overview"))
|
||||||
|
|
||||||
|
|
||||||
@bp.post("/option/<int:option_id>/loeschen")
|
@bp.post("/option/<int:option_id>/delete")
|
||||||
@permission_required("dhcp.edit")
|
@permission_required("dhcp.edit")
|
||||||
def delete_custom_option(option_id: int):
|
def delete_custom_option(option_id: int):
|
||||||
conn = db()
|
conn = db()
|
||||||
@@ -336,7 +358,7 @@ def delete_custom_option(option_id: int):
|
|||||||
abort(404)
|
abort(404)
|
||||||
if int(option["is_standard"] or 0):
|
if int(option["is_standard"] or 0):
|
||||||
flash(
|
flash(
|
||||||
"Standardoptionen koennen nicht geloescht werden -- nur ihr Wert lasst sich leeren.",
|
"Standardoptionen können nicht gelöscht werden – nur ihr Wert lässt sich leeren.",
|
||||||
"danger",
|
"danger",
|
||||||
)
|
)
|
||||||
return redirect(url_for("dhcp.overview"))
|
return redirect(url_for("dhcp.overview"))
|
||||||
@@ -352,14 +374,14 @@ def delete_custom_option(option_id: int):
|
|||||||
# --------------------------------------------------------------------------- #
|
# --------------------------------------------------------------------------- #
|
||||||
|
|
||||||
|
|
||||||
@bp.post("/reservierung")
|
@bp.post("/reservation")
|
||||||
@permission_required("dhcp.edit")
|
@permission_required("dhcp.edit")
|
||||||
def add_reservation():
|
def add_reservation():
|
||||||
conn = db()
|
conn = db()
|
||||||
mac = kea.normalize_mac(request.form.get("mac", ""))
|
mac = kea.normalize_mac(request.form.get("mac", ""))
|
||||||
ip = (request.form.get("ip") or "").strip()
|
ip = (request.form.get("ip") or "").strip()
|
||||||
if not mac:
|
if not mac:
|
||||||
flash("Die MAC-Adresse ist ungueltig.", "danger")
|
flash("Die MAC-Adresse ist ungültig.", "danger")
|
||||||
return redirect(url_for("dhcp.overview"))
|
return redirect(url_for("dhcp.overview"))
|
||||||
|
|
||||||
# Eine Reservierung ausserhalb aller eingerichteten Bereiche liefert Kea nie
|
# Eine Reservierung ausserhalb aller eingerichteten Bereiche liefert Kea nie
|
||||||
@@ -367,7 +389,7 @@ def add_reservation():
|
|||||||
# schlechteste der drei Moeglichkeiten -- deshalb hier ablehnen.
|
# schlechteste der drei Moeglichkeiten -- deshalb hier ablehnen.
|
||||||
if not Database.value(conn, "SELECT COUNT(*) FROM dhcp_subnets WHERE enabled=1"):
|
if not Database.value(conn, "SELECT COUNT(*) FROM dhcp_subnets WHERE enabled=1"):
|
||||||
flash(
|
flash(
|
||||||
"Es ist noch kein Bereich eingerichtet. Legen Sie zuerst ein Subnetz an -- "
|
"Es ist noch kein Bereich eingerichtet. Legen Sie zuerst ein Subnetz an – "
|
||||||
"ohne eines wird keine Reservierung ausgeliefert.",
|
"ohne eines wird keine Reservierung ausgeliefert.",
|
||||||
"danger",
|
"danger",
|
||||||
)
|
)
|
||||||
@@ -380,7 +402,7 @@ def add_reservation():
|
|||||||
# keine Reservierung mehr moeglich. Stattdessen benennen, was ungeprueft
|
# keine Reservierung mehr moeglich. Stattdessen benennen, was ungeprueft
|
||||||
# bleibt.
|
# bleibt.
|
||||||
flash(
|
flash(
|
||||||
"Der Bereich konnte nicht geprueft werden -- ohne Systemhelfer ist das Netz "
|
"Der Bereich konnte nicht geprüft werden – ohne Systemhelfer ist das Netz "
|
||||||
"dieses Servers nicht feststellbar.",
|
"dieses Servers nicht feststellbar.",
|
||||||
"warning",
|
"warning",
|
||||||
)
|
)
|
||||||
@@ -390,11 +412,12 @@ def add_reservation():
|
|||||||
bereiche = ", ".join(str(netz) for netz in netze)
|
bereiche = ", ".join(str(netz) for netz in netze)
|
||||||
flash(
|
flash(
|
||||||
f"{ip or 'Diese Adresse'} liegt in keinem eingerichteten Bereich "
|
f"{ip or 'Diese Adresse'} liegt in keinem eingerichteten Bereich "
|
||||||
f"({bereiche}). Die Reservierung waere ohne Wirkung.",
|
f"({bereiche}). Die Reservierung wäre ohne Wirkung.",
|
||||||
"danger",
|
"danger",
|
||||||
)
|
)
|
||||||
return redirect(url_for("dhcp.overview"))
|
return redirect(url_for("dhcp.overview"))
|
||||||
|
|
||||||
|
vorher = Database.one(conn, "SELECT * FROM dhcp_reservations WHERE mac=?", (mac,))
|
||||||
with transaction():
|
with transaction():
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"INSERT INTO dhcp_reservations (mac, ip, hostname, comment, created_at) "
|
"INSERT INTO dhcp_reservations (mac, ip, hostname, comment, created_at) "
|
||||||
@@ -407,12 +430,23 @@ def add_reservation():
|
|||||||
(request.form.get("comment") or "").strip(),
|
(request.form.get("comment") or "").strip(),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
_audit("dhcp.reservation_saved", f"{mac} -> {ip}", severity="notice")
|
nachher = Database.one(conn, "SELECT * FROM dhcp_reservations WHERE mac=?", (mac,))
|
||||||
|
_audit(
|
||||||
|
"dhcp.reservation_saved",
|
||||||
|
mac,
|
||||||
|
audit.diff(
|
||||||
|
vorher,
|
||||||
|
nachher,
|
||||||
|
felder=(("ip", "IP"), ("hostname", "Hostname"), ("comment", "Kommentar")),
|
||||||
|
)
|
||||||
|
or f"neu angelegt: {ip}",
|
||||||
|
severity="notice",
|
||||||
|
)
|
||||||
flash("Reservierung gespeichert.", "success")
|
flash("Reservierung gespeichert.", "success")
|
||||||
return _write_and_redirect()
|
return _write_and_redirect()
|
||||||
|
|
||||||
|
|
||||||
@bp.post("/reservierung/<int:reservation_id>/loeschen")
|
@bp.post("/reservation/<int:reservation_id>/delete")
|
||||||
@permission_required("dhcp.edit")
|
@permission_required("dhcp.edit")
|
||||||
def delete_reservation(reservation_id: int):
|
def delete_reservation(reservation_id: int):
|
||||||
with transaction() as conn:
|
with transaction() as conn:
|
||||||
@@ -437,14 +471,14 @@ def _write_config() -> dict[str, Any]:
|
|||||||
sysops = extension.sysops
|
sysops = extension.sysops
|
||||||
conn = db()
|
conn = db()
|
||||||
if sysops is None or not sysops.available:
|
if sysops is None or not sysops.available:
|
||||||
return {"ok": False, "message": "Systemhelfer nicht verfuegbar."}
|
return {"ok": False, "message": "Systemhelfer nicht verfügbar."}
|
||||||
|
|
||||||
networks = kea.detect_networks(sysops)
|
networks = kea.detect_networks(sysops)
|
||||||
config, warnings = kea.render_config(conn, networks=networks, settings=_settings_values())
|
config, warnings = kea.render_config(conn, networks=networks, settings=_settings_values())
|
||||||
if not config["Dhcp4"]["subnet4"]:
|
if not config["Dhcp4"]["subnet4"]:
|
||||||
return {
|
return {
|
||||||
"ok": False,
|
"ok": False,
|
||||||
"message": "Es ist kein gueltiges Subnetz konfiguriert -- die Datei wurde nicht geschrieben.",
|
"message": "Es ist kein gültiges Subnetz konfiguriert – die Datei wurde nicht geschrieben.",
|
||||||
"warnings": warnings,
|
"warnings": warnings,
|
||||||
}
|
}
|
||||||
result = sysops.write_kea_config(kea.config_to_text(config))
|
result = sysops.write_kea_config(kea.config_to_text(config))
|
||||||
@@ -480,13 +514,13 @@ def _write_and_redirect():
|
|||||||
return redirect(url_for("dhcp.overview"))
|
return redirect(url_for("dhcp.overview"))
|
||||||
|
|
||||||
|
|
||||||
@bp.post("/schreiben")
|
@bp.post("/write")
|
||||||
@permission_required("dhcp.edit")
|
@permission_required("dhcp.edit")
|
||||||
def write_config():
|
def write_config():
|
||||||
return _write_and_redirect()
|
return _write_and_redirect()
|
||||||
|
|
||||||
|
|
||||||
@bp.post("/dienst/<action>")
|
@bp.post("/service/<action>")
|
||||||
@permission_required("dhcp.execute")
|
@permission_required("dhcp.execute")
|
||||||
def service_action(action: str):
|
def service_action(action: str):
|
||||||
allowed = {"install", "start", "stop"}
|
allowed = {"install", "start", "stop"}
|
||||||
@@ -495,7 +529,7 @@ def service_action(action: str):
|
|||||||
extension = core()
|
extension = core()
|
||||||
sysops = extension.sysops
|
sysops = extension.sysops
|
||||||
if sysops is None or not sysops.available:
|
if sysops is None or not sysops.available:
|
||||||
flash("Systemaktionen sind in dieser Umgebung nicht verfuegbar.", "warning")
|
flash("Systemaktionen sind in dieser Umgebung nicht verfügbar.", "warning")
|
||||||
return redirect(url_for("dhcp.overview"))
|
return redirect(url_for("dhcp.overview"))
|
||||||
|
|
||||||
if action == "install":
|
if action == "install":
|
||||||
|
|||||||
@@ -2,7 +2,10 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import hashlib
|
||||||
import io
|
import io
|
||||||
|
import json
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
from flask import (
|
from flask import (
|
||||||
Blueprint,
|
Blueprint,
|
||||||
@@ -23,7 +26,7 @@ from tesm_core.extension import core, db, transaction
|
|||||||
|
|
||||||
from ..services import fileshare as service
|
from ..services import fileshare as service
|
||||||
|
|
||||||
bp = Blueprint("fileshare", __name__, url_prefix="/dateifreigaben")
|
bp = Blueprint("fileshare", __name__, url_prefix="/fileshares")
|
||||||
|
|
||||||
|
|
||||||
@bp.before_request
|
@bp.before_request
|
||||||
@@ -47,8 +50,33 @@ def _audit(action: str, target: str, detail: str = "", severity: str = "info") -
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _unterschriften(roh: str) -> list[str]:
|
||||||
|
"""Die Namen aus dem Unterschriftsfeld -- Text, keine Struktur.
|
||||||
|
|
||||||
|
Was hier ankommt, hat der Browser geschickt und damit der Mensch davor.
|
||||||
|
Es wird protokolliert, nicht geglaubt: laenge begrenzt, Zeilenumbrueche
|
||||||
|
raus, mehr passiert damit nicht.
|
||||||
|
"""
|
||||||
|
namen: list[str] = []
|
||||||
|
try:
|
||||||
|
eintraege = json.loads(roh) if roh else []
|
||||||
|
except ValueError:
|
||||||
|
return []
|
||||||
|
if not isinstance(eintraege, list):
|
||||||
|
return []
|
||||||
|
for eintrag in eintraege[:20]:
|
||||||
|
name = str(eintrag if isinstance(eintrag, str) else eintrag.get("name", ""))
|
||||||
|
name = " ".join(name.split())[:120]
|
||||||
|
if name:
|
||||||
|
namen.append(name)
|
||||||
|
return namen
|
||||||
|
|
||||||
|
|
||||||
def _mount_or_404(token: str) -> service.Mount:
|
def _mount_or_404(token: str) -> service.Mount:
|
||||||
mounts = service.active_mounts(db(), current_user.id)
|
conn = db()
|
||||||
|
mounts = service.doppelte_zusammenfassen(
|
||||||
|
conn, service.active_mounts(conn, current_user.id), service.aktuelle_sitzung()
|
||||||
|
)
|
||||||
for mount in mounts:
|
for mount in mounts:
|
||||||
if mount.token == token:
|
if mount.token == token:
|
||||||
return mount
|
return mount
|
||||||
@@ -59,9 +87,11 @@ def _mount_or_404(token: str) -> service.Mount:
|
|||||||
@permission_required("fileshare.view")
|
@permission_required("fileshare.view")
|
||||||
def browse():
|
def browse():
|
||||||
conn = db()
|
conn = db()
|
||||||
mounts = service.active_mounts(conn, current_user.id)
|
mounts = service.doppelte_zusammenfassen(
|
||||||
token = request.args.get("freigabe") or (mounts[0].token if mounts else "")
|
conn, service.active_mounts(conn, current_user.id), service.aktuelle_sitzung()
|
||||||
relative = request.args.get("pfad") or ""
|
)
|
||||||
|
token = request.args.get("share") or (mounts[0].token if mounts else "")
|
||||||
|
relative = request.args.get("path") or ""
|
||||||
listing = None
|
listing = None
|
||||||
error = ""
|
error = ""
|
||||||
if token:
|
if token:
|
||||||
@@ -82,14 +112,21 @@ def browse():
|
|||||||
max_upload_mb=uploads.limit_mb(conn),
|
max_upload_mb=uploads.limit_mb(conn),
|
||||||
# Vorschlagsliste fuer "Ausgefuellt speichern": wohin darf es gehen?
|
# Vorschlagsliste fuer "Ausgefuellt speichern": wohin darf es gehen?
|
||||||
verzeichnisse=(service.all_directories(mount) if listing else []),
|
verzeichnisse=(service.all_directories(mount) if listing else []),
|
||||||
|
signature_witness=bool(core().settings.get(conn, "signature_witness_enabled")),
|
||||||
|
# Die Uhr des Servers. Das Datum unter einer Unterschrift soll nicht
|
||||||
|
# davon abhaengen, wie das Tablet gestellt ist.
|
||||||
|
serverzeit=datetime.now(timezone.utc).astimezone().isoformat(timespec="seconds"),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@bp.get("/<token>/herunterladen")
|
@bp.get("/<token>/download")
|
||||||
@permission_required("fileshare.view")
|
# Herunterladen traegt Daten aus dem System heraus -- in der Rechtematrix ist
|
||||||
|
# das "Exportieren", nicht "Ansehen". Eine Vorschau bleibt im Haus, eine
|
||||||
|
# heruntergeladene Datei nicht.
|
||||||
|
@permission_required("fileshare.export")
|
||||||
def download(token: str):
|
def download(token: str):
|
||||||
mount = _mount_or_404(token)
|
mount = _mount_or_404(token)
|
||||||
relative = request.args.get("pfad") or ""
|
relative = request.args.get("path") or ""
|
||||||
try:
|
try:
|
||||||
path = service.resolve_path(mount, relative)
|
path = service.resolve_path(mount, relative)
|
||||||
except service.FileshareError as exc:
|
except service.FileshareError as exc:
|
||||||
@@ -110,11 +147,11 @@ def download(token: str):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@bp.get("/<token>/vorschau")
|
@bp.get("/<token>/preview")
|
||||||
@permission_required("fileshare.view")
|
@permission_required("fileshare.view")
|
||||||
def preview(token: str):
|
def preview(token: str):
|
||||||
mount = _mount_or_404(token)
|
mount = _mount_or_404(token)
|
||||||
relative = request.args.get("pfad") or ""
|
relative = request.args.get("path") or ""
|
||||||
try:
|
try:
|
||||||
path = service.resolve_path(mount, relative)
|
path = service.resolve_path(mount, relative)
|
||||||
mimetype = service.preview_mime(path.name)
|
mimetype = service.preview_mime(path.name)
|
||||||
@@ -149,15 +186,15 @@ def preview(token: str):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@bp.post("/<token>/hochladen")
|
@bp.post("/<token>/upload")
|
||||||
@permission_required("fileshare.create")
|
@permission_required("fileshare.create")
|
||||||
def upload(token: str):
|
def upload(token: str):
|
||||||
mount = _mount_or_404(token)
|
mount = _mount_or_404(token)
|
||||||
relative = request.form.get("pfad") or ""
|
relative = request.form.get("path") or ""
|
||||||
files = request.files.getlist("files")
|
files = request.files.getlist("files")
|
||||||
if not files:
|
if not files:
|
||||||
flash("Keine Datei ausgewaehlt.", "danger")
|
flash("Keine Datei ausgewählt.", "danger")
|
||||||
return redirect(url_for("fileshare.browse", freigabe=token, pfad=relative))
|
return redirect(url_for("fileshare.browse", share=token, path=relative))
|
||||||
|
|
||||||
limit = uploads.limit_bytes(db())
|
limit = uploads.limit_bytes(db())
|
||||||
stored = 0
|
stored = 0
|
||||||
@@ -169,22 +206,53 @@ def upload(token: str):
|
|||||||
name = service.safe_filename(upload_file.filename)
|
name = service.safe_filename(upload_file.filename)
|
||||||
data = upload_file.stream.read(limit + 1)
|
data = upload_file.stream.read(limit + 1)
|
||||||
if len(data) > limit:
|
if len(data) > limit:
|
||||||
flash(f"{name}: zu gross (Grenze {limit // 1024 // 1024} MB).", "danger")
|
flash(f"{name}: zu groß (Grenze {limit // 1024 // 1024} MB).", "danger")
|
||||||
continue
|
continue
|
||||||
(target_dir / name).write_bytes(data)
|
(target_dir / name).write_bytes(data)
|
||||||
stored += 1
|
stored += 1
|
||||||
except service.FileshareError as exc:
|
except service.FileshareError as exc:
|
||||||
flash(str(exc), "danger")
|
flash(str(exc), "danger")
|
||||||
return redirect(url_for("fileshare.browse", freigabe=token, pfad=relative))
|
return redirect(url_for("fileshare.browse", share=token, path=relative))
|
||||||
|
|
||||||
if stored:
|
if stored:
|
||||||
_audit("fileshare.uploaded", f"{mount.label}:{relative}", f"{stored} Datei(en)")
|
_audit("fileshare.uploaded", f"{mount.label}:{relative}", f"{stored} Datei(en)")
|
||||||
flash(f"{stored} Datei(en) hochgeladen.", "success")
|
flash(f"{stored} Datei(en) hochgeladen.", "success")
|
||||||
return redirect(url_for("fileshare.browse", freigabe=token, pfad=relative))
|
return redirect(url_for("fileshare.browse", share=token, path=relative))
|
||||||
|
|
||||||
|
|
||||||
@bp.post("/<token>/formular")
|
@bp.get("/<token>/signature")
|
||||||
@permission_required("fileshare.create")
|
@permission_required("fileshare.view")
|
||||||
|
def signature_state(token: str):
|
||||||
|
"""Passt die Datei noch zu ihrem Eintrag im Aenderungsprotokoll?
|
||||||
|
|
||||||
|
Die Vorschau fragt das beim Oeffnen. Eine unterschriebene Fassung, die
|
||||||
|
seither veraendert wurde, sieht man einer PDF sonst nicht an -- und genau
|
||||||
|
das ist die Frage, die man sich vor einer Unterschrift stellt.
|
||||||
|
"""
|
||||||
|
mount = _mount_or_404(token)
|
||||||
|
try:
|
||||||
|
return service.signaturstand(db(), mount, request.args.get("path") or "")
|
||||||
|
except service.FileshareError as exc:
|
||||||
|
return {"error": str(exc)}, 400
|
||||||
|
|
||||||
|
|
||||||
|
@bp.get("/<token>/folders")
|
||||||
|
@permission_required("fileshare.view")
|
||||||
|
def folders(token: str):
|
||||||
|
"""Die Ordner **dieser** Freigabe als Vorschlagsliste.
|
||||||
|
|
||||||
|
Gebraucht, seit das Speichern in eine andere Freigabe gehen darf: die
|
||||||
|
Vorschlaege muessen der gewaehlten Freigabe folgen. Alle Listen beim
|
||||||
|
Seitenaufbau mitzuschicken waere die Alternative gewesen -- bei vier
|
||||||
|
eingebundenen Freigaben mit je ein paar hundert Ordnern ist das eine
|
||||||
|
viertel Megabyte, die fast nie gebraucht wird.
|
||||||
|
"""
|
||||||
|
mount = _mount_or_404(token)
|
||||||
|
return {"folders": service.all_directories(mount)}
|
||||||
|
|
||||||
|
|
||||||
|
@bp.post("/<token>/form")
|
||||||
|
@permission_required("fileshare_pdf.create")
|
||||||
def save_form(token: str):
|
def save_form(token: str):
|
||||||
"""Nimmt ein in der Vorschau ausgefuelltes PDF und legt es daneben ab.
|
"""Nimmt ein in der Vorschau ausgefuelltes PDF und legt es daneben ab.
|
||||||
|
|
||||||
@@ -193,20 +261,50 @@ def save_form(token: str):
|
|||||||
die alte Datei danach loeschen -- das ist eine bewusste Handlung.
|
die alte Datei danach loeschen -- das ist eine bewusste Handlung.
|
||||||
"""
|
"""
|
||||||
mount = _mount_or_404(token)
|
mount = _mount_or_404(token)
|
||||||
relative = request.form.get("pfad") or ""
|
# Das Ziel darf eine andere Freigabe sein: die Vorlage liegt oft in einer
|
||||||
|
# Vorlagenfreigabe, die ausgefuellte Fassung gehoert in die Ablage des
|
||||||
|
# Bereichs. Geprueft wird es wie jede andere Einbindung -- nur eine, die
|
||||||
|
# diesem Benutzer gerade gehoert, kommt in Frage.
|
||||||
|
ziel_token = (request.form.get("ziel") or "").strip()
|
||||||
|
ziel_mount = _mount_or_404(ziel_token) if ziel_token else mount
|
||||||
|
relative = request.form.get("path") or ""
|
||||||
upload_file = request.files.get("datei")
|
upload_file = request.files.get("datei")
|
||||||
if upload_file is None or not upload_file.filename:
|
if upload_file is None or not upload_file.filename:
|
||||||
return {"error": "Es kam keine Datei an."}, 400
|
return {"error": "Es kam keine Datei an."}, 400
|
||||||
|
|
||||||
|
# Ein unterschriebenes Dokument ist abgeschlossen. Es als Vorlage fuer eine
|
||||||
|
# neue Fassung zu benutzen hiesse, eine Unterschrift unter einen anderen
|
||||||
|
# Text zu stellen -- die Oberflaeche blendet das aus, hier wird es
|
||||||
|
# abgewiesen.
|
||||||
|
if service.signaturstand(db(), mount, relative).get("status") in {
|
||||||
|
"unveraendert",
|
||||||
|
"veraendert",
|
||||||
|
}:
|
||||||
|
return {
|
||||||
|
"error": "Dieses Dokument ist unterschrieben und damit abgeschlossen. "
|
||||||
|
"Legen Sie eine neue Fassung aus der ursprünglichen Vorlage an."
|
||||||
|
}, 409
|
||||||
|
|
||||||
grenze = uploads.limit_bytes(db())
|
grenze = uploads.limit_bytes(db())
|
||||||
daten = upload_file.stream.read(grenze + 1)
|
daten = upload_file.stream.read(grenze + 1)
|
||||||
if len(daten) > grenze:
|
if len(daten) > grenze:
|
||||||
return {"error": f"Zu gross (Grenze {grenze // 1024 // 1024} MB)."}, 413
|
return {"error": f"Zu groß (Grenze {grenze // 1024 // 1024} MB)."}, 413
|
||||||
if not daten.startswith(b"%PDF-"):
|
if not daten.startswith(b"%PDF-"):
|
||||||
# Diese Route nimmt nur, was pdf.js hier ablegt. Ein anderer Inhalt
|
# Diese Route nimmt nur, was pdf.js hier ablegt. Ein anderer Inhalt
|
||||||
# waere ein Umweg, um beliebige Dateien in die Freigabe zu bekommen.
|
# waere ein Umweg, um beliebige Dateien in die Freigabe zu bekommen.
|
||||||
return {"error": "Das ist keine PDF-Datei."}, 415
|
return {"error": "Das ist keine PDF-Datei."}, 415
|
||||||
|
|
||||||
|
# Geprueft wird die **Datei**, nicht ein mitgeschicktes Auswahlfeld: wer nur
|
||||||
|
# anlegen darf, legt endgueltige Fassungen ab. Traegt die Datei noch
|
||||||
|
# Formularfelder, ist sie weiter veraenderbar -- dafuer braucht es
|
||||||
|
# "PDF-Formulare: Bearbeiten". Ein umgebogenes Feld im Browser hilft damit
|
||||||
|
# niemandem weiter.
|
||||||
|
if service.hat_formularfelder(daten) and not current_user.has("fileshare_pdf.edit"):
|
||||||
|
return {
|
||||||
|
"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.
|
# Wohin und unter welchem Namen -- beides darf der Benutzer bestimmen.
|
||||||
ordner = (request.form.get("ordner") or "").strip().strip("/")
|
ordner = (request.form.get("ordner") or "").strip().strip("/")
|
||||||
wunschname = (request.form.get("name") or "").strip()
|
wunschname = (request.form.get("name") or "").strip()
|
||||||
@@ -214,15 +312,22 @@ def save_form(token: str):
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
original = service.resolve_path(mount, relative)
|
original = service.resolve_path(mount, relative)
|
||||||
|
# Ohne Namen landet die Datei neben dem Original -- das geht nur in
|
||||||
|
# derselben Freigabe.
|
||||||
|
if ziel_mount.token != mount.token and not wunschname:
|
||||||
|
return {"error": "Für eine andere Freigabe braucht es einen Dateinamen."}, 400
|
||||||
# Mit Namen entscheidet das Ordnerfeld -- leer heisst Wurzelverzeichnis,
|
# Mit Namen entscheidet das Ordnerfeld -- leer heisst Wurzelverzeichnis,
|
||||||
# so wie es im Feld steht. Ohne Namen (Aufruf ohne Oberflaeche) bleibt
|
# so wie es im Feld steht. Ohne Namen (Aufruf ohne Oberflaeche) bleibt
|
||||||
# es beim Ordner des Originals.
|
# es beim Ordner des Originals.
|
||||||
if wunschname:
|
if wunschname:
|
||||||
zielordner = service.resolve_path(mount, ordner)
|
zielordner = service.resolve_path(ziel_mount, ordner)
|
||||||
else:
|
else:
|
||||||
zielordner = service.resolve_path(mount, ordner) if ordner else original.parent
|
zielordner = service.resolve_path(mount, ordner) if ordner else original.parent
|
||||||
if not zielordner.is_dir():
|
if not zielordner.is_dir():
|
||||||
return {"error": f"Den Ordner '{ordner}' gibt es in dieser Freigabe nicht."}, 400
|
return {
|
||||||
|
"error": f"Den Ordner '{ordner}' gibt es in der Freigabe "
|
||||||
|
f"'{ziel_mount.label}' nicht."
|
||||||
|
}, 400
|
||||||
if wunschname:
|
if wunschname:
|
||||||
# Eingegeben wird der Name ohne Endung; die kommt von hier. Wer
|
# Eingegeben wird der Name ohne Endung; die kommt von hier. Wer
|
||||||
# sie mittippt, bekommt sie nicht doppelt -- und wer eine falsche
|
# sie mittippt, bekommt sie nicht doppelt -- und wer eine falsche
|
||||||
@@ -233,8 +338,8 @@ def save_form(token: str):
|
|||||||
ziel = zielordner / name
|
ziel = zielordner / name
|
||||||
if ziel.exists() and not ersetzen:
|
if ziel.exists() and not ersetzen:
|
||||||
return {
|
return {
|
||||||
"error": f"'{name}' gibt es dort schon. Anderen Namen waehlen oder "
|
"error": f"In '{ziel_mount.label}' gibt es '{name}' schon. Anderen "
|
||||||
"'Vorhandene ersetzen' ankreuzen."
|
"Namen wählen oder 'Vorhandene ersetzen' ankreuzen."
|
||||||
}, 409
|
}, 409
|
||||||
else:
|
else:
|
||||||
# Ohne Angabe wie bisher: neben das Original, ohne etwas zu ueberschreiben.
|
# Ohne Angabe wie bisher: neben das Original, ohne etwas zu ueberschreiben.
|
||||||
@@ -243,49 +348,92 @@ def save_form(token: str):
|
|||||||
except service.FileshareError as exc:
|
except service.FileshareError as exc:
|
||||||
return {"error": str(exc)}, 400
|
return {"error": str(exc)}, 400
|
||||||
|
|
||||||
wohin = ziel.relative_to(mount.path.resolve()).as_posix()
|
wohin = ziel.relative_to(ziel_mount.path.resolve()).as_posix()
|
||||||
_audit("fileshare.form_saved", f"{mount.label}:{relative}", wohin, severity="notice")
|
pruefsumme = hashlib.sha256(daten).hexdigest()
|
||||||
return {"message": f"Gespeichert als {wohin}.", "pfad": wohin}
|
unterschriften = _unterschriften(request.form.get("unterschriften") or "")
|
||||||
|
|
||||||
|
# Zwei Angaben, die man nicht verwechseln darf, und deshalb stehen beide
|
||||||
|
# da: der Name **in** der Unterschrift ist eine Angabe des
|
||||||
|
# Unterschreibenden -- wie auf Papier. Wer die Datei abgelegt hat, steht
|
||||||
|
# ohnehin als Handelnder in der Zeile und ist belegt.
|
||||||
|
# Zwei Zeilen: oben, was ein Mensch liest; unten die Pruefsumme, die
|
||||||
|
# niemand liest, aber jeder vergleichen koennen muss. In einer einzigen
|
||||||
|
# Zeile drueckte der Hash den Rest aus der Spalte.
|
||||||
|
kopf = f"{'Signiert' if unterschriften else 'Gespeichert'}: {wohin}"
|
||||||
|
if unterschriften:
|
||||||
|
kopf += f" von: {', '.join(unterschriften)}"
|
||||||
|
beschreibung = f"{kopf}{chr(10)}sha256={pruefsumme}"
|
||||||
|
_audit(
|
||||||
|
"fileshare.form_signed" if unterschriften else "fileshare.form_saved",
|
||||||
|
f"{mount.label}:{relative}",
|
||||||
|
beschreibung,
|
||||||
|
severity="notice",
|
||||||
|
)
|
||||||
|
if unterschriften:
|
||||||
|
# Zusaetzlich ins Signaturverzeichnis: das Protokoll wird irgendwann
|
||||||
|
# archiviert, die Frage "ist diese Datei noch die unterschriebene?"
|
||||||
|
# stellt sich aber noch Jahre spaeter. Und mit fsync -- die Datei liegt
|
||||||
|
# schon auf der Freigabe; ein Stromausfall soll nicht den Nachweis
|
||||||
|
# dazu verschlucken.
|
||||||
|
verbindung = db()
|
||||||
|
with core().database.dauerhaft(verbindung), transaction():
|
||||||
|
service.signatur_merken(
|
||||||
|
verbindung,
|
||||||
|
sha256=pruefsumme,
|
||||||
|
label=ziel_mount.label,
|
||||||
|
pfad=wohin,
|
||||||
|
actor=current_user.username,
|
||||||
|
namen=unterschriften,
|
||||||
|
)
|
||||||
|
# Zweites Exemplar ausserhalb der Datenbank.
|
||||||
|
service.signatur_journalisieren(
|
||||||
|
sha256=pruefsumme,
|
||||||
|
label=ziel_mount.label,
|
||||||
|
pfad=wohin,
|
||||||
|
actor=current_user.username,
|
||||||
|
namen=unterschriften,
|
||||||
|
)
|
||||||
|
return {"message": f"Gespeichert als {wohin}.", "path": wohin, "sha256": pruefsumme}
|
||||||
|
|
||||||
|
|
||||||
@bp.post("/<token>/ordner")
|
@bp.post("/<token>/folder")
|
||||||
@permission_required("fileshare.create")
|
@permission_required("fileshare.create")
|
||||||
def make_directory(token: str):
|
def make_directory(token: str):
|
||||||
mount = _mount_or_404(token)
|
mount = _mount_or_404(token)
|
||||||
relative = request.form.get("pfad") or ""
|
relative = request.form.get("path") or ""
|
||||||
try:
|
try:
|
||||||
service.make_directory(mount, relative, request.form.get("name", ""))
|
service.make_directory(mount, relative, request.form.get("name", ""))
|
||||||
except (service.FileshareError, OSError) as exc:
|
except (service.FileshareError, OSError) as exc:
|
||||||
flash(str(exc), "danger")
|
flash(str(exc), "danger")
|
||||||
return redirect(url_for("fileshare.browse", freigabe=token, pfad=relative))
|
return redirect(url_for("fileshare.browse", share=token, path=relative))
|
||||||
_audit("fileshare.directory_created", f"{mount.label}:{relative}/{request.form.get('name')}")
|
_audit("fileshare.directory_created", f"{mount.label}:{relative}/{request.form.get('name')}")
|
||||||
flash("Ordner angelegt.", "success")
|
flash("Ordner angelegt.", "success")
|
||||||
return redirect(url_for("fileshare.browse", freigabe=token, pfad=relative))
|
return redirect(url_for("fileshare.browse", share=token, path=relative))
|
||||||
|
|
||||||
|
|
||||||
@bp.post("/<token>/umbenennen")
|
@bp.post("/<token>/rename")
|
||||||
@permission_required("fileshare.edit")
|
@permission_required("fileshare.edit")
|
||||||
def rename(token: str):
|
def rename(token: str):
|
||||||
mount = _mount_or_404(token)
|
mount = _mount_or_404(token)
|
||||||
relative = request.form.get("pfad") or ""
|
relative = request.form.get("path") or ""
|
||||||
parent = request.form.get("parent") or ""
|
parent = request.form.get("parent") or ""
|
||||||
try:
|
try:
|
||||||
service.rename_entry(mount, relative, request.form.get("name", ""))
|
service.rename_entry(mount, relative, request.form.get("name", ""))
|
||||||
except (service.FileshareError, OSError) as exc:
|
except (service.FileshareError, OSError) as exc:
|
||||||
flash(str(exc), "danger")
|
flash(str(exc), "danger")
|
||||||
return redirect(url_for("fileshare.browse", freigabe=token, pfad=parent))
|
return redirect(url_for("fileshare.browse", share=token, path=parent))
|
||||||
_audit("fileshare.renamed", f"{mount.label}:{relative}")
|
_audit("fileshare.renamed", f"{mount.label}:{relative}")
|
||||||
flash("Umbenannt.", "success")
|
flash("Umbenannt.", "success")
|
||||||
return redirect(url_for("fileshare.browse", freigabe=token, pfad=parent))
|
return redirect(url_for("fileshare.browse", share=token, path=parent))
|
||||||
|
|
||||||
|
|
||||||
@bp.post("/<token>/loeschen")
|
@bp.post("/<token>/delete")
|
||||||
@permission_required("fileshare.delete")
|
@permission_required("fileshare.delete")
|
||||||
def delete(token: str):
|
def delete(token: str):
|
||||||
mount = _mount_or_404(token)
|
mount = _mount_or_404(token)
|
||||||
parent = request.form.get("parent") or ""
|
parent = request.form.get("parent") or ""
|
||||||
removed = 0
|
removed = 0
|
||||||
for relative in request.form.getlist("pfade"):
|
for relative in request.form.getlist("paths"):
|
||||||
try:
|
try:
|
||||||
kind = service.delete_entry(mount, relative)
|
kind = service.delete_entry(mount, relative)
|
||||||
_audit("fileshare.deleted", f"{mount.label}:{relative}", kind, severity="warning")
|
_audit("fileshare.deleted", f"{mount.label}:{relative}", kind, severity="warning")
|
||||||
@@ -293,18 +441,18 @@ def delete(token: str):
|
|||||||
except (service.FileshareError, OSError) as exc:
|
except (service.FileshareError, OSError) as exc:
|
||||||
flash(str(exc), "danger")
|
flash(str(exc), "danger")
|
||||||
if removed:
|
if removed:
|
||||||
flash(f"{removed} Eintrag/Eintraege geloescht.", "success")
|
flash(f"{removed} Eintrag/Einträge gelöscht.", "success")
|
||||||
return redirect(url_for("fileshare.browse", freigabe=token, pfad=parent))
|
return redirect(url_for("fileshare.browse", share=token, path=parent))
|
||||||
|
|
||||||
|
|
||||||
@bp.post("/<token>/freigeben")
|
@bp.post("/<token>/release")
|
||||||
@permission_required("fileshare.view")
|
@permission_required("fileshare.view")
|
||||||
def release(token: str):
|
def release(token: str):
|
||||||
mount = _mount_or_404(token)
|
mount = _mount_or_404(token)
|
||||||
with transaction() as conn:
|
with transaction() as conn:
|
||||||
service.release_mount(conn, mount.id)
|
service.release_mount(conn, mount.id)
|
||||||
_audit("fileshare.released", mount.label)
|
_audit("fileshare.released", mount.label)
|
||||||
flash("Einbindung geloest.", "success")
|
flash("Einbindung gelöst.", "success")
|
||||||
return redirect(url_for("fileshare.browse"))
|
return redirect(url_for("fileshare.browse"))
|
||||||
|
|
||||||
|
|
||||||
@@ -313,7 +461,7 @@ def release(token: str):
|
|||||||
# --------------------------------------------------------------------------- #
|
# --------------------------------------------------------------------------- #
|
||||||
|
|
||||||
|
|
||||||
@bp.get("/zuordnungen")
|
@bp.get("/mappings")
|
||||||
@permission_required("fileshare.edit")
|
@permission_required("fileshare.edit")
|
||||||
def mappings():
|
def mappings():
|
||||||
from tesm_core.auth import directory
|
from tesm_core.auth import directory
|
||||||
@@ -327,7 +475,7 @@ def mappings():
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@bp.post("/zuordnungen")
|
@bp.post("/mappings")
|
||||||
@permission_required("fileshare.edit")
|
@permission_required("fileshare.edit")
|
||||||
def mapping_add():
|
def mapping_add():
|
||||||
try:
|
try:
|
||||||
@@ -339,18 +487,33 @@ def mapping_add():
|
|||||||
label=request.form.get("label", ""),
|
label=request.form.get("label", ""),
|
||||||
unc=request.form.get("unc", ""),
|
unc=request.form.get("unc", ""),
|
||||||
)
|
)
|
||||||
_audit("fileshare.mapping_added", request.form.get("label", ""), severity="notice")
|
_audit(
|
||||||
|
"fileshare.mapping_added",
|
||||||
|
request.form.get("label", ""),
|
||||||
|
service.describe_mapping(
|
||||||
|
{
|
||||||
|
"unc": request.form.get("unc", ""),
|
||||||
|
"ad_group_name": request.form.get("ad_group_name", ""),
|
||||||
|
"ad_group_dn": request.form.get("ad_group_dn", ""),
|
||||||
|
}
|
||||||
|
),
|
||||||
|
severity="notice",
|
||||||
|
)
|
||||||
flash("Zuordnung gespeichert.", "success")
|
flash("Zuordnung gespeichert.", "success")
|
||||||
except service.FileshareError as exc:
|
except service.FileshareError as exc:
|
||||||
flash(str(exc), "danger")
|
flash(str(exc), "danger")
|
||||||
return redirect(url_for("fileshare.mappings"))
|
return redirect(url_for("fileshare.mappings"))
|
||||||
|
|
||||||
|
|
||||||
@bp.post("/zuordnungen/<int:mapping_id>")
|
@bp.post("/mappings/<int:mapping_id>")
|
||||||
@permission_required("fileshare.edit")
|
@permission_required("fileshare.edit")
|
||||||
def mapping_update(mapping_id: int):
|
def mapping_update(mapping_id: int):
|
||||||
try:
|
try:
|
||||||
with transaction() as conn:
|
with transaction() as conn:
|
||||||
|
# Den Stand vor der Aenderung merken -- danach ist er weg, und
|
||||||
|
# "geaendert" ohne "was" beantwortet keine einzige Frage, die man
|
||||||
|
# spaeter an ein Protokoll stellt.
|
||||||
|
vorher = service.get_mapping(conn, mapping_id)
|
||||||
service.update_mapping(
|
service.update_mapping(
|
||||||
conn,
|
conn,
|
||||||
mapping_id,
|
mapping_id,
|
||||||
@@ -359,18 +522,32 @@ def mapping_update(mapping_id: int):
|
|||||||
label=request.form.get("label", ""),
|
label=request.form.get("label", ""),
|
||||||
unc=request.form.get("unc", ""),
|
unc=request.form.get("unc", ""),
|
||||||
)
|
)
|
||||||
_audit("fileshare.mapping_updated", request.form.get("label", ""), severity="notice")
|
nachher = service.get_mapping(conn, mapping_id)
|
||||||
flash("Zuordnung geaendert.", "success")
|
_audit(
|
||||||
|
"fileshare.mapping_updated",
|
||||||
|
str((nachher or {}).get("label") or request.form.get("label", "")),
|
||||||
|
service.mapping_changes(vorher, nachher) or "keine inhaltliche Änderung",
|
||||||
|
severity="notice",
|
||||||
|
)
|
||||||
|
flash("Zuordnung geändert.", "success")
|
||||||
except service.FileshareError as exc:
|
except service.FileshareError as exc:
|
||||||
flash(str(exc), "danger")
|
flash(str(exc), "danger")
|
||||||
return redirect(url_for("fileshare.mappings"))
|
return redirect(url_for("fileshare.mappings"))
|
||||||
|
|
||||||
|
|
||||||
@bp.post("/zuordnungen/<int:mapping_id>/loeschen")
|
@bp.post("/mappings/<int:mapping_id>/delete")
|
||||||
@permission_required("fileshare.edit")
|
@permission_required("fileshare.edit")
|
||||||
def mapping_delete(mapping_id: int):
|
def mapping_delete(mapping_id: int):
|
||||||
with transaction() as conn:
|
with transaction() as conn:
|
||||||
|
entfernt = service.get_mapping(conn, mapping_id)
|
||||||
service.delete_mapping(conn, mapping_id)
|
service.delete_mapping(conn, mapping_id)
|
||||||
_audit("fileshare.mapping_deleted", str(mapping_id), severity="notice")
|
# Eine entfernte Zuordnung nimmt allen Mitgliedern der Gruppe den Zugang
|
||||||
|
# zu dieser Freigabe -- das ist keine Notiz am Rande, sondern eine Warnung.
|
||||||
|
_audit(
|
||||||
|
"fileshare.mapping_deleted",
|
||||||
|
str((entfernt or {}).get("label") or mapping_id),
|
||||||
|
service.describe_mapping(entfernt),
|
||||||
|
severity="warning",
|
||||||
|
)
|
||||||
flash("Zuordnung entfernt.", "success")
|
flash("Zuordnung entfernt.", "success")
|
||||||
return redirect(url_for("fileshare.mappings"))
|
return redirect(url_for("fileshare.mappings"))
|
||||||
@@ -31,7 +31,7 @@ from tesm_core.extension import core, db, transaction
|
|||||||
|
|
||||||
from ..services import inventory, ssh
|
from ..services import inventory, ssh
|
||||||
|
|
||||||
bp = Blueprint("hostkeys", __name__, url_prefix="/hostschluessel")
|
bp = Blueprint("hostkeys", __name__, url_prefix="/monitoring/host-keys")
|
||||||
|
|
||||||
KIND_DEVICE = "client"
|
KIND_DEVICE = "client"
|
||||||
KIND_SWITCH = "switch"
|
KIND_SWITCH = "switch"
|
||||||
@@ -142,7 +142,7 @@ def _require(kind: str) -> None:
|
|||||||
abort(403)
|
abort(403)
|
||||||
|
|
||||||
|
|
||||||
@bp.post("/<kind>/<int:target_id>/pruefen")
|
@bp.post("/<kind>/<int:target_id>/probe")
|
||||||
@permission_required("devices.view", "switches.view", mode="any")
|
@permission_required("devices.view", "switches.view", mode="any")
|
||||||
def probe(kind: str, target_id: int):
|
def probe(kind: str, target_id: int):
|
||||||
conn = db()
|
conn = db()
|
||||||
@@ -169,7 +169,7 @@ def probe(kind: str, target_id: int):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@bp.post("/<kind>/<int:target_id>/freigeben")
|
@bp.post("/<kind>/<int:target_id>/trust")
|
||||||
@permission_required("devices.view", "switches.view", mode="any")
|
@permission_required("devices.view", "switches.view", mode="any")
|
||||||
@reauth_required
|
@reauth_required
|
||||||
def trust(kind: str, target_id: int):
|
def trust(kind: str, target_id: int):
|
||||||
@@ -181,7 +181,7 @@ def trust(kind: str, target_id: int):
|
|||||||
key_type = (request.form.get("key_type") or "").strip()
|
key_type = (request.form.get("key_type") or "").strip()
|
||||||
fingerprint = (request.form.get("fingerprint") or "").strip()
|
fingerprint = (request.form.get("fingerprint") or "").strip()
|
||||||
if not key_type or not ssh.valid_fingerprint(fingerprint):
|
if not key_type or not ssh.valid_fingerprint(fingerprint):
|
||||||
flash("Es wurde kein gueltiger Fingerprint uebergeben.", "danger")
|
flash("Es wurde kein gültiger Fingerprint übergeben.", "danger")
|
||||||
return redirect(url_for("hostkeys.overview"))
|
return redirect(url_for("hostkeys.overview"))
|
||||||
|
|
||||||
with transaction():
|
with transaction():
|
||||||
@@ -199,11 +199,11 @@ def trust(kind: str, target_id: int):
|
|||||||
f"{key_type} {fingerprint}",
|
f"{key_type} {fingerprint}",
|
||||||
severity="notice",
|
severity="notice",
|
||||||
)
|
)
|
||||||
flash(f"Host-Schluessel von {target['label']} freigegeben.", "success")
|
flash(f"Host-Schlüssel von {target['label']} freigegeben.", "success")
|
||||||
return redirect(url_for("hostkeys.overview"))
|
return redirect(url_for("hostkeys.overview"))
|
||||||
|
|
||||||
|
|
||||||
@bp.post("/<kind>/<int:target_id>/verwerfen")
|
@bp.post("/<kind>/<int:target_id>/forget")
|
||||||
@permission_required("devices.view", "switches.view", mode="any")
|
@permission_required("devices.view", "switches.view", mode="any")
|
||||||
def forget(kind: str, target_id: int):
|
def forget(kind: str, target_id: int):
|
||||||
conn = db()
|
conn = db()
|
||||||
@@ -218,11 +218,11 @@ def forget(kind: str, target_id: int):
|
|||||||
target["label"],
|
target["label"],
|
||||||
severity="warning",
|
severity="warning",
|
||||||
)
|
)
|
||||||
flash(f"Gespeicherte Schluessel von {target['label']} entfernt.", "info")
|
flash(f"Gespeicherte Schlüssel von {target['label']} entfernt.", "info")
|
||||||
return redirect(url_for("hostkeys.overview"))
|
return redirect(url_for("hostkeys.overview"))
|
||||||
|
|
||||||
|
|
||||||
@bp.post("/verwaist/verwerfen")
|
@bp.post("/orphaned/forget")
|
||||||
@permission_required("devices.edit", "switches.edit", mode="any")
|
@permission_required("devices.edit", "switches.edit", mode="any")
|
||||||
def forget_orphan():
|
def forget_orphan():
|
||||||
"""Entfernt eine Freigabe, zu der es kein Ziel mehr gibt."""
|
"""Entfernt eine Freigabe, zu der es kein Ziel mehr gibt."""
|
||||||
@@ -232,18 +232,18 @@ def forget_orphan():
|
|||||||
except ValueError:
|
except ValueError:
|
||||||
port = 0
|
port = 0
|
||||||
if not host or not 1 <= port <= 65535:
|
if not host or not 1 <= port <= 65535:
|
||||||
flash("Ungueltiges Ziel.", "danger")
|
flash("Ungültiges Ziel.", "danger")
|
||||||
return redirect(url_for("hostkeys.overview"))
|
return redirect(url_for("hostkeys.overview"))
|
||||||
|
|
||||||
conn = db()
|
conn = db()
|
||||||
if (host, port) in {(row["host"], row["port"]) for row in _targets(conn)}:
|
if (host, port) in {(row["host"], row["port"]) for row in _targets(conn)}:
|
||||||
# Ein Ziel, das es noch gibt, wird ueber seine eigene Zeile verworfen --
|
# Ein Ziel, das es noch gibt, wird ueber seine eigene Zeile verworfen --
|
||||||
# sonst umginge dieser Weg die Rechtepruefung je Objektart.
|
# sonst umginge dieser Weg die Rechtepruefung je Objektart.
|
||||||
flash("Zu diesem Eintrag gibt es ein Ziel -- bitte dort verwerfen.", "warning")
|
flash("Zu diesem Eintrag gibt es ein Ziel – bitte dort verwerfen.", "warning")
|
||||||
return redirect(url_for("hostkeys.overview"))
|
return redirect(url_for("hostkeys.overview"))
|
||||||
|
|
||||||
with transaction():
|
with transaction():
|
||||||
ssh.forget_key(conn, host, port)
|
ssh.forget_key(conn, host, port)
|
||||||
_audit("host_key.orphan_forgotten", f"{host}:{port}", severity="warning")
|
_audit("host_key.orphan_forgotten", f"{host}:{port}", severity="warning")
|
||||||
flash(f"Verwaiste Freigabe fuer {host}:{port} entfernt.", "info")
|
flash(f"Verwaiste Freigabe für {host}:{port} entfernt.", "info")
|
||||||
return redirect(url_for("hostkeys.overview"))
|
return redirect(url_for("hostkeys.overview"))
|
||||||
@@ -23,7 +23,7 @@ from tesm_core.licensing_client import LICENSED_FEATURES, LicenseError
|
|||||||
|
|
||||||
import tesm_licensing as lic
|
import tesm_licensing as lic
|
||||||
|
|
||||||
bp = Blueprint("license", __name__, url_prefix="/lizenz")
|
bp = Blueprint("license", __name__, url_prefix="/administration/license")
|
||||||
|
|
||||||
MAX_UPLOAD_BYTES = 256 * 1024
|
MAX_UPLOAD_BYTES = 256 * 1024
|
||||||
|
|
||||||
@@ -69,7 +69,7 @@ def overview():
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@bp.post("/einspielen")
|
@bp.post("/install")
|
||||||
@permission_required("settings_license.edit")
|
@permission_required("settings_license.edit")
|
||||||
def upload():
|
def upload():
|
||||||
extension = core()
|
extension = core()
|
||||||
@@ -80,11 +80,11 @@ def upload():
|
|||||||
|
|
||||||
upload_file = request.files.get("license_file")
|
upload_file = request.files.get("license_file")
|
||||||
if upload_file is None or not upload_file.filename:
|
if upload_file is None or not upload_file.filename:
|
||||||
flash("Keine Datei ausgewaehlt.", "danger")
|
flash("Keine Datei ausgewählt.", "danger")
|
||||||
return redirect(url_for("license.overview"))
|
return redirect(url_for("license.overview"))
|
||||||
raw = upload_file.stream.read(MAX_UPLOAD_BYTES + 1)
|
raw = upload_file.stream.read(MAX_UPLOAD_BYTES + 1)
|
||||||
if len(raw) > MAX_UPLOAD_BYTES:
|
if len(raw) > MAX_UPLOAD_BYTES:
|
||||||
flash("Die Datei ist zu gross fuer eine Lizenzdatei.", "danger")
|
flash("Die Datei ist zu groß für eine Lizenzdatei.", "danger")
|
||||||
return redirect(url_for("license.overview"))
|
return redirect(url_for("license.overview"))
|
||||||
|
|
||||||
allow_new_issuer = request.form.get("allow_new_issuer") == "1" and current_user.is_admin
|
allow_new_issuer = request.form.get("allow_new_issuer") == "1" and current_user.is_admin
|
||||||
@@ -104,19 +104,19 @@ def upload():
|
|||||||
if state.status and state.status.role != "customer":
|
if state.status and state.status.role != "customer":
|
||||||
flash(
|
flash(
|
||||||
f"Achtung: diese Lizenz hat die Rolle {state.status.role!r}. "
|
f"Achtung: diese Lizenz hat die Rolle {state.status.role!r}. "
|
||||||
"In einer TESM-Kundeninstanz ist das ungewoehnlich.",
|
"In einer TESM-Kundeninstanz ist das ungewöhnlich.",
|
||||||
"warning",
|
"warning",
|
||||||
)
|
)
|
||||||
return redirect(url_for("license.overview"))
|
return redirect(url_for("license.overview"))
|
||||||
|
|
||||||
|
|
||||||
@bp.post("/aktivieren")
|
@bp.post("/activate")
|
||||||
@permission_required("settings_license.edit")
|
@permission_required("settings_license.edit")
|
||||||
def activate():
|
def activate():
|
||||||
return _handshake("activate")
|
return _handshake("activate")
|
||||||
|
|
||||||
|
|
||||||
@bp.post("/deaktivieren")
|
@bp.post("/deactivate")
|
||||||
@permission_required("settings_license.edit")
|
@permission_required("settings_license.edit")
|
||||||
@reauth_required
|
@reauth_required
|
||||||
def deactivate():
|
def deactivate():
|
||||||
@@ -158,7 +158,7 @@ def _handshake(action: str):
|
|||||||
return redirect(url_for("license.overview"))
|
return redirect(url_for("license.overview"))
|
||||||
|
|
||||||
|
|
||||||
@bp.post("/offline-antwort")
|
@bp.post("/offline-response")
|
||||||
@permission_required("settings_license.edit")
|
@permission_required("settings_license.edit")
|
||||||
def offline_response():
|
def offline_response():
|
||||||
extension = core()
|
extension = core()
|
||||||
@@ -177,7 +177,7 @@ def offline_response():
|
|||||||
return redirect(url_for("license.overview"))
|
return redirect(url_for("license.overview"))
|
||||||
|
|
||||||
|
|
||||||
@bp.post("/serverzertifikat")
|
@bp.post("/server-certificate")
|
||||||
@permission_required("settings_license.edit")
|
@permission_required("settings_license.edit")
|
||||||
def server_certificate():
|
def server_certificate():
|
||||||
"""Hinterlegt das Zertifikat des Lizenzservers.
|
"""Hinterlegt das Zertifikat des Lizenzservers.
|
||||||
@@ -214,15 +214,15 @@ def server_certificate():
|
|||||||
severity="notice",
|
severity="notice",
|
||||||
)
|
)
|
||||||
flash(
|
flash(
|
||||||
"Zertifikat hinterlegt. Der naechste Versuch laeuft wieder ueber den Online-Weg."
|
"Zertifikat hinterlegt. Der nächste Versuch läuft wieder über den Online-Weg."
|
||||||
if pem
|
if pem
|
||||||
else "Zertifikat entfernt -- es gelten wieder die Systemanker.",
|
else "Zertifikat entfernt – es gelten wieder die Systemanker.",
|
||||||
"success",
|
"success",
|
||||||
)
|
)
|
||||||
return redirect(url_for("license.overview"))
|
return redirect(url_for("license.overview"))
|
||||||
|
|
||||||
|
|
||||||
@bp.post("/aussteller/<key_id>/entfernen")
|
@bp.post("/issuer/<key_id>/remove")
|
||||||
@admin_required
|
@admin_required
|
||||||
@reauth_required
|
@reauth_required
|
||||||
def remove_trusted_key(key_id: str):
|
def remove_trusted_key(key_id: str):
|
||||||
@@ -246,5 +246,5 @@ def remove_trusted_key(key_id: str):
|
|||||||
if manager is not None:
|
if manager is not None:
|
||||||
with core().database.session() as conn:
|
with core().database.session() as conn:
|
||||||
manager.reload(conn)
|
manager.reload(conn)
|
||||||
flash("Aussteller-Schluessel entfernt.", "info")
|
flash("Aussteller-Schlüssel entfernt.", "info")
|
||||||
return redirect(url_for("license.overview"))
|
return redirect(url_for("license.overview"))
|
||||||
@@ -13,12 +13,12 @@ from tesm_core.extension import core, db
|
|||||||
|
|
||||||
from ..services import kea, monitor
|
from ..services import kea, monitor
|
||||||
|
|
||||||
bp = Blueprint("tesmlogs", __name__, url_prefix="/protokolle")
|
bp = Blueprint("tesmlogs", __name__, url_prefix="/logs")
|
||||||
|
|
||||||
MAX_TAIL_LINES = 2000
|
MAX_TAIL_LINES = 2000
|
||||||
|
|
||||||
|
|
||||||
@bp.get("/kea")
|
@bp.get("/dhcp")
|
||||||
@permission_required("logs_dhcp.view")
|
@permission_required("logs_dhcp.view")
|
||||||
def dhcp():
|
def dhcp():
|
||||||
extension = core()
|
extension = core()
|
||||||
@@ -34,11 +34,11 @@ def dhcp():
|
|||||||
else:
|
else:
|
||||||
error = result.message
|
error = result.message
|
||||||
else:
|
else:
|
||||||
error = "Systemhelfer nicht verfuegbar."
|
error = "Systemhelfer nicht verfügbar."
|
||||||
return render_template("tesm/logs_kea.html", lines=lines, path=kea.LOG_PATH, error=error)
|
return render_template("tesm/logs_kea.html", lines=lines, path=kea.LOG_PATH, error=error)
|
||||||
|
|
||||||
|
|
||||||
@bp.get("/neustarts")
|
@bp.get("/restarts")
|
||||||
@permission_required("devices.view")
|
@permission_required("devices.view")
|
||||||
def restarts():
|
def restarts():
|
||||||
"""Alle Neustarts -- ueber den PoE-Port und per Befehl aus der Wartung.
|
"""Alle Neustarts -- ueber den PoE-Port und per Befehl aus der Wartung.
|
||||||
@@ -48,7 +48,7 @@ def restarts():
|
|||||||
der Neustart-Historie vollstaendig -- obwohl genau dort jemand nachsieht,
|
der Neustart-Historie vollstaendig -- obwohl genau dort jemand nachsieht,
|
||||||
der wissen will, warum ein Geraet weg war.
|
der wissen will, warum ein Geraet weg war.
|
||||||
"""
|
"""
|
||||||
method = (request.args.get("methode") or "").strip()
|
method = (request.args.get("method") or "").strip()
|
||||||
if method and method not in monitor.METHOD_LABELS:
|
if method and method not in monitor.METHOD_LABELS:
|
||||||
method = ""
|
method = ""
|
||||||
sql = (
|
sql = (
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ from tesm_core.extension import core, db, transaction
|
|||||||
|
|
||||||
from ..services import inventory, maintenance as service, ssh, windowsops, monitor
|
from ..services import inventory, maintenance as service, ssh, windowsops, monitor
|
||||||
|
|
||||||
bp = Blueprint("maintenance", __name__, url_prefix="/wartung")
|
bp = Blueprint("maintenance", __name__, url_prefix="/maintenance")
|
||||||
|
|
||||||
|
|
||||||
@bp.before_request
|
@bp.before_request
|
||||||
@@ -85,7 +85,7 @@ def overview():
|
|||||||
category_labels=dict(inventory.CREDENTIAL_CATEGORIES),
|
category_labels=dict(inventory.CREDENTIAL_CATEGORIES),
|
||||||
category_effects=inventory.CATEGORY_EFFECTS,
|
category_effects=inventory.CATEGORY_EFFECTS,
|
||||||
update_category=inventory.MAINTENANCE_CATEGORY,
|
update_category=inventory.MAINTENANCE_CATEGORY,
|
||||||
terminal_enabled=bool(core().settings.get(conn, "terminal_enabled")),
|
terminal_enabled=bool(core().settings.get(conn, "terminal_clients_enabled")),
|
||||||
windows_tool_missing=_windows_tool_missing(conn),
|
windows_tool_missing=_windows_tool_missing(conn),
|
||||||
windows_package=windowsops.REQUIRED_PACKAGE,
|
windows_package=windowsops.REQUIRED_PACKAGE,
|
||||||
)
|
)
|
||||||
@@ -105,7 +105,7 @@ def _windows_tool_missing(conn) -> bool: # type: ignore[no-untyped-def]
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@bp.post("/paket-nachinstallieren")
|
@bp.post("/install-package")
|
||||||
@permission_required("maintenance.execute")
|
@permission_required("maintenance.execute")
|
||||||
def install_windows_tool():
|
def install_windows_tool():
|
||||||
"""Installiert das Paket, das der Fehlermeldung nach fehlt.
|
"""Installiert das Paket, das der Fehlermeldung nach fehlt.
|
||||||
@@ -116,7 +116,7 @@ def install_windows_tool():
|
|||||||
"""
|
"""
|
||||||
sysops = core().sysops
|
sysops = core().sysops
|
||||||
if sysops is None or not sysops.available:
|
if sysops is None or not sysops.available:
|
||||||
flash("Systemaktionen sind in dieser Umgebung nicht verfuegbar.", "warning")
|
flash("Systemaktionen sind in dieser Umgebung nicht verfügbar.", "warning")
|
||||||
return redirect(url_for("maintenance.overview"))
|
return redirect(url_for("maintenance.overview"))
|
||||||
|
|
||||||
result = sysops.apt_install(windowsops.REQUIRED_PACKAGE)
|
result = sysops.apt_install(windowsops.REQUIRED_PACKAGE)
|
||||||
@@ -165,7 +165,7 @@ def status():
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@bp.get("/auftrag/<job_id>")
|
@bp.get("/job/<job_id>")
|
||||||
@permission_required("maintenance.view", "maintenance.execute", mode="any")
|
@permission_required("maintenance.view", "maintenance.execute", mode="any")
|
||||||
def job_detail(job_id: str):
|
def job_detail(job_id: str):
|
||||||
conn = db()
|
conn = db()
|
||||||
@@ -175,7 +175,7 @@ def job_detail(job_id: str):
|
|||||||
return render_template("tesm/maintenance_job.html", job=job)
|
return render_template("tesm/maintenance_job.html", job=job)
|
||||||
|
|
||||||
|
|
||||||
@bp.get("/auftrag/<job_id>/ausgabe")
|
@bp.get("/job/<job_id>/output")
|
||||||
@permission_required("maintenance.view", "maintenance.execute", mode="any")
|
@permission_required("maintenance.view", "maintenance.execute", mode="any")
|
||||||
@fragment
|
@fragment
|
||||||
def job_output(job_id: str):
|
def job_output(job_id: str):
|
||||||
@@ -191,7 +191,7 @@ def _start(kind: str) -> None:
|
|||||||
conn = db()
|
conn = db()
|
||||||
device_ids = [int(value) for value in request.form.getlist("device_ids") if value.isdigit()]
|
device_ids = [int(value) for value in request.form.getlist("device_ids") if value.isdigit()]
|
||||||
if not device_ids:
|
if not device_ids:
|
||||||
flash("Es wurde kein Geraet ausgewaehlt.", "warning")
|
flash("Es wurde kein Gerät ausgewählt.", "warning")
|
||||||
return
|
return
|
||||||
|
|
||||||
eligible = {int(row["id"]): row for row in _eligible_devices(conn)}
|
eligible = {int(row["id"]): row for row in _eligible_devices(conn)}
|
||||||
@@ -204,8 +204,8 @@ def _start(kind: str) -> None:
|
|||||||
# zurueck, der nie gestartet wird und ewig auf "wartet" steht.
|
# zurueck, der nie gestartet wird und ewig auf "wartet" steht.
|
||||||
if kind == service.JOB_KIND_UPDATE and str(device["category"]) != "linux":
|
if kind == service.JOB_KIND_UPDATE and str(device["category"]) != "linux":
|
||||||
flash(
|
flash(
|
||||||
f"{device['name']}: Paketaktualisierung gibt es nur fuer Linux-Clients. "
|
f"{device['name']}: Paketaktualisierung gibt es nur für Linux-Clients. "
|
||||||
"Windows-Geraete lassen sich hier nur neu starten.",
|
"Windows-Geräte lassen sich hier nur neu starten.",
|
||||||
"warning",
|
"warning",
|
||||||
)
|
)
|
||||||
continue
|
continue
|
||||||
@@ -273,17 +273,17 @@ def _start(kind: str) -> None:
|
|||||||
started += 1
|
started += 1
|
||||||
|
|
||||||
if started:
|
if started:
|
||||||
flash(f"{started} Auftrag/Auftraege gestartet.", "success")
|
flash(f"{started} Auftrag/Aufträge gestartet.", "success")
|
||||||
|
|
||||||
|
|
||||||
@bp.post("/aktualisieren")
|
@bp.post("/update")
|
||||||
@permission_required("maintenance.execute")
|
@permission_required("maintenance.execute")
|
||||||
def start_update():
|
def start_update():
|
||||||
_start(service.JOB_KIND_UPDATE)
|
_start(service.JOB_KIND_UPDATE)
|
||||||
return redirect(url_for("maintenance.overview"))
|
return redirect(url_for("maintenance.overview"))
|
||||||
|
|
||||||
|
|
||||||
@bp.post("/neustarten")
|
@bp.post("/restart")
|
||||||
@permission_required("maintenance.execute")
|
@permission_required("maintenance.execute")
|
||||||
def start_reboot():
|
def start_reboot():
|
||||||
_start(service.JOB_KIND_REBOOT)
|
_start(service.JOB_KIND_REBOOT)
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ from tesm_core.auth.service import client_ip
|
|||||||
from tesm_core.extension import core, db, transaction
|
from tesm_core.extension import core, db, transaction
|
||||||
from tesm_core.licensing_client import LicenseError
|
from tesm_core.licensing_client import LicenseError
|
||||||
|
|
||||||
bp = Blueprint("settings", __name__, url_prefix="/einstellungen")
|
bp = Blueprint("settings", __name__, url_prefix="/administration")
|
||||||
|
|
||||||
|
|
||||||
def _audit(action: str, target: str = "", detail: str = "", severity: str = "info") -> None:
|
def _audit(action: str, target: str = "", detail: str = "", severity: str = "info") -> None:
|
||||||
@@ -79,9 +79,13 @@ def system():
|
|||||||
"monitor_failures_before_restart",
|
"monitor_failures_before_restart",
|
||||||
"monitor_auto_restart",
|
"monitor_auto_restart",
|
||||||
"dashboard_public",
|
"dashboard_public",
|
||||||
"terminal_enabled",
|
"terminal_switches_enabled",
|
||||||
|
"terminal_clients_enabled",
|
||||||
"terminal_idle_timeout",
|
"terminal_idle_timeout",
|
||||||
|
"signature_witness_enabled",
|
||||||
"trash_retention_days",
|
"trash_retention_days",
|
||||||
|
"session_idle_minutes",
|
||||||
|
"session_absolute_hours",
|
||||||
"log_rotate_interval",
|
"log_rotate_interval",
|
||||||
"log_rotate_keep",
|
"log_rotate_keep",
|
||||||
],
|
],
|
||||||
@@ -92,6 +96,7 @@ def system():
|
|||||||
timezones=_timezone_choices(),
|
timezones=_timezone_choices(),
|
||||||
nav_items=extension.nav.items,
|
nav_items=extension.nav.items,
|
||||||
nav_order=extension.settings.get(conn, "nav_order") or extension.nav.default_order(),
|
nav_order=extension.settings.get(conn, "nav_order") or extension.nav.default_order(),
|
||||||
|
nav_child_order=extension.settings.get(conn, "nav_child_order") or {},
|
||||||
license_hint=_license_hint("monitor_interval"),
|
license_hint=_license_hint("monitor_interval"),
|
||||||
sysops_available=bool(sysops and sysops.available),
|
sysops_available=bool(sysops and sysops.available),
|
||||||
)
|
)
|
||||||
@@ -109,9 +114,13 @@ def system_save():
|
|||||||
"monitor_failures_before_restart",
|
"monitor_failures_before_restart",
|
||||||
"monitor_auto_restart",
|
"monitor_auto_restart",
|
||||||
"dashboard_public",
|
"dashboard_public",
|
||||||
"terminal_enabled",
|
"terminal_switches_enabled",
|
||||||
|
"terminal_clients_enabled",
|
||||||
"terminal_idle_timeout",
|
"terminal_idle_timeout",
|
||||||
|
"signature_witness_enabled",
|
||||||
"trash_retention_days",
|
"trash_retention_days",
|
||||||
|
"session_idle_minutes",
|
||||||
|
"session_absolute_hours",
|
||||||
"log_rotate_interval",
|
"log_rotate_interval",
|
||||||
"log_rotate_keep",
|
"log_rotate_keep",
|
||||||
):
|
):
|
||||||
@@ -151,10 +160,10 @@ def system_save():
|
|||||||
if changed:
|
if changed:
|
||||||
message = "Einstellungen gespeichert."
|
message = "Einstellungen gespeichert."
|
||||||
if interval_changed:
|
if interval_changed:
|
||||||
message += " Das Pruefintervall greift beim naechsten Durchlauf."
|
message += " Das Prüfintervall greift beim nächsten Durchlauf."
|
||||||
flash(message, "success")
|
flash(message, "success")
|
||||||
else:
|
else:
|
||||||
flash("Keine Aenderungen.", "info")
|
flash("Keine Änderungen.", "info")
|
||||||
return redirect(url_for("settings.system"))
|
return redirect(url_for("settings.system"))
|
||||||
|
|
||||||
|
|
||||||
@@ -165,10 +174,10 @@ def set_hostname():
|
|||||||
sysops = extension.sysops
|
sysops = extension.sysops
|
||||||
hostname = (request.form.get("hostname") or "").strip()
|
hostname = (request.form.get("hostname") or "").strip()
|
||||||
if not hostname or len(hostname) > 63 or not hostname.replace("-", "").isalnum():
|
if not hostname or len(hostname) > 63 or not hostname.replace("-", "").isalnum():
|
||||||
flash("Der Hostname ist ungueltig.", "danger")
|
flash("Der Hostname ist ungültig.", "danger")
|
||||||
return redirect(url_for("settings.system"))
|
return redirect(url_for("settings.system"))
|
||||||
if sysops is None or not sysops.available:
|
if sysops is None or not sysops.available:
|
||||||
flash("Systemaktionen sind in dieser Umgebung nicht verfuegbar.", "warning")
|
flash("Systemaktionen sind in dieser Umgebung nicht verfügbar.", "warning")
|
||||||
return redirect(url_for("settings.system"))
|
return redirect(url_for("settings.system"))
|
||||||
result = sysops.set_hostname(hostname)
|
result = sysops.set_hostname(hostname)
|
||||||
if result.ok:
|
if result.ok:
|
||||||
@@ -181,7 +190,7 @@ def set_hostname():
|
|||||||
return redirect(url_for("settings.system"))
|
return redirect(url_for("settings.system"))
|
||||||
|
|
||||||
|
|
||||||
@bp.post("/system/zeitzone")
|
@bp.post("/system/timezone")
|
||||||
@permission_required("settings_system.execute")
|
@permission_required("settings_system.execute")
|
||||||
def set_timezone():
|
def set_timezone():
|
||||||
extension = core()
|
extension = core()
|
||||||
@@ -191,7 +200,7 @@ def set_timezone():
|
|||||||
flash("Unbekannte Zeitzone.", "danger")
|
flash("Unbekannte Zeitzone.", "danger")
|
||||||
return redirect(url_for("settings.system"))
|
return redirect(url_for("settings.system"))
|
||||||
if sysops is None or not sysops.available:
|
if sysops is None or not sysops.available:
|
||||||
flash("Systemaktionen sind in dieser Umgebung nicht verfuegbar.", "warning")
|
flash("Systemaktionen sind in dieser Umgebung nicht verfügbar.", "warning")
|
||||||
return redirect(url_for("settings.system"))
|
return redirect(url_for("settings.system"))
|
||||||
result = sysops.set_timezone(timezone)
|
result = sysops.set_timezone(timezone)
|
||||||
if result.ok:
|
if result.ok:
|
||||||
@@ -210,7 +219,7 @@ def set_timezone():
|
|||||||
return redirect(url_for("settings.system"))
|
return redirect(url_for("settings.system"))
|
||||||
|
|
||||||
|
|
||||||
@bp.post("/system/pruefung-jetzt")
|
@bp.post("/system/check-now")
|
||||||
@permission_required("settings_system.execute")
|
@permission_required("settings_system.execute")
|
||||||
def run_check_now():
|
def run_check_now():
|
||||||
"""Startet einen Ueberwachungsdurchlauf sofort, im Hintergrund."""
|
"""Startet einen Ueberwachungsdurchlauf sofort, im Hintergrund."""
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ from tesm_core.extension import core, db, transaction
|
|||||||
|
|
||||||
from ..services import inventory, ssh, switchcli
|
from ..services import inventory, ssh, switchcli
|
||||||
|
|
||||||
bp = Blueprint("switches", __name__, url_prefix="/switche")
|
bp = Blueprint("switches", __name__, url_prefix="/monitoring/switches")
|
||||||
|
|
||||||
|
|
||||||
def _audit(conn: Any, action: str, target: str, detail: str = "", severity: str = "info") -> None:
|
def _audit(conn: Any, action: str, target: str, detail: str = "", severity: str = "info") -> None:
|
||||||
@@ -54,11 +54,11 @@ def overview():
|
|||||||
"tesm/switches.html",
|
"tesm/switches.html",
|
||||||
switches=inventory.list_switches(conn),
|
switches=inventory.list_switches(conn),
|
||||||
credentials=inventory.list_credentials(conn),
|
credentials=inventory.list_credentials(conn),
|
||||||
terminal_enabled=bool(core().settings.get(conn, "terminal_enabled")),
|
terminal_enabled=bool(core().settings.get(conn, "terminal_switches_enabled")),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@bp.post("/neu")
|
@bp.post("/new")
|
||||||
@permission_required("switches.create")
|
@permission_required("switches.create")
|
||||||
def create():
|
def create():
|
||||||
try:
|
try:
|
||||||
@@ -71,7 +71,7 @@ def create():
|
|||||||
f"IP {request.form.get('ip')}",
|
f"IP {request.form.get('ip')}",
|
||||||
severity="notice",
|
severity="notice",
|
||||||
)
|
)
|
||||||
flash("Switch angelegt. Der Host-Schluessel muss vor dem ersten Zugriff freigegeben werden.", "success")
|
flash("Switch angelegt. Der Host-Schlüssel muss vor dem ersten Zugriff freigegeben werden.", "success")
|
||||||
return redirect(url_for("switches.detail", switch_id=switch_id))
|
return redirect(url_for("switches.detail", switch_id=switch_id))
|
||||||
except inventory.ValidationError as exc:
|
except inventory.ValidationError as exc:
|
||||||
flash(str(exc), "danger")
|
flash(str(exc), "danger")
|
||||||
@@ -92,13 +92,13 @@ def update(switch_id: int):
|
|||||||
"; ".join(changes),
|
"; ".join(changes),
|
||||||
severity="notice",
|
severity="notice",
|
||||||
)
|
)
|
||||||
flash("Aenderungen gespeichert." if changes else "Keine Aenderungen.", "success" if changes else "info")
|
flash("Änderungen gespeichert." if changes else "Keine Änderungen.", "success" if changes else "info")
|
||||||
except inventory.ValidationError as exc:
|
except inventory.ValidationError as exc:
|
||||||
flash(str(exc), "danger")
|
flash(str(exc), "danger")
|
||||||
return redirect(request.referrer or url_for("switches.overview"))
|
return redirect(request.referrer or url_for("switches.overview"))
|
||||||
|
|
||||||
|
|
||||||
@bp.post("/<int:switch_id>/loeschen")
|
@bp.post("/<int:switch_id>/delete")
|
||||||
@permission_required("switches.delete")
|
@permission_required("switches.delete")
|
||||||
def delete(switch_id: int):
|
def delete(switch_id: int):
|
||||||
try:
|
try:
|
||||||
@@ -142,7 +142,7 @@ def detail(switch_id: int):
|
|||||||
devices=devices,
|
devices=devices,
|
||||||
credentials=inventory.list_credentials(conn),
|
credentials=inventory.list_credentials(conn),
|
||||||
safe_commands=switchcli.SAFE_COMMANDS,
|
safe_commands=switchcli.SAFE_COMMANDS,
|
||||||
terminal_enabled=bool(core().settings.get(conn, "terminal_enabled")),
|
terminal_enabled=bool(core().settings.get(conn, "terminal_switches_enabled")),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -151,7 +151,7 @@ def detail(switch_id: int):
|
|||||||
# --------------------------------------------------------------------------- #
|
# --------------------------------------------------------------------------- #
|
||||||
|
|
||||||
|
|
||||||
@bp.post("/<int:switch_id>/hostkey/pruefen")
|
@bp.post("/<int:switch_id>/host-key/probe")
|
||||||
@permission_required("switches.edit")
|
@permission_required("switches.edit")
|
||||||
def probe_host_key(switch_id: int):
|
def probe_host_key(switch_id: int):
|
||||||
conn = db()
|
conn = db()
|
||||||
@@ -176,7 +176,7 @@ def probe_host_key(switch_id: int):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@bp.post("/<int:switch_id>/hostkey/freigeben")
|
@bp.post("/<int:switch_id>/host-key/trust")
|
||||||
@permission_required("switches.edit")
|
@permission_required("switches.edit")
|
||||||
@reauth_required
|
@reauth_required
|
||||||
def trust_host_key(switch_id: int):
|
def trust_host_key(switch_id: int):
|
||||||
@@ -192,7 +192,7 @@ def trust_host_key(switch_id: int):
|
|||||||
key_type = (request.form.get("key_type") or "").strip()
|
key_type = (request.form.get("key_type") or "").strip()
|
||||||
fingerprint = (request.form.get("fingerprint") or "").strip()
|
fingerprint = (request.form.get("fingerprint") or "").strip()
|
||||||
if not key_type or not ssh.valid_fingerprint(fingerprint):
|
if not key_type or not ssh.valid_fingerprint(fingerprint):
|
||||||
flash("Es wurde kein gueltiger Fingerprint uebergeben.", "danger")
|
flash("Es wurde kein gültiger Fingerprint übergeben.", "danger")
|
||||||
return redirect(url_for("switches.detail", switch_id=switch_id))
|
return redirect(url_for("switches.detail", switch_id=switch_id))
|
||||||
|
|
||||||
with transaction():
|
with transaction():
|
||||||
@@ -211,11 +211,11 @@ def trust_host_key(switch_id: int):
|
|||||||
f"{key_type} {fingerprint}",
|
f"{key_type} {fingerprint}",
|
||||||
severity="notice",
|
severity="notice",
|
||||||
)
|
)
|
||||||
flash("Host-Schluessel freigegeben.", "success")
|
flash("Host-Schlüssel freigegeben.", "success")
|
||||||
return redirect(url_for("switches.detail", switch_id=switch_id))
|
return redirect(url_for("switches.detail", switch_id=switch_id))
|
||||||
|
|
||||||
|
|
||||||
@bp.post("/<int:switch_id>/hostkey/verwerfen")
|
@bp.post("/<int:switch_id>/host-key/forget")
|
||||||
@permission_required("switches.edit")
|
@permission_required("switches.edit")
|
||||||
def forget_host_key(switch_id: int):
|
def forget_host_key(switch_id: int):
|
||||||
conn = db()
|
conn = db()
|
||||||
@@ -225,7 +225,7 @@ def forget_host_key(switch_id: int):
|
|||||||
with transaction():
|
with transaction():
|
||||||
ssh.forget_key(conn, str(switch["ip"]), int(switch["ssh_port"]))
|
ssh.forget_key(conn, str(switch["ip"]), int(switch["ssh_port"]))
|
||||||
_audit(conn, "switch.host_key_forgotten", str(switch["hostname"]), severity="warning")
|
_audit(conn, "switch.host_key_forgotten", str(switch["hostname"]), severity="warning")
|
||||||
flash("Gespeicherte Host-Schluessel entfernt.", "info")
|
flash("Gespeicherte Host-Schlüssel entfernt.", "info")
|
||||||
return redirect(url_for("switches.detail", switch_id=switch_id))
|
return redirect(url_for("switches.detail", switch_id=switch_id))
|
||||||
|
|
||||||
|
|
||||||
@@ -234,7 +234,7 @@ def forget_host_key(switch_id: int):
|
|||||||
# --------------------------------------------------------------------------- #
|
# --------------------------------------------------------------------------- #
|
||||||
|
|
||||||
|
|
||||||
@bp.post("/<int:switch_id>/kommando")
|
@bp.post("/<int:switch_id>/command")
|
||||||
@permission_required("switches.execute")
|
@permission_required("switches.execute")
|
||||||
@reauth_required
|
@reauth_required
|
||||||
def run_command(switch_id: int):
|
def run_command(switch_id: int):
|
||||||
@@ -256,7 +256,7 @@ def run_command(switch_id: int):
|
|||||||
flash("Dieses Kommando steht nicht auf der Positivliste.", "danger")
|
flash("Dieses Kommando steht nicht auf der Positivliste.", "danger")
|
||||||
return redirect(url_for("switches.detail", switch_id=switch_id))
|
return redirect(url_for("switches.detail", switch_id=switch_id))
|
||||||
if not switch["credential_id"]:
|
if not switch["credential_id"]:
|
||||||
flash("Fuer diesen Switch sind keine Zugangsdaten hinterlegt.", "danger")
|
flash("Für diesen Switch sind keine Zugangsdaten hinterlegt.", "danger")
|
||||||
return redirect(url_for("switches.detail", switch_id=switch_id))
|
return redirect(url_for("switches.detail", switch_id=switch_id))
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
|||||||
@@ -24,7 +24,10 @@ Rechteausweitung, die dieses Projekt an anderer Stelle ausschliesst.
|
|||||||
Gemeinsame Sicherheitsmerkmale:
|
Gemeinsame Sicherheitsmerkmale:
|
||||||
|
|
||||||
* Frische Passwortbestaetigung, zusaetzlich zum Recht.
|
* Frische Passwortbestaetigung, zusaetzlich zum Recht.
|
||||||
* Abschaltbar ueber die Einstellung ``terminal_enabled``.
|
* Abschaltbar -- getrennt fuer Switche (``terminal_switches_enabled``) und
|
||||||
|
Clients (``terminal_clients_enabled``). Das Freigeben von Host-Schluesseln
|
||||||
|
haengt an eigenen Routen und bleibt davon unberuehrt: es wird fuer den
|
||||||
|
PoE-Neustart gebraucht.
|
||||||
* Der Host-Schluessel muss vorher freigegeben sein -- die Konsole akzeptiert
|
* Der Host-Schluessel muss vorher freigegeben sein -- die Konsole akzeptiert
|
||||||
keine unbekannten Schluessel (anders als der Vorgaenger, der sie hier
|
keine unbekannten Schluessel (anders als der Vorgaenger, der sie hier
|
||||||
interaktiv uebernahm). Freigegeben wird auf der jeweiligen Detailseite.
|
interaktiv uebernahm). Freigegeben wird auf der jeweiligen Detailseite.
|
||||||
@@ -71,7 +74,7 @@ CLIENT_COMMANDS: tuple[tuple[str, str], ...] = (
|
|||||||
("ip -brief address", "Netzwerkadressen"),
|
("ip -brief address", "Netzwerkadressen"),
|
||||||
("systemctl --failed", "Fehlgeschlagene Dienste"),
|
("systemctl --failed", "Fehlgeschlagene Dienste"),
|
||||||
("journalctl -p err -n 50 --no-pager", "Letzte Fehler im Journal"),
|
("journalctl -p err -n 50 --no-pager", "Letzte Fehler im Journal"),
|
||||||
("apt list --upgradable 2>/dev/null", "Verfuegbare Aktualisierungen"),
|
("apt list --upgradable 2>/dev/null", "Verfügbare Aktualisierungen"),
|
||||||
("uname -a", "Kernel und Architektur"),
|
("uname -a", "Kernel und Architektur"),
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -128,6 +131,18 @@ def _load_target(conn: Any, kind: str, target_id: int) -> Target | None:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _konsole_erlaubt(conn: Any, kind: str) -> bool:
|
||||||
|
"""Ist die Konsole fuer diese Art Ziel eingeschaltet?
|
||||||
|
|
||||||
|
Zwei Schalter, weil beides unabhaengig voneinander gebraucht wird: eine
|
||||||
|
Werkstatt will vielleicht auf Switche, aber nicht auf Clients.
|
||||||
|
"""
|
||||||
|
schluessel = (
|
||||||
|
"terminal_switches_enabled" if kind == KIND_SWITCH else "terminal_clients_enabled"
|
||||||
|
)
|
||||||
|
return bool(core().settings.get(conn, schluessel))
|
||||||
|
|
||||||
|
|
||||||
def _client_module_active() -> bool:
|
def _client_module_active() -> bool:
|
||||||
manager = core().license
|
manager = core().license
|
||||||
return manager is None or manager.module_active("maintenance")
|
return manager is None or manager.module_active("maintenance")
|
||||||
@@ -136,10 +151,10 @@ def _client_module_active() -> bool:
|
|||||||
def _render_console(target: Target): # type: ignore[no-untyped-def]
|
def _render_console(target: Target): # type: ignore[no-untyped-def]
|
||||||
extension = core()
|
extension = core()
|
||||||
conn = db()
|
conn = db()
|
||||||
if not extension.settings.get(conn, "terminal_enabled"):
|
if not _konsole_erlaubt(conn, target.kind):
|
||||||
abort(404)
|
abort(404)
|
||||||
if not target.credential_id:
|
if not target.credential_id:
|
||||||
abort(409, description="Fuer dieses Ziel sind keine Zugangsdaten hinterlegt.")
|
abort(409, description="Für dieses Ziel sind keine Zugangsdaten hinterlegt.")
|
||||||
|
|
||||||
return render_template(
|
return render_template(
|
||||||
"tesm/console.html",
|
"tesm/console.html",
|
||||||
@@ -151,7 +166,7 @@ def _render_console(target: Target): # type: ignore[no-untyped-def]
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@bp.get("/switche/<int:switch_id>/konsole")
|
@bp.get("/monitoring/switches/<int:switch_id>/console")
|
||||||
@permission_required("switches.execute")
|
@permission_required("switches.execute")
|
||||||
@reauth_required
|
@reauth_required
|
||||||
def switch_console(switch_id: int):
|
def switch_console(switch_id: int):
|
||||||
@@ -161,7 +176,7 @@ def switch_console(switch_id: int):
|
|||||||
return _render_console(target)
|
return _render_console(target)
|
||||||
|
|
||||||
|
|
||||||
@bp.get("/clients/<int:device_id>/konsole")
|
@bp.get("/monitoring/clients/<int:device_id>/console")
|
||||||
@permission_required("maintenance.execute")
|
@permission_required("maintenance.execute")
|
||||||
@reauth_required
|
@reauth_required
|
||||||
def device_console(device_id: int):
|
def device_console(device_id: int):
|
||||||
@@ -180,7 +195,7 @@ def init_app(app) -> None: # type: ignore[no-untyped-def]
|
|||||||
try:
|
try:
|
||||||
from flask_sock import Sock
|
from flask_sock import Sock
|
||||||
except ImportError: # pragma: no cover - optionale Abhaengigkeit
|
except ImportError: # pragma: no cover - optionale Abhaengigkeit
|
||||||
logger.info("flask-sock ist nicht installiert -- die Konsole bleibt deaktiviert.")
|
logger.info("flask-sock ist nicht installiert – die Konsole bleibt deaktiviert.")
|
||||||
return
|
return
|
||||||
|
|
||||||
sock = Sock(app)
|
sock = Sock(app)
|
||||||
@@ -212,16 +227,16 @@ def _handle_socket(ws, kind: str, target_id: int) -> None: # type: ignore[no-un
|
|||||||
# WebSocket umgeht Dekoratoren, also stehen sie hier ausdruecklich.
|
# WebSocket umgeht Dekoratoren, also stehen sie hier ausdruecklich.
|
||||||
required = "switches.execute" if kind == KIND_SWITCH else "maintenance.execute"
|
required = "switches.execute" if kind == KIND_SWITCH else "maintenance.execute"
|
||||||
if not current_user.is_authenticated or not current_user.has(required):
|
if not current_user.is_authenticated or not current_user.has(required):
|
||||||
_send(ws, "error", "Keine Berechtigung fuer diese Konsole.")
|
_send(ws, "error", "Keine Berechtigung für diese Konsole.")
|
||||||
return
|
return
|
||||||
if not reauth_is_fresh():
|
if not reauth_is_fresh():
|
||||||
_send(ws, "error", "Die Passwortbestaetigung ist abgelaufen. Bitte Seite neu laden.")
|
_send(ws, "error", "Die Passwortbestätigung ist abgelaufen. Bitte Seite neu laden.")
|
||||||
return
|
return
|
||||||
if not extension.settings.get(conn, "terminal_enabled"):
|
if not _konsole_erlaubt(conn, kind):
|
||||||
_send(ws, "error", "Die Konsole ist in dieser Installation abgeschaltet.")
|
_send(ws, "error", "Die Konsole ist in dieser Installation abgeschaltet.")
|
||||||
return
|
return
|
||||||
if kind == KIND_DEVICE and not _client_module_active():
|
if kind == KIND_DEVICE and not _client_module_active():
|
||||||
_send(ws, "error", "Die Client-Konsole gehoert zum Modul Wartung -- es ist nicht aktiv.")
|
_send(ws, "error", "Die Client-Konsole gehört zum Modul Wartung – es ist nicht aktiv.")
|
||||||
return
|
return
|
||||||
|
|
||||||
target = _load_target(conn, kind, target_id)
|
target = _load_target(conn, kind, target_id)
|
||||||
@@ -278,8 +293,8 @@ def _handle_socket(ws, kind: str, target_id: int) -> None: # type: ignore[no-un
|
|||||||
_send(
|
_send(
|
||||||
ws,
|
ws,
|
||||||
"error",
|
"error",
|
||||||
f"{exc} Die Konsole akzeptiert keine unbekannten Schluessel -- bitte auf der "
|
f"{exc} Die Konsole akzeptiert keine unbekannten Schlüssel – bitte auf der "
|
||||||
f"{page} ausdruecklich freigeben.",
|
f"{page} ausdrücklich freigeben.",
|
||||||
)
|
)
|
||||||
except ssh.SshError as exc:
|
except ssh.SshError as exc:
|
||||||
_send(ws, "error", str(exc))
|
_send(ws, "error", str(exc))
|
||||||
@@ -304,7 +319,7 @@ def _read_command(ws, idle_timeout: int) -> str | None: # type: ignore[no-untyp
|
|||||||
"""Naechstes Kommando, oder ``None`` wenn die Sitzung enden soll."""
|
"""Naechstes Kommando, oder ``None`` wenn die Sitzung enden soll."""
|
||||||
raw = ws.receive(timeout=idle_timeout)
|
raw = ws.receive(timeout=idle_timeout)
|
||||||
if raw is None:
|
if raw is None:
|
||||||
_send(ws, "info", "Verbindung wegen Untaetigkeit getrennt.")
|
_send(ws, "info", "Verbindung wegen Untätigkeit getrennt.")
|
||||||
return None
|
return None
|
||||||
try:
|
try:
|
||||||
message = json.loads(raw)
|
message = json.loads(raw)
|
||||||
@@ -392,6 +407,6 @@ def _run_client_session( # type: ignore[no-untyped-def]
|
|||||||
_send(
|
_send(
|
||||||
ws,
|
ws,
|
||||||
"error",
|
"error",
|
||||||
f"Rueckgabewert {result['status']} nach {result['duration_ms']} ms.",
|
f"Rückgabewert {result['status']} nach {result['duration_ms']} ms.",
|
||||||
)
|
)
|
||||||
return commands
|
return commands
|
||||||
+52
-24
@@ -39,7 +39,7 @@ def cmd_create_admin(args: argparse.Namespace) -> int:
|
|||||||
if not password:
|
if not password:
|
||||||
password = getpass.getpass("Passwort: ")
|
password = getpass.getpass("Passwort: ")
|
||||||
if password != getpass.getpass("Passwort wiederholen: "):
|
if password != getpass.getpass("Passwort wiederholen: "):
|
||||||
print("Die Passwoerter stimmen nicht ueberein.", file=sys.stderr)
|
print("Die Passwörter stimmen nicht überein.", file=sys.stderr)
|
||||||
return 2
|
return 2
|
||||||
|
|
||||||
problems = extension.password_policy.check(password, username=username)
|
problems = extension.password_policy.check(password, username=username)
|
||||||
@@ -75,7 +75,7 @@ def cmd_create_admin(args: argparse.Namespace) -> int:
|
|||||||
actor_id=user_id,
|
actor_id=user_id,
|
||||||
action="user.admin_created",
|
action="user.admin_created",
|
||||||
target=username,
|
target=username,
|
||||||
detail=f"ueber die Kommandozeile {action}",
|
detail=f"über die Kommandozeile {action}",
|
||||||
severity="warning",
|
severity="warning",
|
||||||
)
|
)
|
||||||
print(f"Administrator {username!r} {action}.")
|
print(f"Administrator {username!r} {action}.")
|
||||||
@@ -133,7 +133,7 @@ def cmd_status(args: argparse.Namespace) -> int:
|
|||||||
print(
|
print(
|
||||||
"Audit-Kette : "
|
"Audit-Kette : "
|
||||||
+ ("unversehrt" if chain["ok"] else f"GEBROCHEN bei Eintrag {chain['broken_at']}")
|
+ ("unversehrt" if chain["ok"] else f"GEBROCHEN bei Eintrag {chain['broken_at']}")
|
||||||
+ f" ({chain['checked']} geprueft)"
|
+ f" ({chain['checked']} geprüft)"
|
||||||
)
|
)
|
||||||
for key, value in counts.items():
|
for key, value in counts.items():
|
||||||
print(f"{key.capitalize():<15}: {value}")
|
print(f"{key.capitalize():<15}: {value}")
|
||||||
@@ -168,6 +168,27 @@ def cmd_housekeeping(_args: argparse.Namespace) -> int:
|
|||||||
return 0
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
def cmd_restore_signatures(_args: argparse.Namespace) -> int:
|
||||||
|
"""Traegt Unterschriften aus dem Journal wieder in die Datenbank ein.
|
||||||
|
|
||||||
|
Gebraucht nach einer Wiederherstellung: die Freigabe traegt die Dateien
|
||||||
|
von gestern, die zurueckgespielte Datenbank ist von vorgestern -- die
|
||||||
|
Unterschriften dazwischen stehen dann nur noch im Journal neben dem
|
||||||
|
Protokollarchiv.
|
||||||
|
"""
|
||||||
|
app, extension = _app_and_core()
|
||||||
|
from .services import fileshare
|
||||||
|
|
||||||
|
with app.app_context(), extension.database.session() as conn:
|
||||||
|
with extension.database.transaction(conn):
|
||||||
|
uebernommen = fileshare.signaturen_aus_journal(conn)
|
||||||
|
gesamt = int(
|
||||||
|
conn.execute("SELECT COUNT(*) FROM document_signatures").fetchone()[0]
|
||||||
|
)
|
||||||
|
print(f"{uebernommen} Unterschrift(en) übernommen, {gesamt} insgesamt.")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
def cmd_check_now(_args: argparse.Namespace) -> int:
|
def cmd_check_now(_args: argparse.Namespace) -> int:
|
||||||
app, extension = _app_and_core()
|
app, extension = _app_and_core()
|
||||||
from .services import inventory, monitor
|
from .services import inventory, monitor
|
||||||
@@ -183,7 +204,7 @@ def cmd_check_now(_args: argparse.Namespace) -> int:
|
|||||||
license_allows_auto_restart=allowed,
|
license_allows_auto_restart=allowed,
|
||||||
)
|
)
|
||||||
print(
|
print(
|
||||||
f"{report.checked} geprueft, {report.online} online, {report.offline} offline, "
|
f"{report.checked} geprüft, {report.online} online, {report.offline} offline, "
|
||||||
f"{report.restarts_triggered} Neustarts ({report.restarts_ok} erfolgreich), "
|
f"{report.restarts_triggered} Neustarts ({report.restarts_ok} erfolgreich), "
|
||||||
f"{report.duration_ms} ms"
|
f"{report.duration_ms} ms"
|
||||||
)
|
)
|
||||||
@@ -214,7 +235,7 @@ def cmd_convert_legacy(args: argparse.Namespace) -> int:
|
|||||||
return 2
|
return 2
|
||||||
ziel = Path(args.ausgabe) if args.ausgabe else quelle.with_name(f"{quelle.stem}-neu.json")
|
ziel = Path(args.ausgabe) if args.ausgabe else quelle.with_name(f"{quelle.stem}-neu.json")
|
||||||
if ziel.exists() and not args.force:
|
if ziel.exists() and not args.force:
|
||||||
print(f"{ziel} existiert bereits. Mit --force ueberschreiben.")
|
print(f"{ziel} existiert bereits. Mit --force überschreiben.")
|
||||||
return 2
|
return 2
|
||||||
|
|
||||||
alt_pass = args.passphrase or getpass("Passphrase der alten Datei: ")
|
alt_pass = args.passphrase or getpass("Passphrase der alten Datei: ")
|
||||||
@@ -231,7 +252,7 @@ def cmd_convert_legacy(args: argparse.Namespace) -> int:
|
|||||||
print("--dry-run: keine Datei geschrieben.")
|
print("--dry-run: keine Datei geschrieben.")
|
||||||
return 0
|
return 0
|
||||||
|
|
||||||
neu_pass = args.neue_passphrase or getpass("Passphrase fuer die neue Datei: ")
|
neu_pass = args.neue_passphrase or getpass("Passphrase für die neue Datei: ")
|
||||||
try:
|
try:
|
||||||
ziel.write_text(transfer.seal(nutzlast, neu_pass), encoding="utf-8", newline="\n")
|
ziel.write_text(transfer.seal(nutzlast, neu_pass), encoding="utf-8", newline="\n")
|
||||||
except Exception as exc: # noqa: BLE001 - Passphrase zu kurz, Pfad nicht schreibbar
|
except Exception as exc: # noqa: BLE001 - Passphrase zu kurz, Pfad nicht schreibbar
|
||||||
@@ -265,10 +286,10 @@ def cmd_legacy_export(args: argparse.Namespace) -> int:
|
|||||||
quelle = Path(args.source)
|
quelle = Path(args.source)
|
||||||
ziel = Path(args.out)
|
ziel = Path(args.out)
|
||||||
if ziel.exists() and not args.force:
|
if ziel.exists() and not args.force:
|
||||||
print(f"{ziel} existiert bereits. Mit --force ueberschreiben.")
|
print(f"{ziel} existiert bereits. Mit --force überschreiben.")
|
||||||
return 2
|
return 2
|
||||||
|
|
||||||
passphrase = args.passphrase or getpass("Passphrase fuer die Exportdatei: ")
|
passphrase = args.passphrase or getpass("Passphrase für die Exportdatei: ")
|
||||||
try:
|
try:
|
||||||
nutzlast, bericht = legacy.read_installation(quelle)
|
nutzlast, bericht = legacy.read_installation(quelle)
|
||||||
inhalt = legacy.seal_legacy(nutzlast, passphrase)
|
inhalt = legacy.seal_legacy(nutzlast, passphrase)
|
||||||
@@ -354,7 +375,7 @@ def cmd_legacy_import(args: argparse.Namespace) -> int:
|
|||||||
action="backup.imported",
|
action="backup.imported",
|
||||||
detail="; ".join(
|
detail="; ".join(
|
||||||
f"{key}: {zahlen['created']} neu, {zahlen['updated']} aktualisiert, "
|
f"{key}: {zahlen['created']} neu, {zahlen['updated']} aktualisiert, "
|
||||||
f"{zahlen['skipped']} uebersprungen"
|
f"{zahlen['skipped']} übersprungen"
|
||||||
for key, zahlen in bericht.items()
|
for key, zahlen in bericht.items()
|
||||||
)
|
)
|
||||||
+ " (Kommandozeile)",
|
+ " (Kommandozeile)",
|
||||||
@@ -364,19 +385,20 @@ def cmd_legacy_import(args: argparse.Namespace) -> int:
|
|||||||
for key, zahlen in sorted(bericht.items()):
|
for key, zahlen in sorted(bericht.items()):
|
||||||
print(
|
print(
|
||||||
f" {key}: {zahlen['created']} neu, {zahlen['updated']} aktualisiert, "
|
f" {key}: {zahlen['created']} neu, {zahlen['updated']} aktualisiert, "
|
||||||
f"{zahlen['skipped']} uebersprungen"
|
f"{zahlen['skipped']} übersprungen"
|
||||||
)
|
)
|
||||||
return 0
|
return 0
|
||||||
|
|
||||||
|
|
||||||
def build_parser() -> argparse.ArgumentParser:
|
def build_parser() -> argparse.ArgumentParser:
|
||||||
parser = argparse.ArgumentParser(
|
parser = argparse.ArgumentParser(
|
||||||
prog="tesm-admin", description="Verwaltungswerkzeuge fuer TESM"
|
prog="tesm-admin", description="Verwaltungswerkzeuge für TESM"
|
||||||
)
|
)
|
||||||
sub = parser.add_subparsers(dest="command", required=True)
|
sub = parser.add_subparsers(dest="command", required=True)
|
||||||
|
|
||||||
admin = sub.add_parser("create-admin", help="Administratorkonto anlegen oder zuruecksetzen")
|
admin = sub.add_parser("create-admin", help="Administratorkonto anlegen oder zurücksetzen")
|
||||||
admin.add_argument("--username")
|
admin.add_argument("--username")
|
||||||
admin.add_argument("--password", help="Nur fuer automatisierte Installationen")
|
admin.add_argument("--password", help="Nur für automatisierte Installationen")
|
||||||
admin.add_argument("--force", action="store_true", help="Passwortregeln ignorieren")
|
admin.add_argument("--force", action="store_true", help="Passwortregeln ignorieren")
|
||||||
admin.set_defaults(func=cmd_create_admin)
|
admin.set_defaults(func=cmd_create_admin)
|
||||||
|
|
||||||
@@ -384,18 +406,24 @@ def build_parser() -> argparse.ArgumentParser:
|
|||||||
status.add_argument("--json", action="store_true")
|
status.add_argument("--json", action="store_true")
|
||||||
status.set_defaults(func=cmd_status)
|
status.set_defaults(func=cmd_status)
|
||||||
|
|
||||||
house = sub.add_parser("housekeeping", help="Sitzungen, Ratenereignisse, Protokolle aufraeumen")
|
house = sub.add_parser("housekeeping", help="Sitzungen, Ratenereignisse, Protokolle aufräumen")
|
||||||
house.set_defaults(func=cmd_housekeeping)
|
house.set_defaults(func=cmd_housekeeping)
|
||||||
|
|
||||||
check = sub.add_parser("check-now", help="Einen Ueberwachungsdurchlauf sofort ausfuehren")
|
restore = sub.add_parser(
|
||||||
|
"restore-signatures",
|
||||||
|
help="Unterschriften aus dem Journal neben dem Protokollarchiv übernehmen",
|
||||||
|
)
|
||||||
|
restore.set_defaults(func=cmd_restore_signatures)
|
||||||
|
|
||||||
|
check = sub.add_parser("check-now", help="Einen Überwachungsdurchlauf sofort ausführen")
|
||||||
check.set_defaults(func=cmd_check_now)
|
check.set_defaults(func=cmd_check_now)
|
||||||
|
|
||||||
legacy = sub.add_parser(
|
legacy = sub.add_parser(
|
||||||
"convert-legacy",
|
"convert-legacy",
|
||||||
help="Export des alten TESM in das neue Format uebersetzen",
|
help="Export des alten TESM in das neue Format übersetzen",
|
||||||
description=(
|
description=(
|
||||||
"Liest eine Exportdatei des Vorgaengers und schreibt eine Datei im "
|
"Liest eine Exportdatei des Vorgängers und schreibt eine Datei im "
|
||||||
"Format dieser Anwendung. Uebersetzt werden Clients, Switche, "
|
"Format dieser Anwendung. Übersetzt werden Clients, Switche, "
|
||||||
"Zugangsdaten und die DHCP-Konfiguration."
|
"Zugangsdaten und die DHCP-Konfiguration."
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
@@ -410,7 +438,7 @@ def build_parser() -> argparse.ArgumentParser:
|
|||||||
legacy.add_argument(
|
legacy.add_argument(
|
||||||
"--dry-run", action="store_true", help="Nur den Bericht zeigen, nichts schreiben"
|
"--dry-run", action="store_true", help="Nur den Bericht zeigen, nichts schreiben"
|
||||||
)
|
)
|
||||||
legacy.add_argument("--force", action="store_true", help="Zieldatei ueberschreiben")
|
legacy.add_argument("--force", action="store_true", help="Zieldatei überschreiben")
|
||||||
legacy.set_defaults(func=cmd_convert_legacy)
|
legacy.set_defaults(func=cmd_convert_legacy)
|
||||||
|
|
||||||
|
|
||||||
@@ -418,10 +446,10 @@ def build_parser() -> argparse.ArgumentParser:
|
|||||||
"legacy-export",
|
"legacy-export",
|
||||||
help="Daten einer alten Installation als Exportdatei sichern",
|
help="Daten einer alten Installation als Exportdatei sichern",
|
||||||
description=(
|
description=(
|
||||||
"Liest die Datenbank einer Installation der Vorgaengerfassung und schreibt "
|
"Liest die Datenbank einer Installation der Vorgängerfassung und schreibt "
|
||||||
"Clients, Switche, Zugangsdaten und DHCP in eine Datei im alten Format. "
|
"Clients, Switche, Zugangsdaten und DHCP in eine Datei im alten Format. "
|
||||||
"Die alte Installation wird nur gelesen. Ohne Browser und auch dann noch "
|
"Die alte Installation wird nur gelesen. Ohne Browser und auch dann noch "
|
||||||
"moeglich, wenn die alte Anwendung nicht mehr startet."
|
"möglich, wenn die alte Anwendung nicht mehr startet."
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
export_alt.add_argument(
|
export_alt.add_argument(
|
||||||
@@ -430,16 +458,16 @@ def build_parser() -> argparse.ArgumentParser:
|
|||||||
export_alt.add_argument("--out", required=True, help="Zieldatei")
|
export_alt.add_argument("--out", required=True, help="Zieldatei")
|
||||||
export_alt.add_argument("--passphrase", help="Passphrase der Zieldatei (sonst wird gefragt)")
|
export_alt.add_argument("--passphrase", help="Passphrase der Zieldatei (sonst wird gefragt)")
|
||||||
export_alt.add_argument("--dry-run", action="store_true", help="Nur den Bericht zeigen")
|
export_alt.add_argument("--dry-run", action="store_true", help="Nur den Bericht zeigen")
|
||||||
export_alt.add_argument("--force", action="store_true", help="Zieldatei ueberschreiben")
|
export_alt.add_argument("--force", action="store_true", help="Zieldatei überschreiben")
|
||||||
export_alt.set_defaults(func=cmd_legacy_export)
|
export_alt.set_defaults(func=cmd_legacy_export)
|
||||||
|
|
||||||
import_neu = sub.add_parser(
|
import_neu = sub.add_parser(
|
||||||
"legacy-import",
|
"legacy-import",
|
||||||
help="Datei im Format dieser Anwendung einlesen",
|
help="Datei im Format dieser Anwendung einlesen",
|
||||||
description=(
|
description=(
|
||||||
"Liest eine Sicherungs- oder uebersetzte Datei ein -- derselbe Weg wie unter "
|
"Liest eine Sicherungs- oder übersetzte Datei ein – derselbe Weg wie unter "
|
||||||
"Verwaltung -> Sicherung, nur ohne Browser. Ohne --categories werden alle "
|
"Verwaltung -> Sicherung, nur ohne Browser. Ohne --categories werden alle "
|
||||||
"Kategorien uebernommen, die in der Datei stehen."
|
"Kategorien übernommen, die in der Datei stehen."
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
import_neu.add_argument("datei", help="Datei im Format dieser Anwendung")
|
import_neu.add_argument("datei", help="Datei im Format dieser Anwendung")
|
||||||
|
|||||||
@@ -45,10 +45,10 @@ def _has_fileshare_mounts(user: Any) -> bool:
|
|||||||
|
|
||||||
NAVIGATION = NavTree(
|
NAVIGATION = NavTree(
|
||||||
[
|
[
|
||||||
NavItem(key="index", label="Statusuebersicht", icon="grid", endpoint="dashboard.index"),
|
NavItem(key="index", label="Statusübersicht", icon="grid", endpoint="dashboard.index"),
|
||||||
NavItem(
|
NavItem(
|
||||||
key="monitoring",
|
key="monitoring",
|
||||||
label="Ueberwachung",
|
label="Überwachung",
|
||||||
icon="cpu",
|
icon="cpu",
|
||||||
children=(
|
children=(
|
||||||
NavItem(
|
NavItem(
|
||||||
@@ -74,21 +74,21 @@ NAVIGATION = NavTree(
|
|||||||
),
|
),
|
||||||
NavItem(
|
NavItem(
|
||||||
key="hostkeys",
|
key="hostkeys",
|
||||||
label="Host-Schluessel",
|
label="Host-Schlüssel",
|
||||||
icon="shield",
|
icon="shield",
|
||||||
endpoint="hostkeys.overview",
|
endpoint="hostkeys.overview",
|
||||||
permissions=("devices.view", "switches.view"),
|
permissions=("devices.view", "switches.view"),
|
||||||
),
|
),
|
||||||
NavItem(
|
|
||||||
key="maintenance",
|
|
||||||
label="Wartung",
|
|
||||||
icon="wrench",
|
|
||||||
endpoint="maintenance.overview",
|
|
||||||
permissions=("maintenance.view", "maintenance.execute"),
|
|
||||||
condition=_module("maintenance"),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
NavItem(
|
||||||
|
key="maintenance",
|
||||||
|
label="Wartung",
|
||||||
|
icon="wrench",
|
||||||
|
endpoint="maintenance.overview",
|
||||||
|
permissions=("maintenance.view", "maintenance.execute"),
|
||||||
|
condition=_module("maintenance"),
|
||||||
|
),
|
||||||
NavItem(
|
NavItem(
|
||||||
key="dhcp",
|
key="dhcp",
|
||||||
label="DHCP-Server",
|
label="DHCP-Server",
|
||||||
@@ -147,7 +147,7 @@ NAVIGATION = NavTree(
|
|||||||
),
|
),
|
||||||
NavItem(
|
NavItem(
|
||||||
key="logs_dhcp",
|
key="logs_dhcp",
|
||||||
label="Kea-DHCP",
|
label="DHCP",
|
||||||
icon="network",
|
icon="network",
|
||||||
endpoint="tesmlogs.dhcp",
|
endpoint="tesmlogs.dhcp",
|
||||||
permissions=("logs_dhcp.view",),
|
permissions=("logs_dhcp.view",),
|
||||||
|
|||||||
@@ -31,11 +31,11 @@ DEVICES = Resource(
|
|||||||
key="devices",
|
key="devices",
|
||||||
label="Clients",
|
label="Clients",
|
||||||
actions=(A.VIEW, A.CREATE, A.EDIT, A.DELETE, A.EXECUTE),
|
actions=(A.VIEW, A.CREATE, A.EDIT, A.DELETE, A.EXECUTE),
|
||||||
hint="Ueberwachte PoE-Endgeraete",
|
hint="Überwachte PoE-Endgeräte",
|
||||||
action_hints={
|
action_hints={
|
||||||
A.EXECUTE: "PoE-Neustart eines Geraets manuell ausloesen. Der automatische "
|
A.EXECUTE: "PoE-Neustart eines Geräts manuell auslösen. Der automatische "
|
||||||
"Neustart bei Ausfall ist davon unabhaengig und laeuft immer.",
|
"Neustart bei Ausfall ist davon unabhängig und läuft immer.",
|
||||||
A.DELETE: "Geraet in den Papierkorb legen.",
|
A.DELETE: "Gerät in den Papierkorb legen.",
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -45,9 +45,9 @@ SWITCHES = Resource(
|
|||||||
actions=(A.VIEW, A.CREATE, A.EDIT, A.DELETE, A.EXECUTE),
|
actions=(A.VIEW, A.CREATE, A.EDIT, A.DELETE, A.EXECUTE),
|
||||||
hint="Aruba-Switche und ihre Erreichbarkeit",
|
hint="Aruba-Switche und ihre Erreichbarkeit",
|
||||||
action_hints={
|
action_hints={
|
||||||
A.EXECUTE: "Browser-Terminal oeffnen und Kommandos auf dem Switch ausfuehren. "
|
A.EXECUTE: "Browser-Terminal öffnen und Kommandos auf dem Switch ausführen. "
|
||||||
"Verlangt zusaetzlich eine frische Passwortbestaetigung; jede Sitzung wird "
|
"Verlangt zusätzlich eine frische Passwortbestätigung; jede Sitzung wird "
|
||||||
"vollstaendig protokolliert.",
|
"vollständig protokolliert.",
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -57,27 +57,28 @@ CREDENTIALS = Resource(
|
|||||||
actions=(A.VIEW, A.CREATE, A.EDIT, A.DELETE, A.SECRETS),
|
actions=(A.VIEW, A.CREATE, A.EDIT, A.DELETE, A.SECRETS),
|
||||||
hint="Wiederverwendbare SSH-Logins",
|
hint="Wiederverwendbare SSH-Logins",
|
||||||
action_hints={
|
action_hints={
|
||||||
A.VIEW: "Namen und Benutzer sehen -- nicht das Passwort.",
|
A.VIEW: "Namen und Benutzer sehen – nicht das Passwort.",
|
||||||
A.SECRETS: "Passwort im Klartext anzeigen. Verlangt eine frische "
|
A.SECRETS: "Passwort im Klartext anzeigen. Verlangt eine frische "
|
||||||
"Passwortbestaetigung und wird protokolliert.",
|
"Passwortbestätigung und wird protokolliert.",
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
MAINTENANCE = Resource(
|
MAINTENANCE = Resource(
|
||||||
key="maintenance",
|
key="maintenance",
|
||||||
label="Wartung",
|
# Nicht "Wartung": so heisst der Bereich. Die Zeile sagt, was sie erlaubt.
|
||||||
|
label="Aktualisierung und Neustart",
|
||||||
actions=(A.VIEW, A.EXECUTE),
|
actions=(A.VIEW, A.EXECUTE),
|
||||||
hint="Paketaktualisierung und Neustart per SSH (lizenzpflichtiges Modul)",
|
hint="Paketaktualisierung und Neustart per SSH (lizenzpflichtiges Modul)",
|
||||||
action_hints={A.EXECUTE: "Aktualisierung oder Neustart auf ausgewaehlten Geraeten starten."},
|
action_hints={A.EXECUTE: "Aktualisierung oder Neustart auf ausgewählten Geräten starten."},
|
||||||
)
|
)
|
||||||
|
|
||||||
DHCP = Resource(
|
DHCP = Resource(
|
||||||
key="dhcp",
|
key="dhcp",
|
||||||
label="DHCP-Server",
|
label="DHCP-Server",
|
||||||
actions=(A.VIEW, A.EDIT, A.EXECUTE),
|
actions=(A.VIEW, A.EDIT, A.EXECUTE),
|
||||||
hint="Kea-DHCP: Subnetze, Optionen, Reservierungen (lizenzpflichtiges Modul)",
|
hint="DHCP: Subnetze, Optionen, Reservierungen (lizenzpflichtiges Modul)",
|
||||||
action_hints={
|
action_hints={
|
||||||
A.EDIT: "Konfiguration aendern und schreiben.",
|
A.EDIT: "Konfiguration ändern und schreiben.",
|
||||||
A.EXECUTE: "Dienst installieren, starten oder stoppen.",
|
A.EXECUTE: "Dienst installieren, starten oder stoppen.",
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
@@ -85,27 +86,65 @@ DHCP = Resource(
|
|||||||
FILESHARE = Resource(
|
FILESHARE = Resource(
|
||||||
key="fileshare",
|
key="fileshare",
|
||||||
label="Dateifreigaben",
|
label="Dateifreigaben",
|
||||||
actions=(A.VIEW, A.CREATE, A.EDIT, A.DELETE),
|
actions=(A.VIEW, A.CREATE, A.EDIT, A.DELETE, A.EXPORT),
|
||||||
hint="SMB-Freigaben aus dem Verzeichnisdienst (lizenzpflichtiges Modul)",
|
hint="SMB-Freigaben aus dem Verzeichnisdienst (lizenzpflichtiges Modul)",
|
||||||
action_hints={
|
action_hints={
|
||||||
A.CREATE: "Ordner anlegen und Dateien hochladen.",
|
A.CREATE: "Ordner anlegen und Dateien hochladen.",
|
||||||
A.EDIT: "Umbenennen und verschieben.",
|
A.EDIT: "Umbenennen und verschieben.",
|
||||||
|
# Genau die Bedeutung aus der Legende: Daten aus dem System
|
||||||
|
# heraustragen. Ansehen und Mitnehmen sind zweierlei -- eine Vorschau
|
||||||
|
# bleibt im Haus, eine heruntergeladene Datei nicht.
|
||||||
|
A.EXPORT: "Dateien herunterladen.",
|
||||||
|
},
|
||||||
|
# Exporte verlangen sonst eine frische Passwortbestaetigung. Hier nicht:
|
||||||
|
# Herunterladen ist in einer Dateifreigabe der Alltag, nicht der
|
||||||
|
# Sonderfall. Alle 15 Minuten ein Passwort waere Schikane -- und wuerde
|
||||||
|
# nur dazu fuehren, dass jemand das Recht pauschal vergibt oder ganz
|
||||||
|
# weglaesst. Die Grenze zieht hier das Recht selbst, nicht die Abfrage.
|
||||||
|
ohne_wiederanmeldung=(A.EXPORT,),
|
||||||
|
)
|
||||||
|
|
||||||
|
#: Eigene Zeile statt Anhaengsel von "Dateifreigaben": ein Formular auszufuellen
|
||||||
|
#: ist ein anderer Vorgang als eine Datei hochzuladen, und wer umbenennen darf,
|
||||||
|
#: soll deshalb nicht automatisch veraenderbare Formulare ablegen duerfen.
|
||||||
|
FILESHARE_PDF = Resource(
|
||||||
|
key="fileshare_pdf",
|
||||||
|
label="PDF-Formulare",
|
||||||
|
actions=(A.VIEW, A.CREATE, A.EDIT),
|
||||||
|
hint="Formulare in der Vorschau ausfüllen und in der Freigabe ablegen",
|
||||||
|
action_hints={
|
||||||
|
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.",
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
LOGS_DHCP = Resource(
|
LOGS_DHCP = Resource(
|
||||||
key="logs_dhcp",
|
key="logs_dhcp",
|
||||||
label="Kea-DHCP-Protokoll",
|
label="DHCP-Protokoll",
|
||||||
actions=(A.VIEW,),
|
actions=(A.VIEW,),
|
||||||
)
|
)
|
||||||
|
|
||||||
MONITORING_AREA = Area(
|
MONITORING_AREA = Area(
|
||||||
key="monitoring",
|
key="monitoring",
|
||||||
label="Ueberwachung",
|
label="Überwachung",
|
||||||
icon="cpu",
|
icon="cpu",
|
||||||
hint="Geraete, Switche, Zugangsdaten und Wartung. Ohne diesen Zugang bleibt der "
|
hint="Geräte, Switche und Zugangsdaten. Ohne diesen Zugang bleibt der "
|
||||||
"gesamte Bereich verborgen.",
|
"gesamte Bereich verborgen.",
|
||||||
resources=(DEVICES, SWITCHES, CREDENTIALS, MAINTENANCE),
|
resources=(DEVICES, SWITCHES, CREDENTIALS),
|
||||||
|
)
|
||||||
|
|
||||||
|
#: Wartung ueberwacht nichts, sie greift ein -- und sie steht seit 2.5.0 als
|
||||||
|
#: eigener Punkt im Menue. Der Bereichsschluessel heisst ``servicing``, weil
|
||||||
|
#: ``maintenance`` schon die Ressource ist und Bereiche und Ressourcen sich
|
||||||
|
#: einen Namensraum teilen. Nach aussen heisst beides "Wartung".
|
||||||
|
MAINTENANCE_AREA = Area(
|
||||||
|
key="servicing",
|
||||||
|
label="Wartung",
|
||||||
|
icon="wrench",
|
||||||
|
hint="Paketpflege und Neustarts. Ohne diesen Zugang bleibt der Bereich "
|
||||||
|
"verborgen – auch wenn einzelne Rechte darunter gesetzt sind.",
|
||||||
|
resources=(MAINTENANCE,),
|
||||||
)
|
)
|
||||||
|
|
||||||
NETWORK_AREA = Area(
|
NETWORK_AREA = Area(
|
||||||
@@ -119,12 +158,13 @@ FILES_AREA = Area(
|
|||||||
key="files",
|
key="files",
|
||||||
label="Dateifreigaben",
|
label="Dateifreigaben",
|
||||||
icon="folder",
|
icon="folder",
|
||||||
resources=(FILESHARE,),
|
resources=(FILESHARE, FILESHARE_PDF),
|
||||||
)
|
)
|
||||||
|
|
||||||
PERMISSIONS = PermissionTree(
|
PERMISSIONS = PermissionTree(
|
||||||
[
|
[
|
||||||
MONITORING_AREA,
|
MONITORING_AREA,
|
||||||
|
MAINTENANCE_AREA,
|
||||||
NETWORK_AREA,
|
NETWORK_AREA,
|
||||||
FILES_AREA,
|
FILES_AREA,
|
||||||
observability_area(LOGS_DHCP),
|
observability_area(LOGS_DHCP),
|
||||||
@@ -137,7 +177,7 @@ ROLE_PRESETS: tuple[RolePreset, ...] = (
|
|||||||
RolePreset(
|
RolePreset(
|
||||||
key="viewer",
|
key="viewer",
|
||||||
name="Benutzer",
|
name="Benutzer",
|
||||||
description="Standardgruppe: Geraete und Switche ansehen, laufendes Protokoll lesen.",
|
description="Standardgruppe: Geräte und Switche ansehen, laufendes Protokoll lesen.",
|
||||||
grants=(
|
grants=(
|
||||||
("devices", (A.VIEW,)),
|
("devices", (A.VIEW,)),
|
||||||
("switches", (A.VIEW,)),
|
("switches", (A.VIEW,)),
|
||||||
@@ -148,7 +188,7 @@ ROLE_PRESETS: tuple[RolePreset, ...] = (
|
|||||||
RolePreset(
|
RolePreset(
|
||||||
key="operations",
|
key="operations",
|
||||||
name="Betrieb",
|
name="Betrieb",
|
||||||
description="Geraete und Switche pflegen, Neustarts ausloesen, Wartung fahren.",
|
description="Geräte und Switche pflegen, Neustarts auslösen, Wartung fahren.",
|
||||||
grants=(
|
grants=(
|
||||||
("devices", "*"),
|
("devices", "*"),
|
||||||
("switches", (A.VIEW, A.CREATE, A.EDIT, A.EXECUTE)),
|
("switches", (A.VIEW, A.CREATE, A.EDIT, A.EXECUTE)),
|
||||||
@@ -162,7 +202,7 @@ ROLE_PRESETS: tuple[RolePreset, ...] = (
|
|||||||
RolePreset(
|
RolePreset(
|
||||||
key="support",
|
key="support",
|
||||||
name="Support",
|
name="Support",
|
||||||
description="Erste Hilfe am Telefon: Status sehen, PoE-Neustart ausloesen.",
|
description="Erste Hilfe am Telefon: Status sehen, PoE-Neustart auslösen.",
|
||||||
grants=(
|
grants=(
|
||||||
("devices", (A.VIEW, A.EXECUTE)),
|
("devices", (A.VIEW, A.EXECUTE)),
|
||||||
("switches", (A.VIEW,)),
|
("switches", (A.VIEW,)),
|
||||||
@@ -184,7 +224,7 @@ ROLE_PRESETS: tuple[RolePreset, ...] = (
|
|||||||
RolePreset(
|
RolePreset(
|
||||||
key="auditor",
|
key="auditor",
|
||||||
name="Revision",
|
name="Revision",
|
||||||
description="Nur lesen -- inklusive Aenderungsprotokoll und Diagnose.",
|
description="Nur lesen – inklusive Änderungsprotokoll und Diagnose.",
|
||||||
grants=(
|
grants=(
|
||||||
("devices", (A.VIEW,)),
|
("devices", (A.VIEW,)),
|
||||||
("switches", (A.VIEW,)),
|
("switches", (A.VIEW,)),
|
||||||
@@ -202,7 +242,7 @@ ROLE_PRESETS: tuple[RolePreset, ...] = (
|
|||||||
RolePreset(
|
RolePreset(
|
||||||
key="administration",
|
key="administration",
|
||||||
name="Administration",
|
name="Administration",
|
||||||
description="Benutzer, Gruppen und Einstellungen -- ohne Admin-Kennzeichen.",
|
description="Benutzer, Gruppen und Einstellungen – ohne Admin-Kennzeichen.",
|
||||||
grants=(
|
grants=(
|
||||||
("users", "*"),
|
("users", "*"),
|
||||||
("groups", "*"),
|
("groups", "*"),
|
||||||
|
|||||||
@@ -250,8 +250,133 @@ CREATE INDEX IF NOT EXISTS idx_restart_events_method ON restart_events(method, t
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
# Neue Rechte sind nach einem Update bei niemandem gesetzt -- ohne diesen
|
||||||
|
# Schritt koennte niemand mehr herunterladen oder ein Formular speichern,
|
||||||
|
# obwohl sich an den Gruppen nichts geaendert hat. Die Zuordnung bildet ab, was
|
||||||
|
# vorher galt.
|
||||||
|
V4 = """
|
||||||
|
INSERT OR IGNORE INTO group_permissions (group_id, permission)
|
||||||
|
SELECT group_id, 'fileshare.export' FROM group_permissions
|
||||||
|
WHERE permission = 'fileshare.view';
|
||||||
|
|
||||||
|
INSERT OR IGNORE INTO group_permissions (group_id, permission)
|
||||||
|
SELECT group_id, 'fileshare_pdf.view' FROM group_permissions
|
||||||
|
WHERE permission = 'fileshare.view';
|
||||||
|
|
||||||
|
INSERT OR IGNORE INTO group_permissions (group_id, permission)
|
||||||
|
SELECT group_id, 'fileshare_pdf.create' FROM group_permissions
|
||||||
|
WHERE permission = 'fileshare.create';
|
||||||
|
|
||||||
|
INSERT OR IGNORE INTO group_permissions (group_id, permission)
|
||||||
|
SELECT group_id, 'fileshare_pdf.edit' FROM group_permissions
|
||||||
|
WHERE permission = 'fileshare.edit';
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
# Aus einem Schalter werden zwei. Wer die Konsole abgeschaltet hatte, soll sie
|
||||||
|
# nicht durch ein Update zurueckbekommen -- der alte Wert gilt deshalb fuer
|
||||||
|
# beide weiter. Fehlt er, greift die Vorgabe der Einstellung.
|
||||||
|
V5 = """
|
||||||
|
INSERT OR IGNORE INTO settings (key, value, updated_at, updated_by)
|
||||||
|
SELECT 'terminal_switches_enabled', value, datetime('now'), 'migration'
|
||||||
|
FROM settings WHERE key = 'terminal_enabled';
|
||||||
|
|
||||||
|
INSERT OR IGNORE INTO settings (key, value, updated_at, updated_by)
|
||||||
|
SELECT 'terminal_clients_enabled', value, datetime('now'), 'migration'
|
||||||
|
FROM settings WHERE key = 'terminal_enabled';
|
||||||
|
|
||||||
|
DELETE FROM settings WHERE key = 'terminal_enabled';
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
# Wartung ist ein eigener Bereich geworden und hat damit einen eigenen
|
||||||
|
# Kill-Switch. Ohne diesen Schritt waere sie nach dem Update bei allen weg:
|
||||||
|
# das neue Bereichsrecht ist bei niemandem gesetzt, und ohne Bereichsrecht
|
||||||
|
# greift kein Recht darunter.
|
||||||
|
#
|
||||||
|
# Bewusst nicht "alle, die ein Wartungsrecht haben": wer den Bereich
|
||||||
|
# Ueberwachung nicht hatte, kam auch vorher nicht an die Wartung. Ein Update
|
||||||
|
# darf Zugang erhalten, aber keinen neuen schaffen.
|
||||||
|
V6 = """
|
||||||
|
INSERT OR IGNORE INTO group_permissions (group_id, permission)
|
||||||
|
SELECT DISTINCT group_id, 'servicing.view'
|
||||||
|
FROM group_permissions
|
||||||
|
WHERE permission IN ('maintenance.view', 'maintenance.execute')
|
||||||
|
AND group_id IN (
|
||||||
|
SELECT group_id FROM group_permissions WHERE permission = 'monitoring.view'
|
||||||
|
);
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
# Die Pruefung "unveraendert seit der Unterschrift" hing am Aenderungsprotokoll
|
||||||
|
# -- und das wird ab 20.000 Zeilen tageweise in Dateien ausgelagert. Mit dem
|
||||||
|
# ausgelagerten Tag waere die Pruefsumme aus der Datenbank verschwunden und
|
||||||
|
# jede aeltere unterschriebene Fassung waere still zu "unbekannt" geworden.
|
||||||
|
# Schlimmer noch: auch eine **veraenderte** Fassung, denn ohne Vergleichswert
|
||||||
|
# gibt es keinen Widerspruch.
|
||||||
|
#
|
||||||
|
# Deshalb eine eigene, winzige Tabelle: eine Zeile je Unterschrift, nie
|
||||||
|
# archiviert. Sie ist ein Verzeichnis, keine Wahrheit -- die Wahrheit bleibt
|
||||||
|
# das Aenderungsprotokoll mit seiner Hash-Kette, auch im Archiv.
|
||||||
|
V7 = """
|
||||||
|
CREATE TABLE IF NOT EXISTS document_signatures (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
sha256 TEXT NOT NULL,
|
||||||
|
share_label TEXT NOT NULL DEFAULT '',
|
||||||
|
path TEXT NOT NULL DEFAULT '',
|
||||||
|
signed_at TEXT NOT NULL,
|
||||||
|
actor TEXT NOT NULL DEFAULT '',
|
||||||
|
names TEXT NOT NULL DEFAULT '',
|
||||||
|
audit_id INTEGER
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_document_signatures_sha
|
||||||
|
ON document_signatures(sha256);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_document_signatures_ort
|
||||||
|
ON document_signatures(share_label, path);
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
def uebernehme_signaturen(conn) -> None: # type: ignore[no-untyped-def]
|
||||||
|
"""Traegt die bereits protokollierten Unterschriften ins Verzeichnis ein.
|
||||||
|
|
||||||
|
Ohne diesen Schritt waeren alle vor dem Update unterschriebenen Fassungen
|
||||||
|
ab sofort "unbekannt" -- richtig waere das nicht, die Eintraege stehen ja
|
||||||
|
noch da.
|
||||||
|
"""
|
||||||
|
from .services import fileshare as service
|
||||||
|
|
||||||
|
zeilen = conn.execute(
|
||||||
|
"SELECT id, ts, actor, detail FROM audit_log WHERE action='fileshare.form_signed' "
|
||||||
|
"ORDER BY id"
|
||||||
|
).fetchall()
|
||||||
|
for zeile in zeilen:
|
||||||
|
detail = str(zeile["detail"] or "")
|
||||||
|
gelesen = service.signaturdetail_lesen(detail)
|
||||||
|
if not gelesen["sha256"]:
|
||||||
|
continue
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO document_signatures (sha256, share_label, path, signed_at, actor, "
|
||||||
|
"names, audit_id) VALUES (?,?,?,?,?,?,?)",
|
||||||
|
(
|
||||||
|
gelesen["sha256"],
|
||||||
|
gelesen["label"],
|
||||||
|
gelesen["pfad"],
|
||||||
|
str(zeile["ts"]),
|
||||||
|
str(zeile["actor"] or ""),
|
||||||
|
", ".join(gelesen["namen"]),
|
||||||
|
int(zeile["id"]),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
TESM_MIGRATIONS: tuple[Migration, ...] = (
|
TESM_MIGRATIONS: tuple[Migration, ...] = (
|
||||||
Migration(version=1, name="baseline", sql=V1),
|
Migration(version=1, name="baseline", sql=V1),
|
||||||
Migration(version=2, name="seed-dhcp-options", fn=seed_dhcp_options),
|
Migration(version=2, name="seed-dhcp-options", fn=seed_dhcp_options),
|
||||||
Migration(version=3, name="restart-events", sql=V3),
|
Migration(version=3, name="restart-events", sql=V3),
|
||||||
|
Migration(version=4, name="fileshare-pdf-rechte", sql=V4),
|
||||||
|
Migration(version=5, name="terminal-getrennt", sql=V5),
|
||||||
|
Migration(version=6, name="wartung-eigener-bereich", sql=V6),
|
||||||
|
Migration(version=7, name="signaturverzeichnis", sql=V7, fn=uebernehme_signaturen),
|
||||||
)
|
)
|
||||||
@@ -21,6 +21,9 @@ Sicherheitsrelevante Aenderungen gegenueber dem Vorgaenger:
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
import zlib
|
||||||
|
import json
|
||||||
import logging
|
import logging
|
||||||
import mimetypes
|
import mimetypes
|
||||||
import os
|
import os
|
||||||
@@ -32,7 +35,7 @@ import sqlite3
|
|||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from datetime import datetime, timedelta, timezone
|
from datetime import datetime, timedelta, timezone
|
||||||
from pathlib import Path, PurePosixPath
|
from pathlib import Path, PurePosixPath
|
||||||
from typing import Any, Iterable
|
from typing import Any, Iterable, Mapping
|
||||||
|
|
||||||
from tesm_core.db import Database
|
from tesm_core.db import Database
|
||||||
from tesm_core.extension import core
|
from tesm_core.extension import core
|
||||||
@@ -40,7 +43,7 @@ from tesm_core.extension import core
|
|||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
_UNC_HINT = (
|
_UNC_HINT = (
|
||||||
"Der Freigabepfad muss die Form \\\\server\\freigabe haben. Schraegstriche "
|
"Der Freigabepfad muss die Form \\\\server\\freigabe haben. Schrägstriche "
|
||||||
"gehen auch: //server/freigabe."
|
"gehen auch: //server/freigabe."
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -193,11 +196,11 @@ def resolve_unc_host(unc: str, suffixes: Iterable[str]) -> tuple[str, str]:
|
|||||||
kandidat = f"{host}.{suffix.strip('.')}"
|
kandidat = f"{host}.{suffix.strip('.')}"
|
||||||
if _resolvable(kandidat):
|
if _resolvable(kandidat):
|
||||||
neu = chr(92) * 2 + kandidat + (chr(92) + rest if rest else "")
|
neu = chr(92) * 2 + kandidat + (chr(92) + rest if rest else "")
|
||||||
return neu, f"Name {host} ueber {kandidat} aufgeloest"
|
return neu, f"Name {host} über {kandidat} aufgelöst"
|
||||||
return unc, (
|
return unc, (
|
||||||
f"Der Name {host} ist auf diesem Server nicht aufloesbar. Bitte den "
|
f"Der Name {host} ist auf diesem Server nicht auflösbar. Bitte den "
|
||||||
"vollqualifizierten Namen oder die IP-Adresse eintragen -- oder unter "
|
"vollqualifizierten Namen oder die IP-Adresse eintragen – oder unter "
|
||||||
"Netzwerk eine Suchdomaene setzen."
|
"Netzwerk eine Suchdomäne setzen."
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -207,6 +210,50 @@ def list_mappings(conn: sqlite3.Connection) -> list[dict[str, Any]]:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
#: Felder einer Zuordnung mit der Beschriftung, unter der sie in der
|
||||||
|
#: Oberflaeche stehen. Das Protokoll soll dieselben Woerter benutzen wie das
|
||||||
|
#: Formular -- sonst muss man beim Lesen uebersetzen.
|
||||||
|
MAPPING_FELDER: tuple[tuple[str, str], ...] = (
|
||||||
|
("label", "Anzeigename"),
|
||||||
|
("unc", "Pfad"),
|
||||||
|
("ad_group_name", "Gruppe"),
|
||||||
|
("ad_group_dn", "Gruppen-DN"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def get_mapping(conn: sqlite3.Connection, mapping_id: int) -> dict[str, Any] | None:
|
||||||
|
return Database.one(conn, "SELECT * FROM fileshare_mappings WHERE id=?", (mapping_id,))
|
||||||
|
|
||||||
|
|
||||||
|
def describe_mapping(row: Mapping[str, Any] | None) -> str:
|
||||||
|
"""Eine Zuordnung in einer Zeile -- fuer das Protokoll.
|
||||||
|
|
||||||
|
Ohne sie stand beim Loeschen nur die Nummer der Zuordnung im Protokoll.
|
||||||
|
Die ist nach dem Loeschen nirgends mehr nachschlagbar; damit war der
|
||||||
|
Eintrag da, aber wertlos.
|
||||||
|
"""
|
||||||
|
if row is None:
|
||||||
|
return ""
|
||||||
|
teile = [str(row["unc"] or "")]
|
||||||
|
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)
|
||||||
|
|
||||||
|
|
||||||
|
def mapping_changes(vorher: Mapping[str, Any] | None, nachher: Mapping[str, Any] | None) -> str:
|
||||||
|
"""Was sich geaendert hat, Feld fuer Feld: ``Anzeigename: alt -> neu``."""
|
||||||
|
if vorher is None or nachher is None:
|
||||||
|
return ""
|
||||||
|
geaendert = []
|
||||||
|
for feld, beschriftung in MAPPING_FELDER:
|
||||||
|
alt = str(vorher[feld] or "")
|
||||||
|
neu = str(nachher[feld] or "")
|
||||||
|
if alt != neu:
|
||||||
|
geaendert.append(f"{beschriftung}: {alt or '(leer)'} -> {neu or '(leer)'}")
|
||||||
|
return "; ".join(geaendert)
|
||||||
|
|
||||||
|
|
||||||
def add_mapping(
|
def add_mapping(
|
||||||
conn: sqlite3.Connection, *, ad_group_dn: str, ad_group_name: str, label: str, unc: str
|
conn: sqlite3.Connection, *, ad_group_dn: str, ad_group_name: str, label: str, unc: str
|
||||||
) -> int:
|
) -> int:
|
||||||
@@ -215,7 +262,7 @@ def add_mapping(
|
|||||||
if not clean_label:
|
if not clean_label:
|
||||||
raise FileshareError("Der Anzeigename darf nicht leer sein.")
|
raise FileshareError("Der Anzeigename darf nicht leer sein.")
|
||||||
if not (ad_group_dn or "").strip():
|
if not (ad_group_dn or "").strip():
|
||||||
raise FileshareError("Es ist keine Verzeichnisgruppe ausgewaehlt.")
|
raise FileshareError("Es ist keine Verzeichnisgruppe ausgewählt.")
|
||||||
cursor = conn.execute(
|
cursor = conn.execute(
|
||||||
"INSERT INTO fileshare_mappings (ad_group_dn, ad_group_name, label, unc, created_at) "
|
"INSERT INTO fileshare_mappings (ad_group_dn, ad_group_name, label, unc, created_at) "
|
||||||
"VALUES (?,?,?,?,?) ON CONFLICT(ad_group_dn, unc) DO NOTHING",
|
"VALUES (?,?,?,?,?) ON CONFLICT(ad_group_dn, unc) DO NOTHING",
|
||||||
@@ -247,7 +294,7 @@ def update_mapping(
|
|||||||
if not clean_label:
|
if not clean_label:
|
||||||
raise FileshareError("Der Anzeigename darf nicht leer sein.")
|
raise FileshareError("Der Anzeigename darf nicht leer sein.")
|
||||||
if not clean_dn:
|
if not clean_dn:
|
||||||
raise FileshareError("Es ist keine Verzeichnisgruppe ausgewaehlt.")
|
raise FileshareError("Es ist keine Verzeichnisgruppe ausgewählt.")
|
||||||
|
|
||||||
vorhanden = Database.one(
|
vorhanden = Database.one(
|
||||||
conn, "SELECT id FROM fileshare_mappings WHERE id=?", (mapping_id,)
|
conn, "SELECT id FROM fileshare_mappings WHERE id=?", (mapping_id,)
|
||||||
@@ -318,12 +365,32 @@ def mount_root() -> Path:
|
|||||||
return Path(str(core().settings.get(db(), "fileshare_mount_root") or "/mnt/tesm-shares"))
|
return Path(str(core().settings.get(db(), "fileshare_mount_root") or "/mnt/tesm-shares"))
|
||||||
|
|
||||||
|
|
||||||
def active_mounts(conn: sqlite3.Connection, user_id: int) -> list[Mount]:
|
def aktuelle_sitzung() -> str:
|
||||||
|
"""Die ID der laufenden Sitzung -- oder leer ausserhalb einer Anfrage."""
|
||||||
|
from flask import session
|
||||||
|
|
||||||
|
return str(getattr(session, "sid", "") or "")
|
||||||
|
|
||||||
|
|
||||||
|
def active_mounts(
|
||||||
|
conn: sqlite3.Connection, user_id: int, session_id: str | None = None
|
||||||
|
) -> list[Mount]:
|
||||||
|
"""Die offenen Einbindungen dieses Kontos.
|
||||||
|
|
||||||
|
Mit ``session_id`` nur die dieser Sitzung. Das braucht das Abmelden: wer
|
||||||
|
an zwei Geraeten angemeldet ist, hat je Geraet eine eigene Einbindung mit
|
||||||
|
eigenem Einhaengepunkt, und das Abmelden am einen darf das andere nicht
|
||||||
|
aushaengen.
|
||||||
|
"""
|
||||||
|
bedingung = "AND session_id = ? " if session_id else ""
|
||||||
|
werte: list[Any] = [user_id, _iso(_now())]
|
||||||
|
if session_id:
|
||||||
|
werte.insert(1, session_id)
|
||||||
rows = Database.all(
|
rows = Database.all(
|
||||||
conn,
|
conn,
|
||||||
"SELECT * FROM fileshare_mounts WHERE user_id=? AND released_at IS NULL "
|
"SELECT * FROM fileshare_mounts WHERE user_id=? " + bedingung
|
||||||
"AND expires_at > ? ORDER BY label COLLATE NOCASE",
|
+ "AND released_at IS NULL AND expires_at > ? ORDER BY label COLLATE NOCASE, id DESC",
|
||||||
(user_id, _iso(_now())),
|
tuple(werte),
|
||||||
)
|
)
|
||||||
return [
|
return [
|
||||||
Mount(
|
Mount(
|
||||||
@@ -344,6 +411,38 @@ def has_mounts(user_id: int) -> bool:
|
|||||||
return bool(active_mounts(db(), user_id))
|
return bool(active_mounts(db(), user_id))
|
||||||
|
|
||||||
|
|
||||||
|
def doppelte_zusammenfassen(
|
||||||
|
conn: sqlite3.Connection, mounts: list[Mount], session_id: str = ""
|
||||||
|
) -> list[Mount]:
|
||||||
|
"""Je Freigabe eine Karteikarte -- vorzugsweise die dieser Sitzung.
|
||||||
|
|
||||||
|
Wer an zwei Geraeten angemeldet ist, hat zwei Einbindungen derselben
|
||||||
|
Freigabe: eine je Sitzung, jede mit eigenem Einhaengepunkt. In der Leiste
|
||||||
|
stand sie deshalb doppelt, einmal "Share" und einmal wieder "Share" -- ohne
|
||||||
|
jeden Unterschied fuer den Betrachter.
|
||||||
|
|
||||||
|
Genommen wird die Einbindung der eigenen Sitzung. Nur wenn es die nicht
|
||||||
|
gibt (alte Zeile, Hintergrundprozess), bleibt eine fremde stehen.
|
||||||
|
"""
|
||||||
|
eigene: set[int] = set()
|
||||||
|
if session_id:
|
||||||
|
eigene = {
|
||||||
|
int(zeile["id"])
|
||||||
|
for zeile in Database.all(
|
||||||
|
conn,
|
||||||
|
"SELECT id FROM fileshare_mounts WHERE session_id=? AND released_at IS NULL",
|
||||||
|
(session_id,),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
gewaehlt: dict[str, Mount] = {}
|
||||||
|
for mount in mounts:
|
||||||
|
schluessel = mount.unc.lower()
|
||||||
|
vorhanden = gewaehlt.get(schluessel)
|
||||||
|
if vorhanden is None or (mount.id in eigene and vorhanden.id not in eigene):
|
||||||
|
gewaehlt[schluessel] = mount
|
||||||
|
return sorted(gewaehlt.values(), key=lambda m: m.label.lower())
|
||||||
|
|
||||||
|
|
||||||
def mount_for_login(
|
def mount_for_login(
|
||||||
conn: sqlite3.Connection,
|
conn: sqlite3.Connection,
|
||||||
*,
|
*,
|
||||||
@@ -363,7 +462,7 @@ def mount_for_login(
|
|||||||
extension = core()
|
extension = core()
|
||||||
sysops = extension.sysops
|
sysops = extension.sysops
|
||||||
if sysops is None or not sysops.available:
|
if sysops is None or not sysops.available:
|
||||||
return ["Systemhelfer nicht verfuegbar -- keine Einbindungen."]
|
return ["Systemhelfer nicht verfügbar – keine Einbindungen."]
|
||||||
|
|
||||||
dns = [dn for dn in group_dns if dn and dn.strip()]
|
dns = [dn for dn in group_dns if dn and dn.strip()]
|
||||||
shares = shares_for_groups(conn, dns)
|
shares = shares_for_groups(conn, dns)
|
||||||
@@ -386,9 +485,41 @@ def mount_for_login(
|
|||||||
# eingebunden, aber leer. Das betrifft die Anmeldung mit zweitem Faktor, bei
|
# eingebunden, aber leer. Das betrifft die Anmeldung mit zweitem Faktor, bei
|
||||||
# der das Passwort im zweiten Schritt nicht mehr vorliegt.
|
# der das Passwort im zweiten Schritt nicht mehr vorliegt.
|
||||||
if not password:
|
if not password:
|
||||||
return ["Kein Passwort fuer den Fileserver verfuegbar -- keine Einbindungen."]
|
return ["Kein Passwort für den Fileserver verfügbar – keine Einbindungen."]
|
||||||
|
|
||||||
|
# Was zuerst kommt: das Ende der Sitzung oder die eigene Obergrenze. Die
|
||||||
|
# Sitzung ist die eigentliche Klammer -- mit ihr endet die Einbindung; die
|
||||||
|
# Obergrenze sorgt dafuer, dass eine sehr lange Sitzung nicht beliebig
|
||||||
|
# lange auf fremden Zugangsdaten sitzt.
|
||||||
hours = int(extension.settings.get(conn, "fileshare_max_age_hours") or 12)
|
hours = int(extension.settings.get(conn, "fileshare_max_age_hours") or 12)
|
||||||
|
frist = _now() + timedelta(hours=hours)
|
||||||
|
# Die Sitzungszeile gibt es hier noch nicht: eingebunden wird waehrend der
|
||||||
|
# Anmeldung, geschrieben wird sie erst beim Speichern der Antwort. Die
|
||||||
|
# Dauer steht aber in derselben Einstellung, aus der die Sitzung sie
|
||||||
|
# gleich bekommt -- und falls die Zeile doch schon steht (erneutes
|
||||||
|
# Einbinden), gilt deren Ende.
|
||||||
|
zeile = (
|
||||||
|
Database.one(
|
||||||
|
conn, "SELECT absolute_expires_at FROM user_sessions WHERE id=?", (session_id,)
|
||||||
|
)
|
||||||
|
if session_id
|
||||||
|
else None
|
||||||
|
)
|
||||||
|
if zeile is not None and zeile["absolute_expires_at"]:
|
||||||
|
try:
|
||||||
|
sitzungsende = datetime.fromisoformat(str(zeile["absolute_expires_at"]))
|
||||||
|
if sitzungsende.tzinfo is None:
|
||||||
|
sitzungsende = sitzungsende.replace(tzinfo=timezone.utc)
|
||||||
|
frist = min(frist, sitzungsende)
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
else:
|
||||||
|
try:
|
||||||
|
dauer = int(extension.settings.get(conn, "session_absolute_hours") or 0)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
dauer = 0
|
||||||
|
if dauer > 0:
|
||||||
|
frist = min(frist, _now() + timedelta(hours=dauer))
|
||||||
root = Path(str(extension.settings.get(conn, "fileshare_mount_root") or "/mnt/tesm-shares"))
|
root = Path(str(extension.settings.get(conn, "fileshare_mount_root") or "/mnt/tesm-shares"))
|
||||||
messages: list[str] = []
|
messages: list[str] = []
|
||||||
|
|
||||||
@@ -418,7 +549,7 @@ def mount_for_login(
|
|||||||
str(share["unc"]),
|
str(share["unc"]),
|
||||||
str(target),
|
str(target),
|
||||||
_iso(_now()),
|
_iso(_now()),
|
||||||
_iso(_now() + timedelta(hours=hours)),
|
_iso(frist),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
messages.append(f"{share['label']}: eingebunden")
|
messages.append(f"{share['label']}: eingebunden")
|
||||||
@@ -455,14 +586,30 @@ def forget_all_mounts(conn: sqlite3.Connection) -> int:
|
|||||||
return int(cursor.rowcount or 0)
|
return int(cursor.rowcount or 0)
|
||||||
|
|
||||||
|
|
||||||
def release_all_for_user(conn: sqlite3.Connection, user_id: int) -> int:
|
def release_all_for_user(conn: sqlite3.Connection, user_id: int, session_id: str = "") -> int:
|
||||||
|
"""Loest die Einbindungen -- ohne Sitzung die des ganzen Kontos.
|
||||||
|
|
||||||
|
Beim Abmelden wird die Sitzung uebergeben: das andere Geraet desselben
|
||||||
|
Benutzers arbeitet weiter, seine Einbindung bleibt. Ohne Sitzung (etwa
|
||||||
|
beim Loeschen eines Kontos) faellt alles.
|
||||||
|
"""
|
||||||
count = 0
|
count = 0
|
||||||
for mount in active_mounts(conn, user_id):
|
for mount in active_mounts(conn, user_id, session_id):
|
||||||
|
if session_id and not _gehoert_zur_sitzung(conn, mount.id, session_id):
|
||||||
|
continue
|
||||||
release_mount(conn, mount.id)
|
release_mount(conn, mount.id)
|
||||||
count += 1
|
count += 1
|
||||||
return count
|
return count
|
||||||
|
|
||||||
|
|
||||||
|
def _gehoert_zur_sitzung(conn: sqlite3.Connection, mount_id: int, session_id: str) -> bool:
|
||||||
|
"""Zeilen ohne Sitzung werden beim Abmelden nicht mitgerissen."""
|
||||||
|
zeile = Database.one(
|
||||||
|
conn, "SELECT session_id FROM fileshare_mounts WHERE id=?", (mount_id,)
|
||||||
|
)
|
||||||
|
return zeile is not None and str(zeile["session_id"] or "") == session_id
|
||||||
|
|
||||||
|
|
||||||
def sweep_expired(database: Any) -> int:
|
def sweep_expired(database: Any) -> int:
|
||||||
"""Loest abgelaufene Einbindungen -- laeuft im Hintergrundprozess.
|
"""Loest abgelaufene Einbindungen -- laeuft im Hintergrundprozess.
|
||||||
|
|
||||||
@@ -473,7 +620,10 @@ def sweep_expired(database: Any) -> int:
|
|||||||
released = 0
|
released = 0
|
||||||
with database.session() as conn:
|
with database.session() as conn:
|
||||||
# Eine Einbindung lebt genau so lange wie die Sitzung, aus der sie
|
# Eine Einbindung lebt genau so lange wie die Sitzung, aus der sie
|
||||||
# entstanden ist. Vorher hing sie an einer eigenen Frist von zwoelf
|
# entstanden ist. Ohne Sitzungsangabe gilt allein die eigene Frist:
|
||||||
|
# "keine Sitzung eingetragen" ist etwas anderes als "Sitzung
|
||||||
|
# abgelaufen". Diese Verwechslung hat jede frische Einbindung binnen
|
||||||
|
# Minuten wieder geloest, weil die ID beim Anmelden noch nicht stand. Vorher hing sie an einer eigenen Frist von zwoelf
|
||||||
# Stunden: nach dem Abmelden -- oder nach der automatischen Abmeldung
|
# Stunden: nach dem Abmelden -- oder nach der automatischen Abmeldung
|
||||||
# bei Leerlauf -- blieb die Freigabe mit den Zugangsdaten des Benutzers
|
# bei Leerlauf -- blieb die Freigabe mit den Zugangsdaten des Benutzers
|
||||||
# weiter eingebunden. Die eigene Frist bleibt als Obergrenze bestehen;
|
# weiter eingebunden. Die eigene Frist bleibt als Obergrenze bestehen;
|
||||||
@@ -486,10 +636,11 @@ def sweep_expired(database: Any) -> int:
|
|||||||
LEFT JOIN user_sessions s ON s.id = m.session_id
|
LEFT JOIN user_sessions s ON s.id = m.session_id
|
||||||
WHERE m.released_at IS NULL
|
WHERE m.released_at IS NULL
|
||||||
AND (m.expires_at <= ?
|
AND (m.expires_at <= ?
|
||||||
OR s.id IS NULL
|
OR (COALESCE(m.session_id, '') <> ''
|
||||||
OR s.revoked_at IS NOT NULL
|
AND (s.id IS NULL
|
||||||
OR s.idle_expires_at <= ?
|
OR s.revoked_at IS NOT NULL
|
||||||
OR s.absolute_expires_at <= ?)
|
OR s.idle_expires_at <= ?
|
||||||
|
OR s.absolute_expires_at <= ?)))
|
||||||
""",
|
""",
|
||||||
(_iso(_now()), _iso(_now()), _iso(_now())),
|
(_iso(_now()), _iso(_now()), _iso(_now())),
|
||||||
)
|
)
|
||||||
@@ -515,14 +666,260 @@ def resolve_path(mount: Mount, relative: str) -> Path:
|
|||||||
"""
|
"""
|
||||||
candidate = PurePosixPath((relative or "").strip().lstrip("/"))
|
candidate = PurePosixPath((relative or "").strip().lstrip("/"))
|
||||||
if any(part == ".." for part in candidate.parts):
|
if any(part == ".." for part in candidate.parts):
|
||||||
raise FileshareError("Ungueltiger Pfad.")
|
raise FileshareError("Ungültiger Pfad.")
|
||||||
root = mount.path.resolve()
|
root = mount.path.resolve()
|
||||||
target = (root / str(candidate)).resolve()
|
target = (root / str(candidate)).resolve()
|
||||||
if target != root and root not in target.parents:
|
if target != root and root not in target.parents:
|
||||||
raise FileshareError("Der Pfad liegt ausserhalb der Freigabe.")
|
raise FileshareError("Der Pfad liegt außerhalb der Freigabe.")
|
||||||
return target
|
return target
|
||||||
|
|
||||||
|
|
||||||
|
#: Wie eine unterschriebene Fassung im Aenderungsprotokoll steht. Die
|
||||||
|
#: Pruefsumme ist der Anker -- sie steht im selben Eintrag wie der Name, den
|
||||||
|
#: der Unterschreibende angegeben hat.
|
||||||
|
SIGNATUR_AKTION = "fileshare.form_signed"
|
||||||
|
|
||||||
|
|
||||||
|
def pruefsumme(pfad: Path) -> str:
|
||||||
|
"""SHA-256 der Datei, blockweise gelesen."""
|
||||||
|
kessel = hashlib.sha256()
|
||||||
|
with pfad.open("rb") as strom:
|
||||||
|
for block in iter(lambda: strom.read(1024 * 1024), b""):
|
||||||
|
kessel.update(block)
|
||||||
|
return kessel.hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
def signaturdetail_lesen(detail: str) -> dict[str, Any]:
|
||||||
|
"""Zerlegt die Protokollzeile einer Unterschrift.
|
||||||
|
|
||||||
|
Aufbau: ``<Freigabe>:<Pfad> sha256=<Summe> -- angegeben unterschrieben
|
||||||
|
von: <Namen>``. Gelesen wird sie an zwei Stellen -- beim Uebernehmen
|
||||||
|
alter Eintraege und wenn das Verzeichnis nichts weiss.
|
||||||
|
"""
|
||||||
|
# Der Trenner ist heute ein Zeilenumbruch, frueher ein Leerzeichen -- die
|
||||||
|
# Zerlegung muss beides vertragen, sonst sind alte Eintraege stumm.
|
||||||
|
ort, _, rest = detail.partition("sha256=")
|
||||||
|
ort = ort.strip()
|
||||||
|
label, _, pfad = ort.partition(":")
|
||||||
|
summe = rest.split(" ", 1)[0].strip() if rest else ""
|
||||||
|
return {
|
||||||
|
"sha256": summe,
|
||||||
|
"label": label.strip(),
|
||||||
|
"pfad": pfad.strip(),
|
||||||
|
"namen": _namen_aus_detail(detail),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def signatur_merken(
|
||||||
|
conn: sqlite3.Connection,
|
||||||
|
*,
|
||||||
|
sha256: str,
|
||||||
|
label: str,
|
||||||
|
pfad: str,
|
||||||
|
actor: str,
|
||||||
|
namen: Iterable[str],
|
||||||
|
audit_id: int | None = None,
|
||||||
|
) -> None:
|
||||||
|
"""Haelt eine Unterschrift im Verzeichnis fest.
|
||||||
|
|
||||||
|
Das Aenderungsprotokoll bleibt der Beleg -- mit Hash-Kette und, nach der
|
||||||
|
Archivierung, in einer Datei. Dieses Verzeichnis ist nur der schnelle Weg
|
||||||
|
dorthin und wird nie ausgelagert; eine Zeile misst ein paar hundert Byte.
|
||||||
|
"""
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO document_signatures (sha256, share_label, path, signed_at, actor, "
|
||||||
|
"names, audit_id) VALUES (?,?,?,?,?,?,?)",
|
||||||
|
(
|
||||||
|
sha256,
|
||||||
|
label,
|
||||||
|
pfad,
|
||||||
|
_iso(_now()),
|
||||||
|
actor,
|
||||||
|
", ".join(namen),
|
||||||
|
audit_id,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def signaturjournal() -> Path:
|
||||||
|
"""Wo das zweite Exemplar liegt: neben dem Archiv des Protokolls."""
|
||||||
|
return core().audit_archive_dir / "signaturen.jsonl"
|
||||||
|
|
||||||
|
|
||||||
|
def signatur_journalisieren(
|
||||||
|
*, sha256: str, label: str, pfad: str, actor: str, namen: Iterable[str]
|
||||||
|
) -> None:
|
||||||
|
"""Schreibt die Unterschrift zusaetzlich in eine Datei -- nur angehaengt.
|
||||||
|
|
||||||
|
Zwei Exemplare an zwei Orten: die Tabelle beantwortet die Frage schnell,
|
||||||
|
diese Datei ueberlebt auch eine zurueckgespielte aeltere Datenbank. Genau
|
||||||
|
das ist der Fall, den eine Sicherung erzeugt: die Datei auf der Freigabe
|
||||||
|
ist von gestern, die Datenbank von vorgestern -- ohne das Journal waere
|
||||||
|
die Unterschrift dazwischen verloren.
|
||||||
|
|
||||||
|
Ein Fehlschlag hier darf das Speichern nicht kippen: die Datei liegt
|
||||||
|
bereits auf der Freigabe, und Tabelle wie Aenderungsprotokoll haben den
|
||||||
|
Eintrag. Er wird protokolliert, nicht geworfen.
|
||||||
|
"""
|
||||||
|
eintrag = {
|
||||||
|
"ts": _iso(_now()),
|
||||||
|
"sha256": sha256,
|
||||||
|
"share": label,
|
||||||
|
"path": pfad,
|
||||||
|
"actor": actor,
|
||||||
|
"names": list(namen),
|
||||||
|
}
|
||||||
|
try:
|
||||||
|
ziel = signaturjournal()
|
||||||
|
ziel.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
neu = not ziel.exists()
|
||||||
|
with ziel.open("a", encoding="utf-8", newline=chr(10)) as strom:
|
||||||
|
strom.write(json.dumps(eintrag, ensure_ascii=False, sort_keys=True) + chr(10))
|
||||||
|
strom.flush()
|
||||||
|
# Ohne fsync steht die Zeile im Puffer des Kernels -- genau der
|
||||||
|
# Zustand, gegen den dieses Journal geschrieben wurde.
|
||||||
|
os.fsync(strom.fileno())
|
||||||
|
if neu:
|
||||||
|
try:
|
||||||
|
os.chmod(ziel, 0o640)
|
||||||
|
except OSError: # pragma: no cover - andere Dateisysteme
|
||||||
|
pass
|
||||||
|
except OSError as fehler: # pragma: no cover - Plattenfehler
|
||||||
|
logger.warning("Signaturjournal nicht geschrieben: %s", fehler)
|
||||||
|
|
||||||
|
|
||||||
|
def signaturen_aus_journal(conn: sqlite3.Connection) -> int:
|
||||||
|
"""Traegt fehlende Unterschriften aus dem Journal in die Tabelle ein.
|
||||||
|
|
||||||
|
Der Weg zurueck nach einer Wiederherstellung. Vorhandene bleiben, wie sie
|
||||||
|
sind -- verglichen wird ueber Pruefsumme und Ablageort.
|
||||||
|
"""
|
||||||
|
quelle = signaturjournal()
|
||||||
|
if not quelle.is_file():
|
||||||
|
return 0
|
||||||
|
uebernommen = 0
|
||||||
|
with quelle.open("r", encoding="utf-8") as strom:
|
||||||
|
for zeile in strom:
|
||||||
|
zeile = zeile.strip()
|
||||||
|
if not zeile:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
eintrag = json.loads(zeile)
|
||||||
|
except ValueError:
|
||||||
|
continue
|
||||||
|
vorhanden = Database.value(
|
||||||
|
conn,
|
||||||
|
"SELECT 1 FROM document_signatures WHERE sha256=? AND path=? LIMIT 1",
|
||||||
|
(str(eintrag.get("sha256", "")), str(eintrag.get("path", ""))),
|
||||||
|
)
|
||||||
|
if vorhanden:
|
||||||
|
continue
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO document_signatures (sha256, share_label, path, signed_at, "
|
||||||
|
"actor, names, audit_id) VALUES (?,?,?,?,?,?,NULL)",
|
||||||
|
(
|
||||||
|
str(eintrag.get("sha256", "")),
|
||||||
|
str(eintrag.get("share", "")),
|
||||||
|
str(eintrag.get("path", "")),
|
||||||
|
str(eintrag.get("ts", "")),
|
||||||
|
str(eintrag.get("actor", "")),
|
||||||
|
", ".join(eintrag.get("names", []) or []),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
uebernommen += 1
|
||||||
|
return uebernommen
|
||||||
|
|
||||||
|
|
||||||
|
def signaturstand(conn: sqlite3.Connection, mount: "Mount", relative: str) -> dict[str, Any]:
|
||||||
|
"""Ist diese Datei die, die unterschrieben wurde?
|
||||||
|
|
||||||
|
Drei moegliche Antworten, und die dritte ist die wichtigste:
|
||||||
|
|
||||||
|
* ``unveraendert`` -- es gibt einen Protokolleintrag mit genau dieser
|
||||||
|
Pruefsumme. Die Datei ist Byte fuer Byte die unterschriebene.
|
||||||
|
* ``veraendert`` -- es gibt einen Eintrag zu genau diesem Ablageort, aber
|
||||||
|
mit anderer Pruefsumme. Jemand hat nach der Unterschrift etwas an der
|
||||||
|
Datei geaendert. Das ist der Fall, wegen dem es diese Pruefung gibt.
|
||||||
|
* ``unbekannt`` -- kein Eintrag. Kein Verdacht: die meisten Dateien in
|
||||||
|
einer Freigabe sind nie durch TESM gegangen.
|
||||||
|
|
||||||
|
Nachgeschlagen wird im Signaturverzeichnis (``document_signatures``) und
|
||||||
|
nicht im Aenderungsprotokoll: das wird ab 20.000 Zeilen tageweise in
|
||||||
|
Dateien ausgelagert, und mit dem ausgelagerten Tag waere die Pruefsumme aus
|
||||||
|
der Datenbank verschwunden. Eine aeltere unterschriebene Fassung waere
|
||||||
|
still zu "unbekannt" geworden -- und eine **veraenderte** ebenso, weil ohne
|
||||||
|
Vergleichswert niemand widerspricht. Das Verzeichnis wird nie archiviert.
|
||||||
|
"""
|
||||||
|
ziel = resolve_path(mount, relative)
|
||||||
|
if not ziel.is_file():
|
||||||
|
return {"status": "unbekannt"}
|
||||||
|
hash_ = pruefsumme(ziel)
|
||||||
|
|
||||||
|
zeile = Database.one(
|
||||||
|
conn,
|
||||||
|
"SELECT signed_at, actor, names FROM document_signatures WHERE sha256=? "
|
||||||
|
"ORDER BY id DESC LIMIT 1",
|
||||||
|
(hash_,),
|
||||||
|
)
|
||||||
|
if zeile is not None:
|
||||||
|
return _stand("unveraendert", hash_, zeile)
|
||||||
|
|
||||||
|
# Nichts mit dieser Pruefsumme -- aber vielleicht etwas zu diesem Ort.
|
||||||
|
zeile = Database.one(
|
||||||
|
conn,
|
||||||
|
"SELECT signed_at, actor, names FROM document_signatures WHERE share_label=? "
|
||||||
|
"AND path=? ORDER BY id DESC LIMIT 1",
|
||||||
|
(mount.label, relative),
|
||||||
|
)
|
||||||
|
if zeile is not None:
|
||||||
|
return _stand("veraendert", hash_, zeile)
|
||||||
|
return {"status": "unbekannt", "sha256": hash_}
|
||||||
|
|
||||||
|
|
||||||
|
def _stand(status: str, hash_: str, zeile: Any) -> dict[str, Any]:
|
||||||
|
namen = [teil.strip() for teil in str(zeile["names"] or "").split(",") if teil.strip()]
|
||||||
|
return {
|
||||||
|
"status": status,
|
||||||
|
"sha256": hash_,
|
||||||
|
"zeit": str(zeile["signed_at"]),
|
||||||
|
"konto": str(zeile["actor"] or ""),
|
||||||
|
"namen": namen,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _namen_aus_detail(detail: str) -> list[str]:
|
||||||
|
"""Die angegebenen Namen aus dem Protokolltext -- ohne sie zu deuten."""
|
||||||
|
marke = "angegeben unterschrieben von: "
|
||||||
|
if marke not in detail:
|
||||||
|
return []
|
||||||
|
return [teil.strip() for teil in detail.split(marke, 1)[1].split(",") if teil.strip()]
|
||||||
|
|
||||||
|
|
||||||
|
#: Mehr als das zaehlt niemand mehr mit dem Auge -- und auf einer
|
||||||
|
#: Netzwerkfreigabe kostet jeder weitere Eintrag Zeit.
|
||||||
|
ZAEHLGRENZE = 500
|
||||||
|
|
||||||
|
|
||||||
|
def _inhalt_zaehlen(verzeichnis: Path) -> int:
|
||||||
|
"""Wie viele Eintraege liegen im Ordner? Begrenzt gezaehlt.
|
||||||
|
|
||||||
|
Gezeigt wird die Zahl an der Stelle, an der bei einer Datei die Groesse
|
||||||
|
steht -- dort stand bisher ein Gedankenstrich. Bei mehr als
|
||||||
|
``ZAEHLGRENZE`` bricht das Zaehlen ab: die Oberflaeche schreibt dann
|
||||||
|
"500+", und niemand wartet auf eine Freigabe mit 40.000 Dateien.
|
||||||
|
"""
|
||||||
|
anzahl = 0
|
||||||
|
try:
|
||||||
|
with os.scandir(verzeichnis) as eintraege:
|
||||||
|
for _ in eintraege:
|
||||||
|
anzahl += 1
|
||||||
|
if anzahl >= ZAEHLGRENZE:
|
||||||
|
break
|
||||||
|
except OSError:
|
||||||
|
return -1
|
||||||
|
return anzahl
|
||||||
|
|
||||||
|
|
||||||
def list_directory(mount: Mount, relative: str = "") -> dict[str, Any]:
|
def list_directory(mount: Mount, relative: str = "") -> dict[str, Any]:
|
||||||
path = resolve_path(mount, relative)
|
path = resolve_path(mount, relative)
|
||||||
if not path.is_dir():
|
if not path.is_dir():
|
||||||
@@ -544,6 +941,7 @@ def list_directory(mount: Mount, relative: str = "") -> dict[str, Any]:
|
|||||||
),
|
),
|
||||||
}
|
}
|
||||||
if entry.is_dir():
|
if entry.is_dir():
|
||||||
|
record["entries"] = _inhalt_zaehlen(entry)
|
||||||
directories.append(record)
|
directories.append(record)
|
||||||
else:
|
else:
|
||||||
record["previewable"] = entry.suffix.lower() in PREVIEW_MIME
|
record["previewable"] = entry.suffix.lower() in PREVIEW_MIME
|
||||||
@@ -574,7 +972,7 @@ def preview_mime(name: str) -> str:
|
|||||||
suffix = Path(name).suffix.lower()
|
suffix = Path(name).suffix.lower()
|
||||||
if suffix in PREVIEW_MIME:
|
if suffix in PREVIEW_MIME:
|
||||||
return PREVIEW_MIME[suffix]
|
return PREVIEW_MIME[suffix]
|
||||||
raise FileshareError("Fuer diesen Dateityp gibt es keine Vorschau -- bitte herunterladen.")
|
raise FileshareError("Für diesen Dateityp gibt es keine Vorschau – bitte herunterladen.")
|
||||||
|
|
||||||
|
|
||||||
def download_mime(name: str) -> str:
|
def download_mime(name: str) -> str:
|
||||||
@@ -586,7 +984,7 @@ def safe_filename(name: str) -> str:
|
|||||||
"""Verhindert Pfadanteile und versteckte Dateien in Uploads/Neuanlagen."""
|
"""Verhindert Pfadanteile und versteckte Dateien in Uploads/Neuanlagen."""
|
||||||
cleaned = os.path.basename((name or "").strip().replace("\\", "/"))
|
cleaned = os.path.basename((name or "").strip().replace("\\", "/"))
|
||||||
if not cleaned or cleaned in {".", ".."} or cleaned.startswith("."):
|
if not cleaned or cleaned in {".", ".."} or cleaned.startswith("."):
|
||||||
raise FileshareError("Ungueltiger Dateiname.")
|
raise FileshareError("Ungültiger Dateiname.")
|
||||||
if len(cleaned) > 200:
|
if len(cleaned) > 200:
|
||||||
raise FileshareError("Der Dateiname ist zu lang.")
|
raise FileshareError("Der Dateiname ist zu lang.")
|
||||||
return cleaned
|
return cleaned
|
||||||
@@ -622,6 +1020,29 @@ def all_directories(mount: Mount, *, tiefe: int = 4, grenze: int = 400) -> list[
|
|||||||
return gefunden
|
return gefunden
|
||||||
|
|
||||||
|
|
||||||
|
def hat_formularfelder(daten: bytes) -> bool:
|
||||||
|
"""Steckt in diesen PDF-Bytes noch ein ausfuellbares Formular?
|
||||||
|
|
||||||
|
Gesucht wird nach Formularfeldern (``/Subtype /Widget``) -- auch in
|
||||||
|
gepackten Objektstroemen, denn moderne Schreiber legen fast alles dort ab.
|
||||||
|
Ein flach gerechnetes Formular hat keine mehr.
|
||||||
|
|
||||||
|
Bewusst ohne Zusatzpaket: die Frage ist einfach genug, und eine
|
||||||
|
PDF-Bibliothek auf jedem Zielsystem waere ein hoher Preis dafuer.
|
||||||
|
"""
|
||||||
|
text = bytearray(daten)
|
||||||
|
for treffer in re.finditer(rb"stream\r?\n", daten):
|
||||||
|
start = treffer.end()
|
||||||
|
ende = daten.find(b"endstream", start)
|
||||||
|
if ende == -1:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
text += b"\n" + zlib.decompress(daten[start:ende])
|
||||||
|
except zlib.error:
|
||||||
|
continue # kein zlib-Strom -- Bilder, Schriften, alles moegliche
|
||||||
|
return re.search(rb"/Subtype\s*/Widget", bytes(text)) is not None
|
||||||
|
|
||||||
|
|
||||||
def free_name(parent: Path, stem: str, zusatz: str, suffix: str) -> Path:
|
def free_name(parent: Path, stem: str, zusatz: str, suffix: str) -> Path:
|
||||||
"""Ein freier Name neben dem Original: ``vorlage-ausgefuellt.pdf``.
|
"""Ein freier Name neben dem Original: ``vorlage-ausgefuellt.pdf``.
|
||||||
|
|
||||||
@@ -635,14 +1056,14 @@ def free_name(parent: Path, stem: str, zusatz: str, suffix: str) -> Path:
|
|||||||
kandidat = parent / f"{stem}{zusatz}-{zaehler}{suffix}"
|
kandidat = parent / f"{stem}{zusatz}-{zaehler}{suffix}"
|
||||||
zaehler += 1
|
zaehler += 1
|
||||||
if zaehler > 200:
|
if zaehler > 200:
|
||||||
raise FileshareError("Zu viele Dateien dieses Namens -- bitte aufraeumen.")
|
raise FileshareError("Zu viele Dateien dieses Namens – bitte aufräumen.")
|
||||||
return kandidat
|
return kandidat
|
||||||
|
|
||||||
|
|
||||||
def delete_entry(mount: Mount, relative: str) -> str:
|
def delete_entry(mount: Mount, relative: str) -> str:
|
||||||
path = resolve_path(mount, relative)
|
path = resolve_path(mount, relative)
|
||||||
if path == mount.path.resolve():
|
if path == mount.path.resolve():
|
||||||
raise FileshareError("Das Wurzelverzeichnis der Freigabe kann nicht geloescht werden.")
|
raise FileshareError("Das Wurzelverzeichnis der Freigabe kann nicht gelöscht werden.")
|
||||||
if path.is_dir():
|
if path.is_dir():
|
||||||
shutil.rmtree(path)
|
shutil.rmtree(path)
|
||||||
return "Verzeichnis"
|
return "Verzeichnis"
|
||||||
|
|||||||
@@ -54,13 +54,13 @@ def is_ssh_managed(category: str | None) -> bool:
|
|||||||
#: war "Windows-Client" vorher -- waehlbar, aber ohne jede Wirkung.
|
#: war "Windows-Client" vorher -- waehlbar, aber ohne jede Wirkung.
|
||||||
CATEGORY_EFFECTS: dict[str, str] = {
|
CATEGORY_EFFECTS: dict[str, str] = {
|
||||||
"switch": "Anmeldung an Switchen: PoE schalten, Konsole, Statusabfragen.",
|
"switch": "Anmeldung an Switchen: PoE schalten, Konsole, Statusabfragen.",
|
||||||
"linux": "Wartung ueber SSH: Paketaktualisierung (apt) und Neustart. Konsole.",
|
"linux": "Wartung über SSH: Paketaktualisierung (apt) und Neustart. Konsole.",
|
||||||
"windows": (
|
"windows": (
|
||||||
"Neustart ueber RPC (wie 'shutdown /m'). Kein SSH, kein Host-Schluessel. Das "
|
"Neustart über RPC (wie 'shutdown /m'). Kein SSH, kein Host-Schlüssel. Das "
|
||||||
"Geraet braucht offene Datei- und Druckerfreigabe (Port 445); das Konto braucht "
|
"Gerät braucht offene Datei- und Druckerfreigabe (Port 445); das Konto braucht "
|
||||||
"das Recht, aus der Ferne herunterzufahren. Paketaktualisierung gibt es hier nicht."
|
"das Recht, aus der Ferne herunterzufahren. Paketaktualisierung gibt es hier nicht."
|
||||||
),
|
),
|
||||||
"router": "Nur Dokumentation -- diese Kategorie loest keine Aktion aus.",
|
"router": "Nur Dokumentation – diese Kategorie löst keine Aktion aus.",
|
||||||
}
|
}
|
||||||
|
|
||||||
DEVICE_CATEGORIES: tuple[tuple[str, str], ...] = (
|
DEVICE_CATEGORIES: tuple[tuple[str, str], ...] = (
|
||||||
@@ -95,7 +95,7 @@ def normalize_mac(value: str) -> str:
|
|||||||
cleaned = re.sub(r"[^0-9a-fA-F]", "", value or "")
|
cleaned = re.sub(r"[^0-9a-fA-F]", "", value or "")
|
||||||
if len(cleaned) != 12:
|
if len(cleaned) != 12:
|
||||||
raise ValidationError(
|
raise ValidationError(
|
||||||
"Die MAC-Adresse muss zwoelf hexadezimale Zeichen enthalten "
|
"Die MAC-Adresse muss zwölf hexadezimale Zeichen enthalten "
|
||||||
"(z. B. aa:bb:cc:dd:ee:ff)."
|
"(z. B. aa:bb:cc:dd:ee:ff)."
|
||||||
)
|
)
|
||||||
return ":".join(cleaned[index : index + 2] for index in range(0, 12, 2)).lower()
|
return ":".join(cleaned[index : index + 2] for index in range(0, 12, 2)).lower()
|
||||||
@@ -106,7 +106,7 @@ def validate_ip(value: str, *, field: str = "IP-Adresse") -> str:
|
|||||||
try:
|
try:
|
||||||
ipaddress.ip_address(text)
|
ipaddress.ip_address(text)
|
||||||
except ValueError as exc:
|
except ValueError as exc:
|
||||||
raise ValidationError(f"{field}: {text!r} ist keine gueltige IP-Adresse.") from exc
|
raise ValidationError(f"{field}: {text!r} ist keine gültige IP-Adresse.") from exc
|
||||||
return text
|
return text
|
||||||
|
|
||||||
|
|
||||||
@@ -116,7 +116,7 @@ def validate_port_name(value: str) -> str:
|
|||||||
return ""
|
return ""
|
||||||
if not PORT_RE.match(text):
|
if not PORT_RE.match(text):
|
||||||
raise ValidationError(
|
raise ValidationError(
|
||||||
"Die Portbezeichnung enthaelt unerlaubte Zeichen (erlaubt: Buchstaben, "
|
"Die Portbezeichnung enthält unerlaubte Zeichen (erlaubt: Buchstaben, "
|
||||||
"Ziffern, / . : -)."
|
"Ziffern, / . : -)."
|
||||||
)
|
)
|
||||||
return text
|
return text
|
||||||
@@ -125,7 +125,7 @@ def validate_port_name(value: str) -> str:
|
|||||||
def validate_hostname(value: str) -> str:
|
def validate_hostname(value: str) -> str:
|
||||||
text = (value or "").strip()
|
text = (value or "").strip()
|
||||||
if not HOSTNAME_RE.match(text):
|
if not HOSTNAME_RE.match(text):
|
||||||
raise ValidationError("Der Hostname enthaelt unerlaubte Zeichen oder ist leer.")
|
raise ValidationError("Der Hostname enthält unerlaubte Zeichen oder ist leer.")
|
||||||
return text
|
return text
|
||||||
|
|
||||||
|
|
||||||
@@ -208,7 +208,7 @@ def create_credential(
|
|||||||
if existing["deleted_at"]:
|
if existing["deleted_at"]:
|
||||||
raise ValidationError(
|
raise ValidationError(
|
||||||
f"Zugangsdaten namens {clean_name!r} liegen im Papierkorb. Stellen Sie sie "
|
f"Zugangsdaten namens {clean_name!r} liegen im Papierkorb. Stellen Sie sie "
|
||||||
"wieder her oder loeschen Sie sie endgueltig."
|
"wieder her oder löschen Sie sie endgültig."
|
||||||
)
|
)
|
||||||
raise ValidationError(f"Zugangsdaten namens {clean_name!r} existieren bereits.")
|
raise ValidationError(f"Zugangsdaten namens {clean_name!r} existieren bereits.")
|
||||||
|
|
||||||
@@ -258,11 +258,11 @@ def update_credential(
|
|||||||
if clean_name != str(row["name"]):
|
if clean_name != str(row["name"]):
|
||||||
changes.append(f"Name: {row['name']} -> {clean_name}")
|
changes.append(f"Name: {row['name']} -> {clean_name}")
|
||||||
if (username or "").strip() != str(row["username"] or ""):
|
if (username or "").strip() != str(row["username"] or ""):
|
||||||
changes.append("Benutzer geaendert")
|
changes.append("Benutzer geändert")
|
||||||
if category != str(row["category"]):
|
if category != str(row["category"]):
|
||||||
changes.append(f"Kategorie: {row['category']} -> {category}")
|
changes.append(f"Kategorie: {row['category']} -> {category}")
|
||||||
if (description or "").strip() != str(row["description"] or ""):
|
if (description or "").strip() != str(row["description"] or ""):
|
||||||
changes.append("Beschreibung geaendert")
|
changes.append("Beschreibung geändert")
|
||||||
|
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"UPDATE credentials SET name=?, username=?, category=?, description=?, updated_at=?, "
|
"UPDATE credentials SET name=?, username=?, category=?, description=?, updated_at=?, "
|
||||||
@@ -274,7 +274,7 @@ def update_credential(
|
|||||||
"UPDATE credentials SET secret_encrypted=? WHERE id=?",
|
"UPDATE credentials SET secret_encrypted=? WHERE id=?",
|
||||||
(keystore.encrypt(secret, aad=aad_for_credential(credential_id)), credential_id),
|
(keystore.encrypt(secret, aad=aad_for_credential(credential_id)), credential_id),
|
||||||
)
|
)
|
||||||
changes.append("Passwort geaendert")
|
changes.append("Passwort geändert")
|
||||||
return changes
|
return changes
|
||||||
|
|
||||||
|
|
||||||
@@ -296,8 +296,8 @@ def credential_secret(
|
|||||||
secret = keystore.decrypt(encrypted, aad=aad_for_credential(int(credential_id)))
|
secret = keystore.decrypt(encrypted, aad=aad_for_credential(int(credential_id)))
|
||||||
except DecryptionError as exc:
|
except DecryptionError as exc:
|
||||||
raise ValidationError(
|
raise ValidationError(
|
||||||
"Das gespeicherte Passwort konnte nicht entschluesselt werden. Wurde der "
|
"Das gespeicherte Passwort konnte nicht entschlüsselt werden. Wurde der "
|
||||||
"Datenschluessel ausgetauscht? Bitte die Zugangsdaten neu eingeben."
|
"Datenschlüssel ausgetauscht? Bitte die Zugangsdaten neu eingeben."
|
||||||
) from exc
|
) from exc
|
||||||
return str(row["username"] or ""), secret
|
return str(row["username"] or ""), secret
|
||||||
|
|
||||||
@@ -315,7 +315,7 @@ def delete_credential(conn: sqlite3.Connection, credential_id: int) -> None:
|
|||||||
if int(row.get("switches") or 0) or int(row.get("devices") or 0):
|
if int(row.get("switches") or 0) or int(row.get("devices") or 0):
|
||||||
raise ValidationError(
|
raise ValidationError(
|
||||||
f"Diese Zugangsdaten werden noch von {row.get('switches')} Switch(en) und "
|
f"Diese Zugangsdaten werden noch von {row.get('switches')} Switch(en) und "
|
||||||
f"{row.get('devices')} Geraet(en) verwendet."
|
f"{row.get('devices')} Gerät(en) verwendet."
|
||||||
)
|
)
|
||||||
conn.execute("UPDATE credentials SET deleted_at=? WHERE id=?", (_now(), credential_id))
|
conn.execute("UPDATE credentials SET deleted_at=? WHERE id=?", (_now(), credential_id))
|
||||||
|
|
||||||
@@ -509,7 +509,7 @@ def delete_switch(conn: sqlite3.Connection, switch_id: int) -> None:
|
|||||||
)
|
)
|
||||||
if int(count or 0):
|
if int(count or 0):
|
||||||
raise ValidationError(
|
raise ValidationError(
|
||||||
f"Diesem Switch sind noch {count} Geraet(e) zugeordnet. Bitte zuerst umhaengen."
|
f"Diesem Switch sind noch {count} Gerät(e) zugeordnet. Bitte zuerst umhängen."
|
||||||
)
|
)
|
||||||
conn.execute("UPDATE switches SET deleted_at=? WHERE id=?", (_now(), switch_id))
|
conn.execute("UPDATE switches SET deleted_at=? WHERE id=?", (_now(), switch_id))
|
||||||
|
|
||||||
@@ -590,13 +590,13 @@ def create_device(
|
|||||||
)
|
)
|
||||||
if existing is not None:
|
if existing is not None:
|
||||||
if existing["deleted_at"]:
|
if existing["deleted_at"]:
|
||||||
raise ValidationError(f"Ein Geraet mit der MAC {clean_mac} liegt im Papierkorb.")
|
raise ValidationError(f"Ein Gerät mit der MAC {clean_mac} liegt im Papierkorb.")
|
||||||
raise ValidationError(f"Ein Geraet mit der MAC {clean_mac} existiert bereits.")
|
raise ValidationError(f"Ein Gerät mit der MAC {clean_mac} existiert bereits.")
|
||||||
|
|
||||||
if switch_id and not clean_port:
|
if switch_id and not clean_port:
|
||||||
raise ValidationError(
|
raise ValidationError(
|
||||||
"Zu einem zugeordneten Switch gehoert eine Portbezeichnung -- sonst ist kein "
|
"Zu einem zugeordneten Switch gehört eine Portbezeichnung – sonst ist kein "
|
||||||
"PoE-Neustart moeglich."
|
"PoE-Neustart möglich."
|
||||||
)
|
)
|
||||||
|
|
||||||
cursor = conn.execute(
|
cursor = conn.execute(
|
||||||
@@ -651,7 +651,7 @@ def update_device(
|
|||||||
conn, "SELECT * FROM devices WHERE id=? AND deleted_at IS NULL", (device_id,)
|
conn, "SELECT * FROM devices WHERE id=? AND deleted_at IS NULL", (device_id,)
|
||||||
)
|
)
|
||||||
if row is None:
|
if row is None:
|
||||||
raise ValidationError("Dieses Geraet existiert nicht (mehr).")
|
raise ValidationError("Dieses Gerät existiert nicht (mehr).")
|
||||||
clean_name = (name or "").strip()
|
clean_name = (name or "").strip()
|
||||||
if not clean_name:
|
if not clean_name:
|
||||||
raise ValidationError("Der Name darf nicht leer sein.")
|
raise ValidationError("Der Name darf nicht leer sein.")
|
||||||
@@ -664,9 +664,9 @@ def update_device(
|
|||||||
(clean_mac, device_id),
|
(clean_mac, device_id),
|
||||||
)
|
)
|
||||||
if clash is not None:
|
if clash is not None:
|
||||||
raise ValidationError(f"Ein Geraet mit der MAC {clean_mac} existiert bereits.")
|
raise ValidationError(f"Ein Gerät mit der MAC {clean_mac} existiert bereits.")
|
||||||
if switch_id and not clean_port:
|
if switch_id and not clean_port:
|
||||||
raise ValidationError("Zu einem zugeordneten Switch gehoert eine Portbezeichnung.")
|
raise ValidationError("Zu einem zugeordneten Switch gehört eine Portbezeichnung.")
|
||||||
|
|
||||||
changes: list[str] = []
|
changes: list[str] = []
|
||||||
for label, before, after in (
|
for label, before, after in (
|
||||||
@@ -677,6 +677,12 @@ def update_device(
|
|||||||
("Port", str(row["port"] or ""), clean_port),
|
("Port", str(row["port"] or ""), clean_port),
|
||||||
("Aktiv", str(bool(row["is_active"])), str(bool(is_active))),
|
("Aktiv", str(bool(row["is_active"])), str(bool(is_active))),
|
||||||
("Automatischer Neustart", str(bool(row["auto_restart"])), str(bool(auto_restart))),
|
("Automatischer Neustart", str(bool(row["auto_restart"])), str(bool(auto_restart))),
|
||||||
|
# Diese vier fehlten: eine geaenderte Kategorie oder ein anderer
|
||||||
|
# SSH-Port stand nirgends, obwohl beides das Verhalten aendert.
|
||||||
|
("SSH-Port", str(row["ssh_port"] or ""), str(validate_ssh_port(ssh_port) or "")),
|
||||||
|
("Zugangsdaten", str(row["credential_id"] or ""), str(credential_id or "")),
|
||||||
|
("Kategorie", str(row["category"] or ""), (category or "").strip()),
|
||||||
|
("Standort", str(row["location"] or ""), (location or "").strip()),
|
||||||
):
|
):
|
||||||
if before != after:
|
if before != after:
|
||||||
changes.append(f"{label}: {before or '-'} -> {after or '-'}")
|
changes.append(f"{label}: {before or '-'} -> {after or '-'}")
|
||||||
|
|||||||
@@ -46,7 +46,7 @@ STANDARD_OPTIONS: tuple[tuple[int, str, str, str], ...] = (
|
|||||||
(9, "lpr-servers", "ipv4-address", "LPR-Druckserver"),
|
(9, "lpr-servers", "ipv4-address", "LPR-Druckserver"),
|
||||||
(10, "impress-servers", "ipv4-address", "Impress-Server"),
|
(10, "impress-servers", "ipv4-address", "Impress-Server"),
|
||||||
(11, "resource-location-servers", "ipv4-address", "Resource Location Server"),
|
(11, "resource-location-servers", "ipv4-address", "Resource Location Server"),
|
||||||
(13, "boot-size", "uint16", "Boot-Image-Groesse (Bloecke)"),
|
(13, "boot-size", "uint16", "Boot-Image-Größe (Blöcke)"),
|
||||||
(14, "merit-dump", "string", "Crash-Dump-Datei"),
|
(14, "merit-dump", "string", "Crash-Dump-Datei"),
|
||||||
(16, "swap-server", "ipv4-address", "Swap-Server"),
|
(16, "swap-server", "ipv4-address", "Swap-Server"),
|
||||||
(17, "root-path", "string", "Root-Pfad (NFS)"),
|
(17, "root-path", "string", "Root-Pfad (NFS)"),
|
||||||
@@ -54,8 +54,8 @@ STANDARD_OPTIONS: tuple[tuple[int, str, str, str], ...] = (
|
|||||||
(19, "ip-forwarding", "boolean", "IP-Forwarding aktivieren"),
|
(19, "ip-forwarding", "boolean", "IP-Forwarding aktivieren"),
|
||||||
(20, "non-local-source-routing", "boolean", "Non-Local Source Routing"),
|
(20, "non-local-source-routing", "boolean", "Non-Local Source Routing"),
|
||||||
(21, "policy-filter", "string", "Policy Filter (Ziel,Maske,...)"),
|
(21, "policy-filter", "string", "Policy Filter (Ziel,Maske,...)"),
|
||||||
(22, "max-dgram-reassembly", "uint16", "Maximale Datagramm-Reassemblierungsgroesse"),
|
(22, "max-dgram-reassembly", "uint16", "Maximale Datagramm-Reassemblierungsgröße"),
|
||||||
(23, "default-ip-ttl", "uint8", "Standard-TTL fuer IP"),
|
(23, "default-ip-ttl", "uint8", "Standard-TTL für IP"),
|
||||||
(24, "path-mtu-aging-timeout", "uint32", "Path-MTU-Aging-Timeout (Sek.)"),
|
(24, "path-mtu-aging-timeout", "uint32", "Path-MTU-Aging-Timeout (Sek.)"),
|
||||||
(25, "path-mtu-plateau-table", "uint16", "Path-MTU-Plateau-Tabelle"),
|
(25, "path-mtu-plateau-table", "uint16", "Path-MTU-Plateau-Tabelle"),
|
||||||
(26, "interface-mtu", "uint16", "MTU des Interfaces"),
|
(26, "interface-mtu", "uint16", "MTU des Interfaces"),
|
||||||
@@ -69,7 +69,7 @@ STANDARD_OPTIONS: tuple[tuple[int, str, str, str], ...] = (
|
|||||||
(34, "trailer-encapsulation", "boolean", "Trailer Encapsulation"),
|
(34, "trailer-encapsulation", "boolean", "Trailer Encapsulation"),
|
||||||
(35, "arp-cache-timeout", "uint32", "ARP-Cache-Timeout (Sek.)"),
|
(35, "arp-cache-timeout", "uint32", "ARP-Cache-Timeout (Sek.)"),
|
||||||
(36, "ieee802-3-encapsulation", "boolean", "Ethernet-Encapsulation (IEEE 802.3)"),
|
(36, "ieee802-3-encapsulation", "boolean", "Ethernet-Encapsulation (IEEE 802.3)"),
|
||||||
(37, "default-tcp-ttl", "uint8", "Standard-TTL fuer TCP"),
|
(37, "default-tcp-ttl", "uint8", "Standard-TTL für TCP"),
|
||||||
(38, "tcp-keepalive-interval", "uint32", "TCP-Keepalive-Intervall (Sek.)"),
|
(38, "tcp-keepalive-interval", "uint32", "TCP-Keepalive-Intervall (Sek.)"),
|
||||||
(40, "nis-domain", "string", "NIS-Domain"),
|
(40, "nis-domain", "string", "NIS-Domain"),
|
||||||
(41, "nis-servers", "ipv4-address", "NIS-Server"),
|
(41, "nis-servers", "ipv4-address", "NIS-Server"),
|
||||||
@@ -190,12 +190,12 @@ def validate_subnet(
|
|||||||
) -> str:
|
) -> str:
|
||||||
"""Rueckgabe: leerer String bei Erfolg, sonst die Fehlermeldung."""
|
"""Rueckgabe: leerer String bei Erfolg, sonst die Fehlermeldung."""
|
||||||
if not interface:
|
if not interface:
|
||||||
return "Es ist kein Interface ausgewaehlt."
|
return "Es ist kein Interface ausgewählt."
|
||||||
try:
|
try:
|
||||||
start = ipaddress.ip_address(range_start)
|
start = ipaddress.ip_address(range_start)
|
||||||
end = ipaddress.ip_address(range_end)
|
end = ipaddress.ip_address(range_end)
|
||||||
except ValueError:
|
except ValueError:
|
||||||
return "Start- und Endadresse muessen gueltige IPv4-Adressen sein."
|
return "Start- und Endadresse müssen gültige IPv4-Adressen sein."
|
||||||
if int(end) < int(start):
|
if int(end) < int(start):
|
||||||
return "Die Endadresse liegt vor der Startadresse."
|
return "Die Endadresse liegt vor der Startadresse."
|
||||||
if matching_network(networks, interface, range_start, range_end) is None:
|
if matching_network(networks, interface, range_start, range_end) is None:
|
||||||
@@ -405,7 +405,7 @@ def render_config(
|
|||||||
# fuehren, dass gar keine Konfiguration mehr geschrieben wird.
|
# fuehren, dass gar keine Konfiguration mehr geschrieben wird.
|
||||||
warnings.append(
|
warnings.append(
|
||||||
f"Subnetz {subnet['range_start']}-{subnet['range_end']} auf {interface} "
|
f"Subnetz {subnet['range_start']}-{subnet['range_end']} auf {interface} "
|
||||||
"passt in kein aktuelles Netz und wurde uebersprungen."
|
"passt in kein aktuelles Netz und wurde übersprungen."
|
||||||
)
|
)
|
||||||
continue
|
continue
|
||||||
interfaces.add(interface)
|
interfaces.add(interface)
|
||||||
@@ -444,7 +444,7 @@ def render_config(
|
|||||||
)
|
)
|
||||||
|
|
||||||
if not subnet_entries:
|
if not subnet_entries:
|
||||||
warnings.append("Es ist kein gueltiges Subnetz konfiguriert -- Kea wuerde nicht starten.")
|
warnings.append("Es ist kein gültiges Subnetz konfiguriert | Kea würde nicht starten.")
|
||||||
|
|
||||||
global_option_data = option_data(global_values)
|
global_option_data = option_data(global_values)
|
||||||
domain = str(settings.get("dhcp_domain") or "").strip()
|
domain = str(settings.get("dhcp_domain") or "").strip()
|
||||||
@@ -560,14 +560,14 @@ class Lease:
|
|||||||
#: benutzte, und ein Geraet, das noch auf einer alten Adresse haengt, faellt
|
#: benutzte, und ein Geraet, das noch auf einer alten Adresse haengt, faellt
|
||||||
#: ueberhaupt nicht auf.
|
#: ueberhaupt nicht auf.
|
||||||
RESERVATION_STATES: dict[str, tuple[str, str]] = {
|
RESERVATION_STATES: dict[str, tuple[str, str]] = {
|
||||||
"aktiv": ("online", "Das Geraet benutzt diese Adresse gerade."),
|
"aktiv": ("online", "Das Gerät benutzt diese Adresse gerade."),
|
||||||
"abgelaufen": ("", "Lease auf dieser Adresse ist abgelaufen -- Geraet derzeit nicht da."),
|
"abgelaufen": ("", "Lease auf dieser Adresse ist abgelaufen | Gerät derzeit nicht da."),
|
||||||
"andere_adresse": (
|
"andere_adresse": (
|
||||||
"warning",
|
"warning",
|
||||||
"Das Geraet haelt noch eine Lease auf einer anderen Adresse. Es uebernimmt die "
|
"Das Gerät hält noch eine Lease auf einer anderen Adresse. Es übernimmt die "
|
||||||
"Reservierung erst nach Ablauf oder Neuanforderung.",
|
"Reservierung erst nach Ablauf oder Neuanforderung.",
|
||||||
),
|
),
|
||||||
"unbenutzt": ("", "Fuer dieses Geraet liegt keine Lease vor."),
|
"unbenutzt": ("", "Für dieses Gerät liegt keine Lease vor."),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -42,19 +42,19 @@ SUPPORTED = ("credentials", "switches", "devices", "dhcp")
|
|||||||
KNOWN_UNSUPPORTED: dict[str, str] = {
|
KNOWN_UNSUPPORTED: dict[str, str] = {
|
||||||
"users": (
|
"users": (
|
||||||
"Lokale Konten: der alte Passwort-Hash ist bcrypt, diese Anwendung "
|
"Lokale Konten: der alte Passwort-Hash ist bcrypt, diese Anwendung "
|
||||||
"verwendet Argon2id. Ein Hash laesst sich nicht umrechnen -- das ist sein "
|
"verwendet Argon2id. Ein Hash lässt sich nicht umrechnen – das ist sein "
|
||||||
"Zweck. Konten neu anlegen."
|
"Zweck. Konten neu anlegen."
|
||||||
),
|
),
|
||||||
"groups": "Gruppen und Rechte: die Rechteschluessel wurden neu geschnitten.",
|
"groups": "Gruppen und Rechte: die Rechteschlüssel wurden neu geschnitten.",
|
||||||
"ldap": (
|
"ldap": (
|
||||||
"Verzeichnisdienst: Einstellungen, Gruppen- und Freigabe-Zuordnungen von "
|
"Verzeichnisdienst: Einstellungen, Gruppen- und Freigabe-Zuordnungen von "
|
||||||
"Hand uebertragen."
|
"Hand übertragen."
|
||||||
),
|
),
|
||||||
"nginx": "Webserver: Domain, Ports und Zertifikat von Hand uebertragen.",
|
"nginx": "Webserver: Domain, Ports und Zertifikat von Hand übertragen.",
|
||||||
"logs": "Auditlog: bleibt bewusst beim alten System.",
|
"logs": "Auditlog: bleibt bewusst beim alten System.",
|
||||||
"known_hosts": (
|
"known_hosts": (
|
||||||
"SSH-Host-Schluessel: werden hier je Geraet mit Fingerprint gefuehrt und "
|
"SSH-Host-Schlüssel: werden hier je Gerät mit Fingerprint geführt und "
|
||||||
"ueber die Seite 'Host-Schluessel' freigegeben."
|
"über die Seite 'Host-Schlüssel' freigegeben."
|
||||||
),
|
),
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -119,10 +119,10 @@ def open_legacy(raw: bytes | str, passphrase: str) -> dict[str, Any]:
|
|||||||
except ValueError as exc:
|
except ValueError as exc:
|
||||||
raise LegacyTransferError("Die Datei ist kein JSON.") from exc
|
raise LegacyTransferError("Die Datei ist kein JSON.") from exc
|
||||||
if not isinstance(envelope, dict):
|
if not isinstance(envelope, dict):
|
||||||
raise LegacyTransferError("Die Datei enthaelt kein Objekt.")
|
raise LegacyTransferError("Die Datei enthält kein Objekt.")
|
||||||
if str(envelope.get("app") or "") != LEGACY_APP_TAG:
|
if str(envelope.get("app") or "") != LEGACY_APP_TAG:
|
||||||
raise LegacyTransferError(
|
raise LegacyTransferError(
|
||||||
f"Diese Datei gehoert zu {envelope.get('app') or 'einer unbekannten Anwendung'!r}, "
|
f"Diese Datei gehört zu {envelope.get('app') or 'einer unbekannten Anwendung'!r}, "
|
||||||
"nicht zu TESM."
|
"nicht zu TESM."
|
||||||
)
|
)
|
||||||
for schluessel in ("salt", "payload"):
|
for schluessel in ("salt", "payload"):
|
||||||
@@ -142,15 +142,15 @@ def open_legacy(raw: bytes | str, passphrase: str) -> dict[str, Any]:
|
|||||||
klartext = Fernet(key).decrypt(str(envelope["payload"]).encode("utf-8"))
|
klartext = Fernet(key).decrypt(str(envelope["payload"]).encode("utf-8"))
|
||||||
except (InvalidToken, ValueError) as exc:
|
except (InvalidToken, ValueError) as exc:
|
||||||
raise LegacyTransferError(
|
raise LegacyTransferError(
|
||||||
"Entschluesselung fehlgeschlagen -- falsche Passphrase oder beschaedigte Datei."
|
"Entschlüsselung fehlgeschlagen – falsche Passphrase oder beschädigte Datei."
|
||||||
) from exc
|
) from exc
|
||||||
|
|
||||||
try:
|
try:
|
||||||
payload = json.loads(klartext.decode("utf-8"))
|
payload = json.loads(klartext.decode("utf-8"))
|
||||||
except ValueError as exc:
|
except ValueError as exc:
|
||||||
raise LegacyTransferError("Die entschluesselte Nutzlast ist kein JSON.") from exc
|
raise LegacyTransferError("Die entschlüsselte Nutzlast ist kein JSON.") from exc
|
||||||
if not isinstance(payload, dict):
|
if not isinstance(payload, dict):
|
||||||
raise LegacyTransferError("Die Nutzlast enthaelt kein Objekt.")
|
raise LegacyTransferError("Die Nutzlast enthält kein Objekt.")
|
||||||
return payload
|
return payload
|
||||||
|
|
||||||
|
|
||||||
@@ -180,14 +180,14 @@ def _credentials(items: Any, report: Report) -> list[dict[str, Any]]:
|
|||||||
for entry in _liste(items):
|
for entry in _liste(items):
|
||||||
name = str(entry.get("name") or "").strip()
|
name = str(entry.get("name") or "").strip()
|
||||||
if not name:
|
if not name:
|
||||||
report.note("Zugangsdaten ohne Namen uebersprungen.")
|
report.note("Zugangsdaten ohne Namen übersprungen.")
|
||||||
continue
|
continue
|
||||||
umgesetzt: dict[str, Any] = {
|
umgesetzt: dict[str, Any] = {
|
||||||
"name": name,
|
"name": name,
|
||||||
"username": str(entry.get("username") or ""),
|
"username": str(entry.get("username") or ""),
|
||||||
"category": _category(entry.get("category"), report, f"Zugangsdaten {name!r}"),
|
"category": _category(entry.get("category"), report, f"Zugangsdaten {name!r}"),
|
||||||
# Der Vorgaenger kennt kein Beschreibungsfeld.
|
# Der Vorgaenger kennt kein Beschreibungsfeld.
|
||||||
"description": "aus altem TESM uebernommen",
|
"description": "aus altem TESM übernommen",
|
||||||
}
|
}
|
||||||
# Der alte Export entschluesselt das Passwort und legt es im Klartext in
|
# Der alte Export entschluesselt das Passwort und legt es im Klartext in
|
||||||
# die -- als Ganzes passphrasenverschluesselte -- Datei. Beim Import
|
# die -- als Ganzes passphrasenverschluesselte -- Datei. Beim Import
|
||||||
@@ -199,8 +199,8 @@ def _credentials(items: Any, report: Report) -> list[dict[str, Any]]:
|
|||||||
ergebnis.append(umgesetzt)
|
ergebnis.append(umgesetzt)
|
||||||
if ohne_passwort:
|
if ohne_passwort:
|
||||||
report.note(
|
report.note(
|
||||||
f"{ohne_passwort} Zugangsdaten ohne Passwort in der Datei -- "
|
f"{ohne_passwort} Zugangsdaten ohne Passwort in der Datei – "
|
||||||
"sie muessen nachgetragen werden."
|
"sie müssen nachgetragen werden."
|
||||||
)
|
)
|
||||||
report.took("credentials", len(ergebnis))
|
report.took("credentials", len(ergebnis))
|
||||||
return ergebnis
|
return ergebnis
|
||||||
@@ -211,7 +211,7 @@ def _switches(items: Any, report: Report) -> list[dict[str, Any]]:
|
|||||||
for entry in _liste(items):
|
for entry in _liste(items):
|
||||||
hostname = str(entry.get("hostname") or "").strip()
|
hostname = str(entry.get("hostname") or "").strip()
|
||||||
if not hostname:
|
if not hostname:
|
||||||
report.note("Switch ohne Hostnamen uebersprungen.")
|
report.note("Switch ohne Hostnamen übersprungen.")
|
||||||
continue
|
continue
|
||||||
ergebnis.append(
|
ergebnis.append(
|
||||||
{
|
{
|
||||||
@@ -240,7 +240,7 @@ def _devices(items: Any, credentials: list[dict[str, Any]], report: Report) -> l
|
|||||||
for entry in _liste(items):
|
for entry in _liste(items):
|
||||||
mac = str(entry.get("mac") or "").strip()
|
mac = str(entry.get("mac") or "").strip()
|
||||||
if not mac:
|
if not mac:
|
||||||
report.note("Client ohne MAC-Adresse uebersprungen.")
|
report.note("Client ohne MAC-Adresse übersprungen.")
|
||||||
continue
|
continue
|
||||||
zugang = entry.get("credential_name")
|
zugang = entry.get("credential_name")
|
||||||
kategorie = kategorie_je_zugang.get(str(zugang or ""), "")
|
kategorie = kategorie_je_zugang.get(str(zugang or ""), "")
|
||||||
@@ -266,7 +266,7 @@ def _devices(items: Any, credentials: list[dict[str, Any]], report: Report) -> l
|
|||||||
if abgeleitet:
|
if abgeleitet:
|
||||||
report.note(
|
report.note(
|
||||||
f"Kategorie von {abgeleitet} Client(s) aus den zugeordneten Zugangsdaten "
|
f"Kategorie von {abgeleitet} Client(s) aus den zugeordneten Zugangsdaten "
|
||||||
"abgeleitet -- der Vorgaenger kennt kein solches Feld. Bitte pruefen."
|
"abgeleitet – der Vorgänger kennt kein solches Feld. Bitte prüfen."
|
||||||
)
|
)
|
||||||
report.took("devices", len(ergebnis))
|
report.took("devices", len(ergebnis))
|
||||||
return ergebnis
|
return ergebnis
|
||||||
@@ -298,14 +298,14 @@ def _dhcp(data: Any, report: Report) -> tuple[dict[str, Any], dict[str, str]]:
|
|||||||
for entry in _liste(data.get("reservations")):
|
for entry in _liste(data.get("reservations")):
|
||||||
mac = str(entry.get("mac") or "").strip()
|
mac = str(entry.get("mac") or "").strip()
|
||||||
if not mac:
|
if not mac:
|
||||||
report.note("Manuelle Reservierung ohne MAC-Adresse uebersprungen.")
|
report.note("Manuelle Reservierung ohne MAC-Adresse übersprungen.")
|
||||||
continue
|
continue
|
||||||
reservations.append(
|
reservations.append(
|
||||||
{
|
{
|
||||||
"mac": mac,
|
"mac": mac,
|
||||||
"ip": str(entry.get("ip") or ""),
|
"ip": str(entry.get("ip") or ""),
|
||||||
"hostname": str(entry.get("name") or ""),
|
"hostname": str(entry.get("name") or ""),
|
||||||
"comment": "aus altem TESM uebernommen",
|
"comment": "aus altem TESM übernommen",
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -322,7 +322,7 @@ def _dhcp(data: Any, report: Report) -> tuple[dict[str, Any], dict[str, str]]:
|
|||||||
"is_standard": int(bool(definition.get("is_standard"))),
|
"is_standard": int(bool(definition.get("is_standard"))),
|
||||||
}
|
}
|
||||||
if not gemeinsam["code"] or not gemeinsam["name"]:
|
if not gemeinsam["code"] or not gemeinsam["name"]:
|
||||||
report.note("DHCP-Option ohne Code oder Namen uebersprungen.")
|
report.note("DHCP-Option ohne Code oder Namen übersprungen.")
|
||||||
continue
|
continue
|
||||||
werte = _liste(definition.get("values"))
|
werte = _liste(definition.get("values"))
|
||||||
if not werte:
|
if not werte:
|
||||||
@@ -339,7 +339,7 @@ def _dhcp(data: Any, report: Report) -> tuple[dict[str, Any], dict[str, str]]:
|
|||||||
)
|
)
|
||||||
if ohne_wert:
|
if ohne_wert:
|
||||||
report.note(
|
report.note(
|
||||||
f"{ohne_wert} eigene DHCP-Option(en) ohne Wert uebersprungen -- ohne Wert "
|
f"{ohne_wert} eigene DHCP-Option(en) ohne Wert übersprungen – ohne Wert "
|
||||||
"gibt es nichts zu senden. Bei Bedarf von Hand anlegen."
|
"gibt es nichts zu senden. Bei Bedarf von Hand anlegen."
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -405,7 +405,7 @@ def convert(legacy: dict[str, Any]) -> tuple[dict[str, Any], Report]:
|
|||||||
report.note(
|
report.note(
|
||||||
"Diese Zugangsdaten sind zugeordnet, fehlen aber in der Datei und bleiben "
|
"Diese Zugangsdaten sind zugeordnet, fehlen aber in der Datei und bleiben "
|
||||||
f"deshalb leer: {', '.join(fehlend)}. Beim Export im alten TESM die "
|
f"deshalb leer: {', '.join(fehlend)}. Beim Export im alten TESM die "
|
||||||
"Kategorie 'Zugangsdaten' mitwaehlen."
|
"Kategorie 'Zugangsdaten' mitwählen."
|
||||||
)
|
)
|
||||||
|
|
||||||
for schluessel, grund in KNOWN_UNSUPPORTED.items():
|
for schluessel, grund in KNOWN_UNSUPPORTED.items():
|
||||||
@@ -413,12 +413,12 @@ def convert(legacy: dict[str, Any]) -> tuple[dict[str, Any], Report]:
|
|||||||
report.note(grund)
|
report.note(grund)
|
||||||
unbekannt = sorted(set(legacy) - set(SUPPORTED) - set(KNOWN_UNSUPPORTED))
|
unbekannt = sorted(set(legacy) - set(SUPPORTED) - set(KNOWN_UNSUPPORTED))
|
||||||
if unbekannt:
|
if unbekannt:
|
||||||
report.note(f"Unbekannte Abschnitte uebersprungen: {', '.join(unbekannt)}.")
|
report.note(f"Unbekannte Abschnitte übersprungen: {', '.join(unbekannt)}.")
|
||||||
|
|
||||||
if not payload:
|
if not payload:
|
||||||
raise LegacyTransferError(
|
raise LegacyTransferError(
|
||||||
"Die Datei enthaelt keine Clients, Switche, Zugangsdaten oder DHCP-Daten. "
|
"Die Datei enthält keine Clients, Switche, Zugangsdaten oder DHCP-Daten. "
|
||||||
"Beim Export im alten TESM diese Kategorien mitwaehlen."
|
"Beim Export im alten TESM diese Kategorien mitwählen."
|
||||||
)
|
)
|
||||||
return payload, report
|
return payload, report
|
||||||
|
|
||||||
@@ -488,7 +488,7 @@ def _entschluessler(root: Path, report: Report) -> Any:
|
|||||||
schluesseldatei = root / LEGACY_FERNET_NAME
|
schluesseldatei = root / LEGACY_FERNET_NAME
|
||||||
if not schluesseldatei.is_file():
|
if not schluesseldatei.is_file():
|
||||||
report.note(
|
report.note(
|
||||||
f"{LEGACY_FERNET_NAME} nicht gefunden -- die Passwoerter der Zugangsdaten "
|
f"{LEGACY_FERNET_NAME} nicht gefunden – die Passwörter der Zugangsdaten "
|
||||||
"bleiben leer und sind nachzutragen."
|
"bleiben leer und sind nachzutragen."
|
||||||
)
|
)
|
||||||
return lambda _token: ""
|
return lambda _token: ""
|
||||||
@@ -499,7 +499,7 @@ def _entschluessler(root: Path, report: Report) -> Any:
|
|||||||
fernet = Fernet(schluesseldatei.read_bytes().strip())
|
fernet = Fernet(schluesseldatei.read_bytes().strip())
|
||||||
except (ValueError, TypeError):
|
except (ValueError, TypeError):
|
||||||
report.note(
|
report.note(
|
||||||
f"{LEGACY_FERNET_NAME} ist unlesbar -- die Passwoerter der Zugangsdaten "
|
f"{LEGACY_FERNET_NAME} ist unlesbar – die Passwörter der Zugangsdaten "
|
||||||
"bleiben leer und sind nachzutragen."
|
"bleiben leer und sind nachzutragen."
|
||||||
)
|
)
|
||||||
return lambda _token: ""
|
return lambda _token: ""
|
||||||
@@ -515,8 +515,8 @@ def _entschluessler(root: Path, report: Report) -> Any:
|
|||||||
gescheitert.append(1)
|
gescheitert.append(1)
|
||||||
if len(gescheitert) == 1:
|
if len(gescheitert) == 1:
|
||||||
report.note(
|
report.note(
|
||||||
"Mindestens ein Passwort liess sich mit dem gefundenen Schluessel "
|
"Mindestens ein Passwort ließ sich mit dem gefundenen Schlüssel "
|
||||||
"nicht entschluesseln -- es bleibt leer."
|
"nicht entschlüsseln – es bleibt leer."
|
||||||
)
|
)
|
||||||
return ""
|
return ""
|
||||||
|
|
||||||
@@ -643,7 +643,7 @@ def read_installation(root: Path) -> tuple[dict[str, Any], Report]:
|
|||||||
payload = {key: value for key, value in payload.items() if value}
|
payload = {key: value for key, value in payload.items() if value}
|
||||||
if not payload:
|
if not payload:
|
||||||
raise LegacyTransferError(
|
raise LegacyTransferError(
|
||||||
f"In {root} stehen keine uebernehmbaren Daten (Clients, Switche, "
|
f"In {root} stehen keine übernehmbaren Daten (Clients, Switche, "
|
||||||
"Zugangsdaten, DHCP)."
|
"Zugangsdaten, DHCP)."
|
||||||
)
|
)
|
||||||
for abschnitt, inhalt in payload.items():
|
for abschnitt, inhalt in payload.items():
|
||||||
|
|||||||
@@ -110,47 +110,47 @@ def is_noise(line: str) -> bool:
|
|||||||
HINTS: tuple[tuple[str, str], ...] = (
|
HINTS: tuple[tuple[str, str], ...] = (
|
||||||
(
|
(
|
||||||
"incorrect password attempt",
|
"incorrect password attempt",
|
||||||
"Das Passwort aus den Zugangsdaten wurde von 'sudo' abgewiesen. Fuer die "
|
"Das Passwort aus den Zugangsdaten wurde von 'sudo' abgewiesen. Für die "
|
||||||
"SSH-Anmeldung hat es gereicht -- 'sudo' auf diesem Geraet verlangt also ein "
|
"SSH-Anmeldung hat es gereicht – 'sudo' auf diesem Gerät verlangt also ein "
|
||||||
"anderes Passwort, oder das Konto steht nicht in der sudoers-Datei.",
|
"anderes Passwort, oder das Konto steht nicht in der sudoers-Datei.",
|
||||||
),
|
),
|
||||||
(
|
(
|
||||||
"is not in the sudoers file",
|
"is not in the sudoers file",
|
||||||
"Das Wartungskonto darf auf diesem Geraet kein 'sudo' ausfuehren. Auf dem "
|
"Das Wartungskonto darf auf diesem Gerät kein 'sudo' ausführen. Auf dem "
|
||||||
"Zielgeraet eintragen, z. B. in /etc/sudoers.d/tesm-wartung: "
|
"Zielgerät eintragen, z. B. in /etc/sudoers.d/tesm-wartung: "
|
||||||
"'<benutzer> ALL=(root) /usr/bin/apt-get, /sbin/reboot'. Mit vorangestelltem "
|
"'<benutzer> ALL=(root) /usr/bin/apt-get, /sbin/reboot'. Mit vorangestelltem "
|
||||||
"NOPASSWD: entfaellt die Passwortabfrage ganz.",
|
"NOPASSWD: entfällt die Passwortabfrage ganz.",
|
||||||
),
|
),
|
||||||
(
|
(
|
||||||
"a password is required",
|
"a password is required",
|
||||||
"'sudo' verlangt ein Passwort, und in den Zugangsdaten dieses Geraets ist keines "
|
"'sudo' verlangt ein Passwort, und in den Zugangsdaten dieses Geräts ist keines "
|
||||||
"hinterlegt. Tragen Sie dort das Passwort des Wartungskontos ein -- TESM reicht "
|
"hinterlegt. Tragen Sie dort das Passwort des Wartungskontos ein | TESM reicht "
|
||||||
"es ueber die bereits verschluesselte SSH-Verbindung an 'sudo' weiter.",
|
"es über die bereits verschlüsselte SSH-Verbindung an 'sudo' weiter.",
|
||||||
),
|
),
|
||||||
(
|
(
|
||||||
"no tty present",
|
"no tty present",
|
||||||
"Das Wartungskonto verlangt fuer 'sudo' ein Terminal. In der sudoers-Datei des "
|
"Das Wartungskonto verlangt für 'sudo' ein Terminal. In der sudoers-Datei des "
|
||||||
"Zielgeraets 'Defaults requiretty' entfernen oder fuer dieses Konto abschalten.",
|
"Zielgeräts 'Defaults requiretty' entfernen oder für dieses Konto abschalten.",
|
||||||
),
|
),
|
||||||
(
|
(
|
||||||
"command not found",
|
"command not found",
|
||||||
"Das Kommando gibt es auf dem Zielgeraet nicht. Die Wartung erwartet ein "
|
"Das Kommando gibt es auf dem Zielgerät nicht. Die Wartung erwartet ein "
|
||||||
"Debian- oder Ubuntu-System mit apt-get.",
|
"Debian- oder Ubuntu-System mit apt-get.",
|
||||||
),
|
),
|
||||||
(
|
(
|
||||||
"Access is denied",
|
"Access is denied",
|
||||||
"Das Konto darf auf diesem Windows-Geraet keinen Neustart ausloesen. Es muss "
|
"Das Konto darf auf diesem Windows-Gerät keinen Neustart auslösen. Es muss "
|
||||||
"Mitglied der lokalen Administratoren sein.",
|
"Mitglied der lokalen Administratoren sein.",
|
||||||
),
|
),
|
||||||
(
|
(
|
||||||
"is not recognized as an internal or external command",
|
"is not recognized as an internal or external command",
|
||||||
"Das Kommando gibt es auf diesem Geraet nicht. Ist bei den Zugangsdaten die "
|
"Das Kommando gibt es auf diesem Gerät nicht. Ist bei den Zugangsdaten die "
|
||||||
"richtige Kategorie gewaehlt (Linux-Client bzw. Windows-Client)?",
|
"richtige Kategorie gewählt (Linux-Client bzw. Windows-Client)?",
|
||||||
),
|
),
|
||||||
(
|
(
|
||||||
"Could not get lock",
|
"Could not get lock",
|
||||||
"Ein anderer Paketvorgang laeuft bereits auf dem Zielgeraet (etwa "
|
"Ein anderer Paketvorgang läuft bereits auf dem Zielgerät (etwa "
|
||||||
"unattended-upgrades). Spaeter erneut versuchen.",
|
"unattended-upgrades). Später erneut versuchen.",
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -256,8 +256,8 @@ def mark_orphaned_jobs(conn: sqlite3.Connection) -> int:
|
|||||||
"UPDATE jobs SET state='unknown', message=?, finished_at=?, updated_at=? "
|
"UPDATE jobs SET state='unknown', message=?, finished_at=?, updated_at=? "
|
||||||
"WHERE state IN ('pending','running')",
|
"WHERE state IN ('pending','running')",
|
||||||
(
|
(
|
||||||
"Der Dienst wurde waehrend des Laufs neu gestartet. Was auf dem Zielgeraet "
|
"Der Dienst wurde während des Laufs neu gestartet. Was auf dem Zielgerät "
|
||||||
"passiert ist, laesst sich von hier nicht mehr feststellen.",
|
"passiert ist, lässt sich von hier nicht mehr feststellen.",
|
||||||
_now(),
|
_now(),
|
||||||
_now(),
|
_now(),
|
||||||
),
|
),
|
||||||
@@ -293,7 +293,7 @@ def _run_remote_command(
|
|||||||
channel.sendall(stdin_text.encode("utf-8"))
|
channel.sendall(stdin_text.encode("utf-8"))
|
||||||
channel.shutdown_write()
|
channel.shutdown_write()
|
||||||
except Exception as exc: # noqa: BLE001
|
except Exception as exc: # noqa: BLE001
|
||||||
on_line(f"[Eingabe konnte nicht uebergeben werden: {exc}]")
|
on_line(f"[Eingabe konnte nicht übergeben werden: {exc}]")
|
||||||
deadline = time.monotonic() + timeout
|
deadline = time.monotonic() + timeout
|
||||||
pending = b""
|
pending = b""
|
||||||
while True:
|
while True:
|
||||||
@@ -308,7 +308,7 @@ def _run_remote_command(
|
|||||||
elif channel.exit_status_ready():
|
elif channel.exit_status_ready():
|
||||||
break
|
break
|
||||||
elif time.monotonic() > deadline:
|
elif time.monotonic() > deadline:
|
||||||
on_line("[Zeitlimit ueberschritten -- Verbindung wird getrennt]")
|
on_line("[Zeitlimit überschritten | Verbindung wird getrennt]")
|
||||||
channel.close()
|
channel.close()
|
||||||
return 124
|
return 124
|
||||||
else:
|
else:
|
||||||
@@ -341,14 +341,14 @@ def _sudo_mode(
|
|||||||
except Exception: # noqa: BLE001 - dann eben der Weg mit Passwort
|
except Exception: # noqa: BLE001 - dann eben der Weg mit Passwort
|
||||||
status = 1
|
status = 1
|
||||||
if status == 0:
|
if status == 0:
|
||||||
on_line("sudo ist ohne Passwort moeglich.")
|
on_line("sudo ist ohne Passwort möglich.")
|
||||||
return SUDO_PASSWORDLESS, ""
|
return SUDO_PASSWORDLESS, ""
|
||||||
if not password:
|
if not password:
|
||||||
on_line(
|
on_line(
|
||||||
"sudo verlangt ein Passwort, in den Zugangsdaten ist keines hinterlegt."
|
"sudo verlangt ein Passwort, in den Zugangsdaten ist keines hinterlegt."
|
||||||
)
|
)
|
||||||
return SUDO_PASSWORDLESS, ""
|
return SUDO_PASSWORDLESS, ""
|
||||||
on_line("sudo verlangt ein Passwort -- es wird aus den Zugangsdaten uebergeben.")
|
on_line("sudo verlangt ein Passwort – es wird aus den Zugangsdaten übergeben.")
|
||||||
return SUDO_WITH_PASSWORD, password + "\n"
|
return SUDO_WITH_PASSWORD, password + "\n"
|
||||||
|
|
||||||
|
|
||||||
@@ -411,7 +411,7 @@ def run_update(
|
|||||||
)
|
)
|
||||||
if status != 0:
|
if status != 0:
|
||||||
failed = True
|
failed = True
|
||||||
emit(f"Schritt {label!r} endete mit Rueckgabewert {status}.")
|
emit(f"Schritt {label!r} endete mit Rückgabewert {status}.")
|
||||||
problem = hint_for("\n".join(collected))
|
problem = hint_for("\n".join(collected))
|
||||||
if problem:
|
if problem:
|
||||||
emit("")
|
emit("")
|
||||||
@@ -465,7 +465,7 @@ def _reboot_windows(
|
|||||||
Rueckkehr danach ist dasselbe -- und das steht beim Aufrufer.
|
Rueckkehr danach ist dasselbe -- und das steht beim Aufrufer.
|
||||||
"""
|
"""
|
||||||
host = str(device["ip"])
|
host = str(device["ip"])
|
||||||
emit(f"Windows-Geraet -- Neustart ueber RPC an {host} (Port {WINDOWS_PROBE_PORT}).")
|
emit(f"Windows-Gerät | Neustart über RPC an {host} (Port {WINDOWS_PROBE_PORT}).")
|
||||||
result = windowsops.reboot(host, username, password)
|
result = windowsops.reboot(host, username, password)
|
||||||
|
|
||||||
for line in result.output.splitlines():
|
for line in result.output.splitlines():
|
||||||
@@ -582,13 +582,13 @@ def run_reboot(
|
|||||||
sudo, sudo_stdin = _sudo_mode(transport, password, emit)
|
sudo, sudo_stdin = _sudo_mode(transport, password, emit)
|
||||||
command = REBOOT_COMMAND.format(sudo=sudo)
|
command = REBOOT_COMMAND.format(sudo=sudo)
|
||||||
try:
|
try:
|
||||||
emit(f"Neustart wird ausgeloest ({command})")
|
emit(f"Neustart wird ausgelöst ({command})")
|
||||||
try:
|
try:
|
||||||
_run_remote_command(
|
_run_remote_command(
|
||||||
transport, command, timeout=20, on_line=capture, stdin_text=sudo_stdin
|
transport, command, timeout=20, on_line=capture, stdin_text=sudo_stdin
|
||||||
)
|
)
|
||||||
except Exception as exc: # noqa: BLE001 - Abbruch ist hier normal
|
except Exception as exc: # noqa: BLE001 - Abbruch ist hier normal
|
||||||
emit(f"Verbindung getrennt ({exc}) -- das ist beim Neustart zu erwarten.")
|
emit(f"Verbindung getrennt ({exc}) – das ist beim Neustart zu erwarten.")
|
||||||
finally:
|
finally:
|
||||||
try:
|
try:
|
||||||
transport.close()
|
transport.close()
|
||||||
@@ -621,7 +621,7 @@ def run_reboot(
|
|||||||
)
|
)
|
||||||
return
|
return
|
||||||
|
|
||||||
update_job(database, job_id, progress=40, message="Warte auf Rueckkehr des Geraets")
|
update_job(database, job_id, progress=40, message="Warte auf Rückkehr des Geräts")
|
||||||
time.sleep(REBOOT_GRACE_SECONDS)
|
time.sleep(REBOOT_GRACE_SECONDS)
|
||||||
deadline = time.monotonic() + wait_seconds
|
deadline = time.monotonic() + wait_seconds
|
||||||
ssh_port = probe_port
|
ssh_port = probe_port
|
||||||
@@ -632,7 +632,7 @@ def run_reboot(
|
|||||||
# Zeitueberschreitung, obwohl das Geraet zurueck war. Ausserdem ist
|
# Zeitueberschreitung, obwohl das Geraet zurueck war. Ausserdem ist
|
||||||
# "SSH antwortet wieder" die Aussage, auf die es hier ankommt.
|
# "SSH antwortet wieder" die Aussage, auf die es hier ankommt.
|
||||||
if tcp_reachable(host, ssh_port):
|
if tcp_reachable(host, ssh_port):
|
||||||
emit(f"Geraet ist wieder erreichbar (Port {ssh_port} antwortet).")
|
emit(f"Gerät ist wieder erreichbar (Port {ssh_port} antwortet).")
|
||||||
update_job(
|
update_job(
|
||||||
database,
|
database,
|
||||||
job_id,
|
job_id,
|
||||||
@@ -656,7 +656,7 @@ def run_reboot(
|
|||||||
result="ok",
|
result="ok",
|
||||||
actor=actor,
|
actor=actor,
|
||||||
duration_ms=elapsed(),
|
duration_ms=elapsed(),
|
||||||
detail="Neustart ueber SSH, Geraet ist zurueckgekehrt.",
|
detail="Neustart über SSH, Gerät ist zurückgekehrt.",
|
||||||
method=method,
|
method=method,
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
@@ -668,7 +668,7 @@ def run_reboot(
|
|||||||
job_id,
|
job_id,
|
||||||
state="failed",
|
state="failed",
|
||||||
progress=100,
|
progress=100,
|
||||||
message=f"Geraet nicht zurueckgekehrt ({host}:{ssh_port} antwortet nicht).",
|
message=f"Gerät nicht zurückgekehrt ({host}:{ssh_port} antwortet nicht).",
|
||||||
finished=True,
|
finished=True,
|
||||||
)
|
)
|
||||||
_log_restart(
|
_log_restart(
|
||||||
@@ -678,7 +678,7 @@ def run_reboot(
|
|||||||
actor=actor,
|
actor=actor,
|
||||||
duration_ms=elapsed(),
|
duration_ms=elapsed(),
|
||||||
detail=(
|
detail=(
|
||||||
f"Neustart ausgeloest, aber {host}:{ssh_port} hat innerhalb von "
|
f"Neustart ausgelöst, aber {host}:{ssh_port} hat innerhalb von "
|
||||||
f"{wait_seconds}s nicht wieder geantwortet."
|
f"{wait_seconds}s nicht wieder geantwortet."
|
||||||
),
|
),
|
||||||
method=method,
|
method=method,
|
||||||
@@ -718,7 +718,7 @@ def _log_restart(
|
|||||||
method=method,
|
method=method,
|
||||||
)
|
)
|
||||||
except Exception: # noqa: BLE001 - Protokollieren darf den Auftrag nie kippen
|
except Exception: # noqa: BLE001 - Protokollieren darf den Auftrag nie kippen
|
||||||
logger.exception("Neustart konnte nicht protokolliert werden (Geraet %s)", device_id)
|
logger.exception("Neustart konnte nicht protokolliert werden (Gerät %s)", device_id)
|
||||||
|
|
||||||
|
|
||||||
def start_background(target: Callable[..., None], **kwargs: Any) -> None:
|
def start_background(target: Callable[..., None], **kwargs: Any) -> None:
|
||||||
|
|||||||
@@ -115,7 +115,7 @@ def ping(host: str, *, timeout: int = PING_TIMEOUT_SECONDS) -> PingResult:
|
|||||||
check=False,
|
check=False,
|
||||||
)
|
)
|
||||||
except subprocess.TimeoutExpired:
|
except subprocess.TimeoutExpired:
|
||||||
return PingResult(False, detail="Zeitlimit ueberschritten")
|
return PingResult(False, detail="Zeitlimit überschritten")
|
||||||
except OSError as exc:
|
except OSError as exc:
|
||||||
return PingResult(False, detail=str(exc))
|
return PingResult(False, detail=str(exc))
|
||||||
|
|
||||||
@@ -274,8 +274,8 @@ METHOD_SSH = "ssh"
|
|||||||
METHOD_RPC = "rpc"
|
METHOD_RPC = "rpc"
|
||||||
METHOD_LABELS = {
|
METHOD_LABELS = {
|
||||||
METHOD_POE: "PoE-Port",
|
METHOD_POE: "PoE-Port",
|
||||||
METHOD_SSH: "Neustart ueber SSH",
|
METHOD_SSH: "Neustart über SSH",
|
||||||
METHOD_RPC: "Neustart ueber RPC",
|
METHOD_RPC: "Neustart über RPC",
|
||||||
}
|
}
|
||||||
|
|
||||||
#: Zustand zwischen ausgeloestem Neustart und Rueckkehr.
|
#: Zustand zwischen ausgeloestem Neustart und Rueckkehr.
|
||||||
@@ -406,7 +406,7 @@ def restart_device_poe(
|
|||||||
|
|
||||||
if not port or not switch_ip:
|
if not port or not switch_ip:
|
||||||
message = (
|
message = (
|
||||||
"Kein Switch oder kein Port zugeordnet -- ein PoE-Neustart ist nicht moeglich."
|
"Kein Switch oder kein Port zugeordnet – ein PoE-Neustart ist nicht möglich."
|
||||||
)
|
)
|
||||||
with write():
|
with write():
|
||||||
record_restart(
|
record_restart(
|
||||||
@@ -422,7 +422,7 @@ def restart_device_poe(
|
|||||||
return {"ok": False, "message": message, "transcript": ""}
|
return {"ok": False, "message": message, "transcript": ""}
|
||||||
|
|
||||||
if not credential_id:
|
if not credential_id:
|
||||||
message = f"Fuer Switch {device.get('switch_hostname') or switch_ip} sind keine Zugangsdaten hinterlegt."
|
message = f"Für Switch {device.get('switch_hostname') or switch_ip} sind keine Zugangsdaten hinterlegt."
|
||||||
with write():
|
with write():
|
||||||
record_restart(
|
record_restart(
|
||||||
conn,
|
conn,
|
||||||
@@ -486,7 +486,7 @@ def restart_device_poe(
|
|||||||
live.error("poe", f"{name}: PoE-Neustart fehlgeschlagen -- {outcome.error}")
|
live.error("poe", f"{name}: PoE-Neustart fehlgeschlagen -- {outcome.error}")
|
||||||
return {
|
return {
|
||||||
"ok": outcome.ok,
|
"ok": outcome.ok,
|
||||||
"message": outcome.error or "PoE-Neustart durchgefuehrt.",
|
"message": outcome.error or "PoE-Neustart durchgeführt.",
|
||||||
"transcript": outcome.transcript,
|
"transcript": outcome.transcript,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -567,7 +567,7 @@ def run_sweep(
|
|||||||
report.skipped_unlicensed += 1
|
report.skipped_unlicensed += 1
|
||||||
live.warning(
|
live.warning(
|
||||||
"poe",
|
"poe",
|
||||||
f"{name}: automatischer PoE-Neustart uebersprungen -- keine gueltige Lizenz.",
|
f"{name}: automatischer PoE-Neustart übersprungen – keine gültige Lizenz.",
|
||||||
)
|
)
|
||||||
continue
|
continue
|
||||||
report.restarts_triggered += 1
|
report.restarts_triggered += 1
|
||||||
|
|||||||
@@ -54,24 +54,24 @@ DEFINITIONS: dict[str, Method] = {
|
|||||||
POWER_ON: Method(
|
POWER_ON: Method(
|
||||||
key=METHOD_POE,
|
key=METHOD_POE,
|
||||||
label="Einschalten (PoE)",
|
label="Einschalten (PoE)",
|
||||||
hint="Schaltet den Switch-Port aus und wieder ein. Bei einem Geraet, das nicht "
|
hint="Schaltet den Switch-Port aus und wieder ein. Bei einem Gerät, das nicht "
|
||||||
"antwortet, ist das der einzige Weg, der noch etwas bewirkt.",
|
"antwortet, ist das der einzige Weg, der noch etwas bewirkt.",
|
||||||
),
|
),
|
||||||
METHOD_POE: Method(
|
METHOD_POE: Method(
|
||||||
key=METHOD_POE,
|
key=METHOD_POE,
|
||||||
label="PoE-Port",
|
label="PoE-Port",
|
||||||
hint="Schaltet den Switch-Port kurz ab. Wirkt auch, wenn das Geraet haengt.",
|
hint="Schaltet den Switch-Port kurz ab. Wirkt auch, wenn das Gerät hängt.",
|
||||||
),
|
),
|
||||||
METHOD_SSH: Method(
|
METHOD_SSH: Method(
|
||||||
key=METHOD_SSH,
|
key=METHOD_SSH,
|
||||||
label="Neustart ueber SSH",
|
label="Neustart über SSH",
|
||||||
hint="Faehrt das Betriebssystem sauber herunter. Setzt ein antwortendes Geraet "
|
hint="Fährt das Betriebssystem sauber herunter. Setzt ein antwortendes Gerät "
|
||||||
"und einen freigegebenen Host-Schluessel voraus.",
|
"und einen freigegebenen Host-Schlüssel voraus.",
|
||||||
licensed=True,
|
licensed=True,
|
||||||
),
|
),
|
||||||
METHOD_RPC: Method(
|
METHOD_RPC: Method(
|
||||||
key=METHOD_RPC,
|
key=METHOD_RPC,
|
||||||
label="Neustart ueber RPC",
|
label="Neustart über RPC",
|
||||||
hint="Windows: wie 'shutdown /m'. Setzt offene Datei- und Druckerfreigabe voraus.",
|
hint="Windows: wie 'shutdown /m'. Setzt offene Datei- und Druckerfreigabe voraus.",
|
||||||
licensed=True,
|
licensed=True,
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -128,7 +128,7 @@ class ShellSession:
|
|||||||
elif time.monotonic() > deadline:
|
elif time.monotonic() > deadline:
|
||||||
channel.close()
|
channel.close()
|
||||||
raise SshError(
|
raise SshError(
|
||||||
f"Das Kommando hat das Zeitlimit von {timeout}s ueberschritten."
|
f"Das Kommando hat das Zeitlimit von {timeout}s überschritten."
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
time.sleep(0.05)
|
time.sleep(0.05)
|
||||||
|
|||||||
@@ -48,8 +48,8 @@ class UnknownHostKey(SshError):
|
|||||||
|
|
||||||
def __init__(self, host: str, port: int, key_type: str, fingerprint: str) -> None:
|
def __init__(self, host: str, port: int, key_type: str, fingerprint: str) -> None:
|
||||||
super().__init__(
|
super().__init__(
|
||||||
f"Der SSH-Host-Schluessel von {host}:{port} ist unbekannt "
|
f"Der SSH-Host-Schlüssel von {host}:{port} ist unbekannt "
|
||||||
f"({key_type}, {fingerprint}). Bitte pruefen und ausdruecklich freigeben."
|
f"({key_type}, {fingerprint}). Bitte prüfen und ausdrücklich freigeben."
|
||||||
)
|
)
|
||||||
self.host = host
|
self.host = host
|
||||||
self.port = port
|
self.port = port
|
||||||
@@ -62,9 +62,9 @@ class HostKeyChanged(SshError):
|
|||||||
|
|
||||||
def __init__(self, host: str, port: int, expected: str, received: str) -> None:
|
def __init__(self, host: str, port: int, expected: str, received: str) -> None:
|
||||||
super().__init__(
|
super().__init__(
|
||||||
f"Der SSH-Host-Schluessel von {host}:{port} hat sich geaendert "
|
f"Der SSH-Host-Schlüssel von {host}:{port} hat sich geändert "
|
||||||
f"(erwartet {expected}, erhalten {received}). Der Aufbau wurde abgebrochen. "
|
f"(erwartet {expected}, erhalten {received}). Der Aufbau wurde abgebrochen. "
|
||||||
"Das kann ein Geraetetausch sein -- oder ein Angriff."
|
"Das kann ein Gerätetausch sein – oder ein Angriff."
|
||||||
)
|
)
|
||||||
self.host = host
|
self.host = host
|
||||||
self.port = port
|
self.port = port
|
||||||
@@ -187,17 +187,17 @@ def describe_probe(
|
|||||||
if known is None:
|
if known is None:
|
||||||
return (
|
return (
|
||||||
"info",
|
"info",
|
||||||
f"Host-Schluessel gelesen: {record.key_type} {record.fingerprint}. "
|
f"Host-Schlüssel gelesen: {record.key_type} {record.fingerprint}. "
|
||||||
"Bitte gegen eine unabhaengige Quelle pruefen und dann freigeben.",
|
"Bitte gegen eine unabhängige Quelle prüfen und dann freigeben.",
|
||||||
)
|
)
|
||||||
if known.fingerprint != record.fingerprint:
|
if known.fingerprint != record.fingerprint:
|
||||||
return (
|
return (
|
||||||
"danger",
|
"danger",
|
||||||
f"Achtung: der Schluessel weicht vom gespeicherten ab. Gespeichert "
|
f"Achtung: der Schlüssel weicht vom gespeicherten ab. Gespeichert "
|
||||||
f"{known.fingerprint}, gelesen {record.fingerprint}. Das kann ein "
|
f"{known.fingerprint}, gelesen {record.fingerprint}. Das kann ein "
|
||||||
"Geraetetausch sein -- oder ein Angriff.",
|
"Gerätetausch sein – oder ein Angriff.",
|
||||||
)
|
)
|
||||||
return ("success", "Der Host-Schluessel stimmt mit dem gespeicherten ueberein.")
|
return ("success", "Der Host-Schlüssel stimmt mit dem gespeicherten überein.")
|
||||||
|
|
||||||
|
|
||||||
def probe_host_key(host: str, port: int = 22, *, timeout: int = CONNECT_TIMEOUT) -> HostKeyRecord:
|
def probe_host_key(host: str, port: int = 22, *, timeout: int = CONNECT_TIMEOUT) -> HostKeyRecord:
|
||||||
@@ -281,8 +281,8 @@ def open_transport(
|
|||||||
raise SshError(
|
raise SshError(
|
||||||
f"SSH-Handshake mit {host}:{port} fehlgeschlagen ({exc}). "
|
f"SSH-Handshake mit {host}:{port} fehlgeschlagen ({exc}). "
|
||||||
f"Freigegeben sind nur {', '.join(sorted(set(trusted_types)))} -- "
|
f"Freigegeben sind nur {', '.join(sorted(set(trusted_types)))} -- "
|
||||||
"bietet das Geraet inzwischen einen anderen Schluesseltyp an, muss der "
|
"bietet das Gerät inzwischen einen anderen Schlüsseltyp an, muss der "
|
||||||
"Schluessel neu ausgelesen und freigegeben werden."
|
"Schlüssel neu ausgelesen und freigegeben werden."
|
||||||
) from exc
|
) from exc
|
||||||
raise SshError(f"SSH-Handshake mit {host}:{port} fehlgeschlagen ({exc}).") from exc
|
raise SshError(f"SSH-Handshake mit {host}:{port} fehlgeschlagen ({exc}).") from exc
|
||||||
|
|
||||||
|
|||||||
@@ -149,7 +149,7 @@ class SwitchCli:
|
|||||||
``tolerate_error`` (z. B. ``no page``, das nicht jede Firmware kennt).
|
``tolerate_error`` (z. B. ``no page``, das nicht jede Firmware kennt).
|
||||||
"""
|
"""
|
||||||
if self._channel is None:
|
if self._channel is None:
|
||||||
raise SshError("Die Sitzung ist nicht geoeffnet.")
|
raise SshError("Die Sitzung ist nicht geöffnet.")
|
||||||
self.transcript.append(f"\n$ {command}\n")
|
self.transcript.append(f"\n$ {command}\n")
|
||||||
self._channel.send(command + "\r\n")
|
self._channel.send(command + "\r\n")
|
||||||
output = self._read_until_prompt(timeout=timeout)
|
output = self._read_until_prompt(timeout=timeout)
|
||||||
@@ -207,7 +207,7 @@ class SwitchCli:
|
|||||||
off_state = self.poe_state(port)
|
off_state = self.poe_state(port)
|
||||||
if off_state == "enabled":
|
if off_state == "enabled":
|
||||||
result.error = (
|
result.error = (
|
||||||
"Der Switch meldet den Port nach dem Abschalten weiter als aktiv -- "
|
"Der Switch meldet den Port nach dem Abschalten weiter als aktiv – "
|
||||||
"der Neustart hat nicht stattgefunden."
|
"der Neustart hat nicht stattgefunden."
|
||||||
)
|
)
|
||||||
result.transcript = self.full_transcript
|
result.transcript = self.full_transcript
|
||||||
@@ -245,11 +245,11 @@ class SwitchCli:
|
|||||||
#: Kommandos, die die Oberflaeche ohne freies Eingabefeld anbietet.
|
#: Kommandos, die die Oberflaeche ohne freies Eingabefeld anbietet.
|
||||||
SAFE_COMMANDS: tuple[tuple[str, str], ...] = (
|
SAFE_COMMANDS: tuple[tuple[str, str], ...] = (
|
||||||
("show system information", "Systeminformationen"),
|
("show system information", "Systeminformationen"),
|
||||||
("show power-over-ethernet brief", "PoE-Uebersicht aller Ports"),
|
("show power-over-ethernet brief", "PoE-Übersicht aller Ports"),
|
||||||
("show interfaces brief", "Portstatus"),
|
("show interfaces brief", "Portstatus"),
|
||||||
("show vlans", "VLAN-Uebersicht"),
|
("show vlans", "VLAN-Uebersicht"),
|
||||||
("show mac-address", "MAC-Adresstabelle"),
|
("show mac-address", "MAC-Adresstabelle"),
|
||||||
("show lldp info remote-device", "Nachbargeraete (LLDP)"),
|
("show lldp info remote-device", "Nachbargeräte (LLDP)"),
|
||||||
("show version", "Firmware-Version"),
|
("show version", "Firmware-Version"),
|
||||||
("show logging", "Switch-Protokoll"),
|
("show logging", "Switch-Protokoll"),
|
||||||
)
|
)
|
||||||
@@ -50,13 +50,13 @@ def unseal(raw: bytes | str, passphrase: str) -> tuple[dict[str, Any], dict[str,
|
|||||||
|
|
||||||
|
|
||||||
CATEGORIES: tuple[Category, ...] = (
|
CATEGORIES: tuple[Category, ...] = (
|
||||||
Category("devices", "Clients", "Geraetestammdaten inklusive Switch- und Portzuordnung"),
|
Category("devices", "Clients", "Gerätestammdaten inklusive Switch- und Portzuordnung"),
|
||||||
Category("switches", "Switche", "Switch-Inventar"),
|
Category("switches", "Switche", "Switch-Inventar"),
|
||||||
Category(
|
Category(
|
||||||
"credentials",
|
"credentials",
|
||||||
"Zugangsdaten",
|
"Zugangsdaten",
|
||||||
"SSH-Logins. Die Passwoerter liegen im Export im Klartext -- geschuetzt "
|
"SSH-Logins. Die Passwörter liegen im Export im Klartext – geschützt "
|
||||||
"ausschliesslich durch die Passphrase.",
|
"ausschließlich durch die Passphrase.",
|
||||||
sensitive=True,
|
sensitive=True,
|
||||||
),
|
),
|
||||||
Category("dhcp", "DHCP-Konfiguration", "Subnetze, Optionen und manuelle Reservierungen"),
|
Category("dhcp", "DHCP-Konfiguration", "Subnetze, Optionen und manuelle Reservierungen"),
|
||||||
@@ -64,6 +64,13 @@ CATEGORIES: tuple[Category, ...] = (
|
|||||||
Category("users", "Benutzer", "Lokale Konten mit Passwort-Hash", admin_only=True),
|
Category("users", "Benutzer", "Lokale Konten mit Passwort-Hash", admin_only=True),
|
||||||
Category("groups", "Gruppen und Rechte", "Rechtematrix", admin_only=True),
|
Category("groups", "Gruppen und Rechte", "Rechtematrix", admin_only=True),
|
||||||
Category("settings", "Einstellungen", "Laufzeiteinstellungen der Anwendung", admin_only=True),
|
Category("settings", "Einstellungen", "Laufzeiteinstellungen der Anwendung", admin_only=True),
|
||||||
|
Category(
|
||||||
|
"signatures",
|
||||||
|
"Unterschriften",
|
||||||
|
"Pruefsummen unterschriebener Dokumente. Ohne sie sagt die Vorschau nach einer "
|
||||||
|
"Wiederherstellung zu jeder alten Fassung \"unbekannt\" – auch zu einer "
|
||||||
|
"veraenderten.",
|
||||||
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
CATEGORY_BY_KEY = {category.key: category for category in CATEGORIES}
|
CATEGORY_BY_KEY = {category.key: category for category in CATEGORIES}
|
||||||
@@ -123,7 +130,7 @@ def build_export(
|
|||||||
str(row["secret_encrypted"]), aad=aad_for_credential(int(row["id"]))
|
str(row["secret_encrypted"]), aad=aad_for_credential(int(row["id"]))
|
||||||
)
|
)
|
||||||
except Exception: # noqa: BLE001
|
except Exception: # noqa: BLE001
|
||||||
entry["secret_error"] = "nicht entschluesselbar"
|
entry["secret_error"] = "nicht entschlüsselbar"
|
||||||
entries.append(entry)
|
entries.append(entry)
|
||||||
payload["credentials"] = entries
|
payload["credentials"] = entries
|
||||||
if "dhcp" in wanted:
|
if "dhcp" in wanted:
|
||||||
@@ -186,6 +193,16 @@ def build_export(
|
|||||||
# Sicherung -- sonst wuerde ein Restore eine fremde Aktivierung erben.
|
# Sicherung -- sonst wuerde ein Restore eine fremde Aktivierung erben.
|
||||||
if not str(row["key"]).startswith("license_")
|
if not str(row["key"]).startswith("license_")
|
||||||
}
|
}
|
||||||
|
if "signatures" in wanted:
|
||||||
|
# Das Verzeichnis der Unterschriften. Der Beleg selbst bleibt das
|
||||||
|
# Aenderungsprotokoll (Hash-Kette) und das Journal neben dem Archiv --
|
||||||
|
# beides wandert nicht in die Sicherung. Ohne diese Zeilen waere nach
|
||||||
|
# einer Wiederherstellung jede unterschriebene Fassung "unbekannt".
|
||||||
|
payload["signatures"] = Database.all(
|
||||||
|
conn,
|
||||||
|
"SELECT sha256, share_label, path, signed_at, actor, names "
|
||||||
|
"FROM document_signatures ORDER BY id",
|
||||||
|
)
|
||||||
return payload
|
return payload
|
||||||
|
|
||||||
|
|
||||||
@@ -625,6 +642,38 @@ def apply_import(
|
|||||||
(user_id, int(group_id)),
|
(user_id, int(group_id)),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
if "signatures" in wanted:
|
||||||
|
for eintrag in payload.get("signatures") or []:
|
||||||
|
if not isinstance(eintrag, dict):
|
||||||
|
continue
|
||||||
|
summe = str(eintrag.get("sha256") or "").strip()
|
||||||
|
pfad = str(eintrag.get("path") or "").strip()
|
||||||
|
if not summe:
|
||||||
|
continue
|
||||||
|
# Zweimal einlesen darf nichts verdoppeln: verglichen wird ueber
|
||||||
|
# Pruefsumme und Ablageort, genau wie beim Journal.
|
||||||
|
vorhanden = Database.value(
|
||||||
|
conn,
|
||||||
|
"SELECT 1 FROM document_signatures WHERE sha256=? AND path=? LIMIT 1",
|
||||||
|
(summe, pfad),
|
||||||
|
)
|
||||||
|
if vorhanden:
|
||||||
|
bump("signatures", "skipped")
|
||||||
|
continue
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO document_signatures (sha256, share_label, path, signed_at, "
|
||||||
|
"actor, names, audit_id) VALUES (?,?,?,?,?,?,NULL)",
|
||||||
|
(
|
||||||
|
summe,
|
||||||
|
str(eintrag.get("share_label") or ""),
|
||||||
|
pfad,
|
||||||
|
str(eintrag.get("signed_at") or stamp),
|
||||||
|
str(eintrag.get("actor") or ""),
|
||||||
|
str(eintrag.get("names") or ""),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
bump("signatures", "created")
|
||||||
|
|
||||||
if "settings" in wanted:
|
if "settings" in wanted:
|
||||||
for key, value in (payload.get("settings") or {}).items():
|
for key, value in (payload.get("settings") or {}).items():
|
||||||
if str(key).startswith("license_"):
|
if str(key).startswith("license_"):
|
||||||
|
|||||||
@@ -51,28 +51,28 @@ def available() -> bool:
|
|||||||
HINTS: tuple[tuple[str, str], ...] = (
|
HINTS: tuple[tuple[str, str], ...] = (
|
||||||
(
|
(
|
||||||
"NT_STATUS_LOGON_FAILURE",
|
"NT_STATUS_LOGON_FAILURE",
|
||||||
"Benutzername oder Passwort wurden vom Windows-Geraet abgewiesen. Bei einem "
|
"Benutzername oder Passwort wurden vom Windows-Gerät abgewiesen. Bei einem "
|
||||||
"Domaenenkonto die Form DOMAENE\\benutzer verwenden -- mit *einem* "
|
"Domänenkonto die Form DOMAENE\\benutzer verwenden – mit *einem* "
|
||||||
"Backslash.",
|
"Backslash.",
|
||||||
),
|
),
|
||||||
(
|
(
|
||||||
"NT_STATUS_ACCESS_DENIED",
|
"NT_STATUS_ACCESS_DENIED",
|
||||||
"Das Konto darf auf diesem Geraet keinen Neustart ausloesen. Es braucht das Recht "
|
"Das Konto darf auf diesem Gerät keinen Neustart auslösen. Es braucht das Recht "
|
||||||
"'Herunterfahren von einem Remotesystem aus' -- Mitglied der lokalen "
|
"'Herunterfahren von einem Remotesystem aus' | Mitglied der lokalen "
|
||||||
"Administratoren genuegt dafuer in der Regel.",
|
"Administratoren genügt dafür in der Regel.",
|
||||||
),
|
),
|
||||||
(
|
(
|
||||||
"NT_STATUS_CONNECTION_REFUSED",
|
"NT_STATUS_CONNECTION_REFUSED",
|
||||||
"Das Geraet nimmt keine RPC-Verbindung an. Die Datei- und Druckerfreigabe muss "
|
"Das Gerät nimmt keine RPC-Verbindung an. Die Datei- und Druckerfreigabe muss "
|
||||||
"aktiv und Port 445 in der Windows-Firewall freigegeben sein.",
|
"aktiv und Port 445 in der Windows-Firewall freigegeben sein.",
|
||||||
),
|
),
|
||||||
(
|
(
|
||||||
"NT_STATUS_IO_TIMEOUT",
|
"NT_STATUS_IO_TIMEOUT",
|
||||||
"Keine Antwort auf Port 445. Ist das Geraet erreichbar und die Firewall offen?",
|
"Keine Antwort auf Port 445. Ist das Gerät erreichbar und die Firewall offen?",
|
||||||
),
|
),
|
||||||
(
|
(
|
||||||
"NT_STATUS_HOST_UNREACHABLE",
|
"NT_STATUS_HOST_UNREACHABLE",
|
||||||
"Das Geraet ist nicht erreichbar.",
|
"Das Gerät ist nicht erreichbar.",
|
||||||
),
|
),
|
||||||
# Diese Meldung liest sich wie ein technisches Detail, hat aber fast immer
|
# Diese Meldung liest sich wie ein technisches Detail, hat aber fast immer
|
||||||
# eine einzige Ursache: das Konto ist auf dem Zielgeraet kein Administrator.
|
# eine einzige Ursache: das Konto ist auf dem Zielgeraet kein Administrator.
|
||||||
@@ -81,14 +81,14 @@ HINTS: tuple[tuple[str, str], ...] = (
|
|||||||
# bekommt ein Standardkonto die winreg-Pipe gar nicht zu sehen.
|
# bekommt ein Standardkonto die winreg-Pipe gar nicht zu sehen.
|
||||||
(
|
(
|
||||||
"pipe winreg",
|
"pipe winreg",
|
||||||
"Das Konto hat auf diesem Geraet keine Administratorrechte. Ein Neustart ueber "
|
"Das Konto hat auf diesem Gerät keine Administratorrechte. Ein Neustart über "
|
||||||
"RPC braucht sie: die Pipe, ueber die er laeuft, ist fuer Standardkonten nicht "
|
"RPC braucht sie: die Pipe, über die er läuft, ist für Standardkonten nicht "
|
||||||
"sichtbar. Ein Konto der lokalen Administratoren hinterlegen -- pruefen laesst "
|
"sichtbar. Ein Konto der lokalen Administratoren hinterlegen – prüfen lässt "
|
||||||
"sich das mit 'net rpc service status RemoteRegistry'.",
|
"sich das mit 'net rpc service status RemoteRegistry'.",
|
||||||
),
|
),
|
||||||
(
|
(
|
||||||
"NT_STATUS_OBJECT_NAME_NOT_FOUND",
|
"NT_STATUS_OBJECT_NAME_NOT_FOUND",
|
||||||
"Der benoetigte RPC-Dienst antwortet auf dem Geraet nicht. Meist fehlen dem "
|
"Der benötigte RPC-Dienst antwortet auf dem Gerät nicht. Meist fehlen dem "
|
||||||
"Konto die Administratorrechte; sonst ist der Dienst 'Remoteregistrierung' "
|
"Konto die Administratorrechte; sonst ist der Dienst 'Remoteregistrierung' "
|
||||||
"gestoppt.",
|
"gestoppt.",
|
||||||
),
|
),
|
||||||
@@ -143,8 +143,8 @@ def _net_with_credentials(
|
|||||||
return RemoteResult(
|
return RemoteResult(
|
||||||
False,
|
False,
|
||||||
"",
|
"",
|
||||||
f"Auf diesem Server fehlt das Paket {REQUIRED_PACKAGE}. Ohne 'net' laesst sich "
|
f"Auf diesem Server fehlt das Paket {REQUIRED_PACKAGE}. Ohne 'net' lässt sich "
|
||||||
"ein Windows-Geraet von hier nicht ansprechen.",
|
"ein Windows-Gerät von hier nicht ansprechen.",
|
||||||
)
|
)
|
||||||
if not username:
|
if not username:
|
||||||
return RemoteResult(False, "", "Es sind keine Zugangsdaten hinterlegt.")
|
return RemoteResult(False, "", "Es sind keine Zugangsdaten hinterlegt.")
|
||||||
@@ -176,7 +176,7 @@ def _net_with_credentials(
|
|||||||
check=False,
|
check=False,
|
||||||
)
|
)
|
||||||
except subprocess.TimeoutExpired:
|
except subprocess.TimeoutExpired:
|
||||||
return RemoteResult(False, "", f"Zeitlimit von {timeout}s ueberschritten.")
|
return RemoteResult(False, "", f"Zeitlimit von {timeout}s überschritten.")
|
||||||
except OSError as exc:
|
except OSError as exc:
|
||||||
return RemoteResult(False, "", str(exc))
|
return RemoteResult(False, "", str(exc))
|
||||||
return completed.returncode, (completed.stdout + completed.stderr).strip()
|
return completed.returncode, (completed.stdout + completed.stderr).strip()
|
||||||
@@ -190,7 +190,7 @@ def _net_with_credentials(
|
|||||||
stream.flush()
|
stream.flush()
|
||||||
os.fsync(stream.fileno())
|
os.fsync(stream.fileno())
|
||||||
except OSError: # pragma: no cover - defensiv
|
except OSError: # pragma: no cover - defensiv
|
||||||
logger.warning("Anmeldedatei konnte nicht ueberschrieben werden: %s", path)
|
logger.warning("Anmeldedatei konnte nicht überschrieben werden: %s", path)
|
||||||
try:
|
try:
|
||||||
os.unlink(path)
|
os.unlink(path)
|
||||||
except OSError: # pragma: no cover - defensiv
|
except OSError: # pragma: no cover - defensiv
|
||||||
@@ -236,10 +236,10 @@ def check(
|
|||||||
return RemoteResult(
|
return RemoteResult(
|
||||||
False,
|
False,
|
||||||
output,
|
output,
|
||||||
"Anmeldung erfolgreich, aber das Konto hat auf diesem Geraet keine "
|
"Anmeldung erfolgreich, aber das Konto hat auf diesem Gerät keine "
|
||||||
"Administratorrechte. Fuer einen Neustart ueber RPC braucht es sie.",
|
"Administratorrechte. Für einen Neustart über RPC braucht es sie.",
|
||||||
)
|
)
|
||||||
return RemoteResult(False, output, hint_for(output) or "Pruefung fehlgeschlagen.")
|
return RemoteResult(False, output, hint_for(output) or "Prüfung fehlgeschlagen.")
|
||||||
|
|
||||||
|
|
||||||
def reboot(
|
def reboot(
|
||||||
@@ -275,5 +275,5 @@ def reboot(
|
|||||||
return ergebnis
|
return ergebnis
|
||||||
code, output = ergebnis
|
code, output = ergebnis
|
||||||
if code == 0:
|
if code == 0:
|
||||||
return RemoteResult(True, output or "Neustart ausgeloest.")
|
return RemoteResult(True, output or "Neustart ausgelöst.")
|
||||||
return RemoteResult(False, output, hint_for(output))
|
return RemoteResult(False, output, hint_for(output))
|
||||||
@@ -12,8 +12,8 @@ TESM_SETTINGS: tuple[SettingSpec, ...] = (
|
|||||||
"monitor_interval_seconds",
|
"monitor_interval_seconds",
|
||||||
300,
|
300,
|
||||||
"int",
|
"int",
|
||||||
"Pruefintervall (Sekunden)",
|
"Prüfintervall (Sekunden)",
|
||||||
hint="Abstand zwischen zwei Erreichbarkeitspruefungen. Lizenzpflichtig.",
|
hint="Abstand zwischen zwei Erreichbarkeitsprüfungen. Lizenzpflichtig.",
|
||||||
section="monitoring",
|
section="monitoring",
|
||||||
minimum=30,
|
minimum=30,
|
||||||
maximum=86400,
|
maximum=86400,
|
||||||
@@ -23,8 +23,8 @@ TESM_SETTINGS: tuple[SettingSpec, ...] = (
|
|||||||
2,
|
2,
|
||||||
"int",
|
"int",
|
||||||
"Fehlversuche bis zum automatischen PoE-Neustart",
|
"Fehlversuche bis zum automatischen PoE-Neustart",
|
||||||
hint="Erst nach so vielen aufeinanderfolgenden Fehlschlaegen wird geschaltet. "
|
hint="Erst nach so vielen aufeinanderfolgenden Fehlschlägen wird geschaltet. "
|
||||||
"Der Vorgaenger schaltete sofort beim ersten Ausfall.",
|
"Der Vorgänger schaltete sofort beim ersten Ausfall.",
|
||||||
section="monitoring",
|
section="monitoring",
|
||||||
minimum=1,
|
minimum=1,
|
||||||
maximum=10,
|
maximum=10,
|
||||||
@@ -40,8 +40,8 @@ TESM_SETTINGS: tuple[SettingSpec, ...] = (
|
|||||||
"dashboard_public",
|
"dashboard_public",
|
||||||
True,
|
True,
|
||||||
"bool",
|
"bool",
|
||||||
"Statusuebersicht ohne Anmeldung erreichbar",
|
"Statusübersicht ohne Anmeldung erreichbar",
|
||||||
hint="Zeigt ausschliesslich Name und Erreichbarkeit aktiver Geraete -- keine "
|
hint="Zeigt ausschließlich Name und Erreichbarkeit aktiver Geräte – keine "
|
||||||
"IP-Adressen, keine Switch-Zuordnung, keine Verwaltungsfunktionen.",
|
"IP-Adressen, keine Switch-Zuordnung, keine Verwaltungsfunktionen.",
|
||||||
section="monitoring",
|
section="monitoring",
|
||||||
),
|
),
|
||||||
@@ -74,7 +74,7 @@ TESM_SETTINGS: tuple[SettingSpec, ...] = (
|
|||||||
"fileshare_max_age_hours",
|
"fileshare_max_age_hours",
|
||||||
12,
|
12,
|
||||||
"int",
|
"int",
|
||||||
"Einbindungen automatisch loesen nach (Stunden)",
|
"Einbindungen automatisch lösen nach (Stunden)",
|
||||||
section="fileshare",
|
section="fileshare",
|
||||||
minimum=1,
|
minimum=1,
|
||||||
maximum=168,
|
maximum=168,
|
||||||
@@ -93,26 +93,50 @@ TESM_SETTINGS: tuple[SettingSpec, ...] = (
|
|||||||
"maintenance_reboot_wait",
|
"maintenance_reboot_wait",
|
||||||
300,
|
300,
|
||||||
"int",
|
"int",
|
||||||
"Wartezeit auf die Rueckkehr nach einem Neustart (Sekunden)",
|
"Wartezeit auf die Rückkehr nach einem Neustart (Sekunden)",
|
||||||
section="maintenance",
|
section="maintenance",
|
||||||
minimum=30,
|
minimum=30,
|
||||||
maximum=1800,
|
maximum=1800,
|
||||||
),
|
),
|
||||||
# -- Terminal ----------------------------------------------------------- #
|
# -- Unterschriften ------------------------------------------------------ #
|
||||||
SettingSpec(
|
SettingSpec(
|
||||||
"terminal_enabled",
|
"signature_witness_enabled",
|
||||||
True,
|
True,
|
||||||
"bool",
|
"bool",
|
||||||
"Browser-Terminal fuer Switche aktiv",
|
"Unterschriften mit dem ablegenden Konto bezeugen",
|
||||||
hint="Auch wenn aktiv: erfordert das Recht 'Ausfuehren' auf Switche, eine frische "
|
hint="Eine Zeile unter der **ersten** Unterschrift nennt das Konto, das das "
|
||||||
"Passwortbestaetigung und wird vollstaendig protokolliert.",
|
"Dokument in TESM abgelegt hat. In den Dokumenteigenschaften und im "
|
||||||
|
"Änderungsprotokoll steht es ohnehin, unabhängig von diesem Schalter. Der Name in "
|
||||||
|
"der Unterschrift ist eine Angabe des Unterschreibenden – wie auf Papier; das "
|
||||||
|
"Konto ist belegt. Eine Passwortabfrage gibt es dafür nicht.",
|
||||||
|
section="fileshare",
|
||||||
|
),
|
||||||
|
# -- Terminal ----------------------------------------------------------- #
|
||||||
|
SettingSpec(
|
||||||
|
"terminal_switches_enabled",
|
||||||
|
True,
|
||||||
|
"bool",
|
||||||
|
"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",
|
||||||
|
),
|
||||||
|
SettingSpec(
|
||||||
|
"terminal_clients_enabled",
|
||||||
|
True,
|
||||||
|
"bool",
|
||||||
|
"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",
|
section="monitoring",
|
||||||
),
|
),
|
||||||
SettingSpec(
|
SettingSpec(
|
||||||
"terminal_idle_timeout",
|
"terminal_idle_timeout",
|
||||||
300,
|
300,
|
||||||
"int",
|
"int",
|
||||||
"Terminal bei Untaetigkeit trennen nach (Sekunden)",
|
"Terminal bei Untätigkeit trennen nach (Sekunden)",
|
||||||
section="monitoring",
|
section="monitoring",
|
||||||
minimum=60,
|
minimum=60,
|
||||||
maximum=3600,
|
maximum=3600,
|
||||||
|
|||||||
@@ -149,10 +149,10 @@ sie getrennt von der Sicherung auf.', 'warning', 'Passphrase') }}
|
|||||||
</div>
|
</div>
|
||||||
<div class="card__body">
|
<div class="card__body">
|
||||||
<ul class="stack stack--tight fs-sm muted">
|
<ul class="stack stack--tight fs-sm muted">
|
||||||
<li>Verzeichniskonten werden nie exportiert -- sie entstehen am Ziel bei der ersten Anmeldung neu.</li>
|
<li>Verzeichniskonten werden nie exportiert – sie entstehen am Ziel bei der ersten Anmeldung neu.</li>
|
||||||
<li>Lizenz- und Aktivierungszustand bleiben ausgenommen: sie gehören zur Maschine, nicht zur Sicherung.</li>
|
<li>Lizenz- und Aktivierungszustand bleiben ausgenommen: sie gehören zur Maschine, nicht zur Sicherung.</li>
|
||||||
<li>Sitzungen, Ratenbegrenzung und das Änderungsprotokoll sind nicht Teil der Sicherung.</li>
|
<li>Sitzungen, Ratenbegrenzung und das Änderungsprotokoll sind nicht Teil der Sicherung.</li>
|
||||||
<li>Der Zwischenstand des Imports liegt in der Datenbank -- ein Dienst-Neustall verliert ihn nicht.</li>
|
<li>Der Zwischenstand des Imports liegt in der Datenbank – ein Dienst-Neustall verliert ihn nicht.</li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@@ -88,7 +88,7 @@
|
|||||||
<div class="flex-1">
|
<div class="flex-1">
|
||||||
<div class="card__title">{{ icon('power', size=17) }} Neustarts</div>
|
<div class="card__title">{{ icon('power', size=17) }} Neustarts</div>
|
||||||
<div class="card__sub">
|
<div class="card__sub">
|
||||||
Die letzten {{ events | length }} Neustarts -- über den PoE-Port wie per SSH
|
Die letzten {{ events | length }} Neustarts – über den PoE-Port wie per SSH
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -72,7 +72,7 @@
|
|||||||
<div class="flex-1">
|
<div class="flex-1">
|
||||||
<div class="card__title">{{ icon('network', size=17) }} Subnetze</div>
|
<div class="card__title">{{ icon('network', size=17) }} Subnetze</div>
|
||||||
<div class="card__sub">
|
<div class="card__sub">
|
||||||
Subnetz und Maske kommen vom laufenden System -- eingegeben wird nur der Adressbereich.
|
Subnetz und Maske kommen vom laufenden System – eingegeben wird nur der Adressbereich.
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -23,7 +23,7 @@
|
|||||||
<div class="tabs">
|
<div class="tabs">
|
||||||
{% for mount in mounts %}
|
{% for mount in mounts %}
|
||||||
<a class="tab {{ 'is-active' if mount.token == token else '' }}"
|
<a class="tab {{ 'is-active' if mount.token == token else '' }}"
|
||||||
href="{{ url_for('fileshare.browse', freigabe=mount.token) }}">{{ mount.label }}</a>
|
href="{{ url_for('fileshare.browse', share=mount.token) }}">{{ mount.label }}</a>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -33,9 +33,9 @@
|
|||||||
<div class="card__header">
|
<div class="card__header">
|
||||||
<div class="flex-1">
|
<div class="flex-1">
|
||||||
<nav class="breadcrumb">
|
<nav class="breadcrumb">
|
||||||
<span><a href="{{ url_for('fileshare.browse', freigabe=token) }}">{{ current.label }}</a></span>
|
<span><a href="{{ url_for('fileshare.browse', share=token) }}">{{ current.label }}</a></span>
|
||||||
{% for crumb in listing.breadcrumb %}
|
{% for crumb in listing.breadcrumb %}
|
||||||
<span><a href="{{ url_for('fileshare.browse', freigabe=token, pfad=crumb.relative) }}">{{ crumb.name }}</a></span>
|
<span><a href="{{ url_for('fileshare.browse', share=token, path=crumb.relative) }}">{{ crumb.name }}</a></span>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</nav>
|
</nav>
|
||||||
<div class="card__sub mono fs-xs">{{ current.unc }}</div>
|
<div class="card__sub mono fs-xs">{{ current.unc }}</div>
|
||||||
@@ -69,8 +69,8 @@
|
|||||||
<tr>
|
<tr>
|
||||||
{% if current_user.has('fileshare.delete') %}<td></td>{% endif %}
|
{% if current_user.has('fileshare.delete') %}<td></td>{% endif %}
|
||||||
<td colspan="4">
|
<td colspan="4">
|
||||||
<a class="link-icon" href="{{ url_for('fileshare.browse', freigabe=token,
|
<a class="link-icon" href="{{ url_for('fileshare.browse', share=token,
|
||||||
pfad=(listing.breadcrumb[-2].relative if listing.breadcrumb | length > 1 else '')) }}">
|
path=(listing.breadcrumb[-2].relative if listing.breadcrumb | length > 1 else '')) }}">
|
||||||
{{ icon('chevron-right', size=14) }} eine Ebene höher
|
{{ icon('chevron-right', size=14) }} eine Ebene höher
|
||||||
</a>
|
</a>
|
||||||
</td>
|
</td>
|
||||||
@@ -79,13 +79,26 @@
|
|||||||
{% for entry in listing.directories %}
|
{% for entry in listing.directories %}
|
||||||
<tr>
|
<tr>
|
||||||
{% if current_user.has('fileshare.delete') %}
|
{% if current_user.has('fileshare.delete') %}
|
||||||
<td><label class="check"><input type="checkbox" name="pfade" value="{{ entry.relative }}"><span class="sr-only">auswählen</span></label></td>
|
<td><label class="check"><input type="checkbox" name="paths" value="{{ entry.relative }}"><span class="sr-only">auswählen</span></label></td>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<td>
|
{# Symbol und Name in einer Zeile: der Zeilenumbruch dazwischen
|
||||||
|
liess den Ordner aussehen wie zwei halbe Zeilen. #}
|
||||||
|
<td class="file-cell">
|
||||||
{{ icon('folder', size=15) }}
|
{{ icon('folder', size=15) }}
|
||||||
<a href="{{ url_for('fileshare.browse', freigabe=token, pfad=entry.relative) }}">{{ entry.name }}</a>
|
<a href="{{ url_for('fileshare.browse', share=token, path=entry.relative) }}">{{ entry.name }}</a>
|
||||||
|
</td>
|
||||||
|
{# An der Stelle der Dateigrösse: wie viel liegt darin. #}
|
||||||
|
<td class="numeric faint fs-sm">
|
||||||
|
{%- if entry.entries is not defined or entry.entries < 0 -%}
|
||||||
|
–
|
||||||
|
{%- elif entry.entries >= 500 -%}
|
||||||
|
500+ Einträge
|
||||||
|
{%- elif entry.entries == 1 -%}
|
||||||
|
1 Eintrag
|
||||||
|
{%- else -%}
|
||||||
|
{{ entry.entries }} Einträge
|
||||||
|
{%- endif -%}
|
||||||
</td>
|
</td>
|
||||||
<td class="numeric faint">–</td>
|
|
||||||
<td class="fs-sm">{{ entry.modified | dt }}</td>
|
<td class="fs-sm">{{ entry.modified | dt }}</td>
|
||||||
<td class="actions"></td>
|
<td class="actions"></td>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -93,7 +106,7 @@
|
|||||||
{% for entry in listing.files %}
|
{% for entry in listing.files %}
|
||||||
<tr>
|
<tr>
|
||||||
{% if current_user.has('fileshare.delete') %}
|
{% if current_user.has('fileshare.delete') %}
|
||||||
<td><label class="check"><input type="checkbox" name="pfade" value="{{ entry.relative }}"><span class="sr-only">auswählen</span></label></td>
|
<td><label class="check"><input type="checkbox" name="paths" value="{{ entry.relative }}"><span class="sr-only">auswählen</span></label></td>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{# Der Name ist die grösste Fläche der Zeile -- er soll auch
|
{# Der Name ist die grösste Fläche der Zeile -- er soll auch
|
||||||
das Naheliegende tun. Was sich anzeigen lässt, öffnet die
|
das Naheliegende tun. Was sich anzeigen lässt, öffnet die
|
||||||
@@ -102,18 +115,27 @@
|
|||||||
{% if entry.previewable %}
|
{% if entry.previewable %}
|
||||||
<button class="link-icon file-name" type="button"
|
<button class="link-icon file-name" type="button"
|
||||||
data-dialog-open="file-preview"
|
data-dialog-open="file-preview"
|
||||||
data-fill-Src="{{ url_for('fileshare.preview', token=token, pfad=entry.relative) }}"
|
data-fill-Src="{{ url_for('fileshare.preview', token=token, path=entry.relative) }}"
|
||||||
|
data-fill-Pruefung="{{ url_for('fileshare.signature_state', token=token, path=entry.relative) }}"
|
||||||
data-fill-Kind="{{ entry.preview_kind }}"
|
data-fill-Kind="{{ entry.preview_kind }}"
|
||||||
data-fill-Pfad="{{ entry.relative }}"
|
data-fill-Pfad="{{ entry.relative }}"
|
||||||
data-fill-Name="{{ entry.name }}"
|
data-fill-Name="{{ entry.name }}"
|
||||||
data-fill-Download="{{ url_for('fileshare.download', token=token, pfad=entry.relative) }}">
|
{% if current_user.has('fileshare.export') %}data-fill-Download="{{ url_for('fileshare.download', token=token, path=entry.relative) }}"{% endif %}>
|
||||||
{{ icon('file-text', size=15) }} {{ entry.name }}
|
{{ icon('file-text', size=15) }} {{ entry.name }}
|
||||||
</button>
|
</button>
|
||||||
{% else %}
|
{% else %}
|
||||||
<a class="link-icon"
|
{% if current_user.has('fileshare.export') %}
|
||||||
href="{{ url_for('fileshare.download', token=token, pfad=entry.relative) }}">
|
<a class="link-icon"
|
||||||
{{ icon('file-text', size=15) }} {{ entry.name }}
|
href="{{ url_for('fileshare.download', token=token, path=entry.relative) }}">
|
||||||
</a>
|
{{ icon('file-text', size=15) }} {{ entry.name }}
|
||||||
|
</a>
|
||||||
|
{% else %}
|
||||||
|
{# Ohne Exportrecht bleibt der Name Text: es gibt hier
|
||||||
|
nichts zu holen, und ein toter Link waere Spott. #}
|
||||||
|
<span class="link-icon faint">
|
||||||
|
{{ icon('file-text', size=15) }} {{ entry.name }}
|
||||||
|
</span>
|
||||||
|
{% endif %}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</td>
|
</td>
|
||||||
<td class="numeric fs-sm">{{ entry.size | bytes }}</td>
|
<td class="numeric fs-sm">{{ entry.size | bytes }}</td>
|
||||||
@@ -122,11 +144,12 @@
|
|||||||
{% if entry.previewable %}
|
{% if entry.previewable %}
|
||||||
<button class="btn btn--sm btn--ghost" type="button"
|
<button class="btn btn--sm btn--ghost" type="button"
|
||||||
data-dialog-open="file-preview"
|
data-dialog-open="file-preview"
|
||||||
data-fill-Src="{{ url_for('fileshare.preview', token=token, pfad=entry.relative) }}"
|
data-fill-Src="{{ url_for('fileshare.preview', token=token, path=entry.relative) }}"
|
||||||
|
data-fill-Pruefung="{{ url_for('fileshare.signature_state', token=token, path=entry.relative) }}"
|
||||||
data-fill-Kind="{{ entry.preview_kind }}"
|
data-fill-Kind="{{ entry.preview_kind }}"
|
||||||
data-fill-Pfad="{{ entry.relative }}"
|
data-fill-Pfad="{{ entry.relative }}"
|
||||||
data-fill-Name="{{ entry.name }}"
|
data-fill-Name="{{ entry.name }}"
|
||||||
data-fill-Download="{{ url_for('fileshare.download', token=token, pfad=entry.relative) }}"
|
{% if current_user.has('fileshare.export') %}data-fill-Download="{{ url_for('fileshare.download', token=token, path=entry.relative) }}"{% endif %}
|
||||||
aria-label="Vorschau">{{ icon('eye', size=15) }}</button>
|
aria-label="Vorschau">{{ icon('eye', size=15) }}</button>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% if current_user.has('fileshare.edit') %}
|
{% if current_user.has('fileshare.edit') %}
|
||||||
@@ -137,9 +160,11 @@
|
|||||||
data-fill-Endung="{{ entry.suffix or '' }}"
|
data-fill-Endung="{{ entry.suffix or '' }}"
|
||||||
aria-label="Umbenennen">{{ icon('pencil', size=15) }}</button>
|
aria-label="Umbenennen">{{ icon('pencil', size=15) }}</button>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<a class="btn btn--sm btn--ghost"
|
{% if current_user.has('fileshare.export') %}
|
||||||
href="{{ url_for('fileshare.download', token=token, pfad=entry.relative) }}"
|
<a class="btn btn--sm btn--ghost"
|
||||||
aria-label="Herunterladen">{{ icon('download', size=15) }}</a>
|
href="{{ url_for('fileshare.download', token=token, path=entry.relative) }}"
|
||||||
|
aria-label="Herunterladen">{{ icon('download', size=15) }}</a>
|
||||||
|
{% endif %}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
@@ -172,7 +197,7 @@
|
|||||||
<form method="post" enctype="multipart/form-data" data-no-guard
|
<form method="post" enctype="multipart/form-data" data-no-guard
|
||||||
action="{{ url_for('fileshare.upload', token=token) }}">
|
action="{{ url_for('fileshare.upload', token=token) }}">
|
||||||
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
|
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
|
||||||
<input type="hidden" name="pfad" value="{{ listing.relative }}">
|
<input type="hidden" name="path" value="{{ listing.relative }}">
|
||||||
<div class="card__body row">
|
<div class="card__body row">
|
||||||
<input class="input" type="file" name="files" multiple required style="flex:1">
|
<input class="input" type="file" name="files" multiple required style="flex:1">
|
||||||
<button class="btn btn--primary" type="submit" data-busy-label="Lädt ...">Hochladen</button>
|
<button class="btn btn--primary" type="submit" data-busy-label="Lädt ...">Hochladen</button>
|
||||||
@@ -189,7 +214,7 @@
|
|||||||
{% call modal('folder-new', 'Ordner anlegen', listing.relative or 'Wurzelverzeichnis', 'narrow') %}
|
{% call modal('folder-new', 'Ordner anlegen', listing.relative or 'Wurzelverzeichnis', 'narrow') %}
|
||||||
<form method="post" action="{{ url_for('fileshare.make_directory', token=token) }}">
|
<form method="post" action="{{ url_for('fileshare.make_directory', token=token) }}">
|
||||||
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
|
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
|
||||||
<input type="hidden" name="pfad" value="{{ listing.relative }}">
|
<input type="hidden" name="path" value="{{ listing.relative }}">
|
||||||
<div class="modal__body">{{ field('name', 'Name', required=True) }}</div>
|
<div class="modal__body">{{ field('name', 'Name', required=True) }}</div>
|
||||||
<div class="modal__footer">
|
<div class="modal__footer">
|
||||||
<button class="btn" type="button" data-dialog-close>Abbrechen</button>
|
<button class="btn" type="button" data-dialog-close>Abbrechen</button>
|
||||||
@@ -210,8 +235,15 @@
|
|||||||
iPad in Originalgrösse (also einen Ausschnitt) und ein PDF auf iPadOS
|
iPad in Originalgrösse (also einen Ausschnitt) und ein PDF auf iPadOS
|
||||||
nur als Standbild -- Formularfelder liessen sich nicht anfassen. #}
|
nur als Standbild -- Formularfelder liessen sich nicht anfassen. #}
|
||||||
<div class="modal__body modal__body--flush" data-viewer
|
<div class="modal__body modal__body--flush" data-viewer
|
||||||
|
{# Ohne das Recht zum Ausfuellen zeigt die Vorschau das Dokument so,
|
||||||
|
wie es ist -- mit seinen eigenen Marken, ohne Eingabefelder. #}
|
||||||
|
data-viewer-formulare="{{ '1' if current_user.has('fileshare_pdf.view') else '' }}"
|
||||||
data-viewer-pdfjs="{{ asset_url('vendor/pdfjs/pdf.min.js') }}"
|
data-viewer-pdfjs="{{ asset_url('vendor/pdfjs/pdf.min.js') }}"
|
||||||
data-viewer-pdflib="{{ asset_url('vendor/pdflib/pdf-lib.min.js') }}">
|
data-viewer-pdflib="{{ asset_url('vendor/pdflib/pdf-lib.min.js') }}"
|
||||||
|
data-viewer-konto="{{ current_user.username }}"
|
||||||
|
data-viewer-zeuge="{{ '1' if signature_witness else '0' }}"
|
||||||
|
data-viewer-zeit="{{ serverzeit }}"
|
||||||
|
data-viewer-ordnerliste="{{ verzeichnisse | tojson }}">
|
||||||
<div class="viewer">
|
<div class="viewer">
|
||||||
<div class="viewer__stage" data-viewer-bildbuehne hidden>
|
<div class="viewer__stage" data-viewer-bildbuehne hidden>
|
||||||
<img class="viewer__img" alt="" data-viewer-bild>
|
<img class="viewer__img" alt="" data-viewer-bild>
|
||||||
@@ -227,12 +259,63 @@
|
|||||||
<iframe class="file-preview" title="Vorschau" data-viewer-rahmen
|
<iframe class="file-preview" title="Vorschau" data-viewer-rahmen
|
||||||
src="about:blank" referrerpolicy="same-origin" hidden></iframe>
|
src="about:blank" referrerpolicy="same-origin" hidden></iframe>
|
||||||
{# Gefragt wird erst, wenn gespeichert werden soll -- vorher waere es
|
{# Gefragt wird erst, wenn gespeichert werden soll -- vorher waere es
|
||||||
eine Zeile Formular ueber jeder Bildvorschau. #}
|
eine Zeile Formular über jeder Bildvorschau. Und nur, wer Formulare
|
||||||
|
ablegen darf, sieht sie überhaupt. #}
|
||||||
|
{% if current_user.has('fileshare_pdf.create') %}
|
||||||
|
{# Unterschreiben: Name tippen, mit dem Finger zeichnen, ins Dokument
|
||||||
|
setzen. Der Name daneben ist eine Angabe des Unterschreibenden – wie
|
||||||
|
auf Papier; wer abgelegt hat, steht im Änderungsprotokoll. #}
|
||||||
|
<div class="viewer__sign" data-viewer-unterschriftpanel hidden>
|
||||||
|
<div class="viewer__signfelder">
|
||||||
|
<label class="viewer__feld">
|
||||||
|
<span class="field__label">Vorname</span>
|
||||||
|
<input class="input" data-viewer-vorname autocomplete="off">
|
||||||
|
</label>
|
||||||
|
<label class="viewer__feld">
|
||||||
|
<span class="field__label">Nachname</span>
|
||||||
|
<input class="input" data-viewer-nachname autocomplete="off">
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<canvas class="viewer__signpad" data-viewer-signpad width="700" height="200"
|
||||||
|
aria-label="Unterschriftsfeld"></canvas>
|
||||||
|
<div class="viewer__signaktionen">
|
||||||
|
<span class="fs-xs faint" data-viewer-signhinweis></span>
|
||||||
|
<button class="btn btn--sm btn--ghost" type="button" data-viewer-signleeren>Leeren</button>
|
||||||
|
<button class="btn btn--sm btn--ghost" type="button" data-viewer-signabbrechen>Abbrechen</button>
|
||||||
|
<button class="btn btn--sm btn--primary" type="button" data-viewer-signsetzen>
|
||||||
|
Unterschrift setzen
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<p class="fs-xs faint">
|
||||||
|
Nach „Unterschrift setzen“ auf die Stelle im Dokument tippen. Mit Unterschrift
|
||||||
|
wird immer abgeschlossen gespeichert – die Felder sind danach gesperrt.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
<div class="viewer__save" data-viewer-speichernzeile hidden>
|
<div class="viewer__save" data-viewer-speichernzeile hidden>
|
||||||
|
{# Zwei eingebundene Freigaben, ein Formular: die Vorlage liegt in der
|
||||||
|
einen, die ausgefüllte Fassung gehört in die andere. #}
|
||||||
|
<label class="viewer__feld">
|
||||||
|
<span class="field__label">Freigabe</span>
|
||||||
|
<select class="select" data-viewer-ziel
|
||||||
|
data-url-vorlage="{{ url_for('fileshare.folders', token='__token__') }}">
|
||||||
|
{% for eintrag in mounts %}
|
||||||
|
<option value="{{ eintrag.token }}" {% if eintrag.token == token %}selected{% endif %}>
|
||||||
|
{{ eintrag.label }}
|
||||||
|
</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
<label class="viewer__feld">
|
<label class="viewer__feld">
|
||||||
<span class="field__label">Ordner</span>
|
<span class="field__label">Ordner</span>
|
||||||
<input class="input" list="freigabe-ordner" data-viewer-ordner
|
{# Auswahl statt Eingabe mit Vorschlagsliste: auf einem iPad laesst
|
||||||
placeholder="Wurzelverzeichnis der Freigabe" autocomplete="off">
|
sich ein <datalist> kaum bedienen -- das Freigabefeld daneben
|
||||||
|
zeigt, wie es sein soll. #}
|
||||||
|
<select class="select" data-viewer-ordner>
|
||||||
|
<option value="">Wurzelverzeichnis der Freigabe</option>
|
||||||
|
{% for eintrag in verzeichnisse %}
|
||||||
|
<option value="{{ eintrag }}">{{ eintrag }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
</label>
|
</label>
|
||||||
<label class="viewer__feld">
|
<label class="viewer__feld">
|
||||||
<span class="field__label">Dateiname (ohne Endung)</span>
|
<span class="field__label">Dateiname (ohne Endung)</span>
|
||||||
@@ -243,18 +326,38 @@
|
|||||||
{# Zwei Wege, beide gewollt: weiter ausfüllbar (das Bild wird das
|
{# Zwei Wege, beide gewollt: weiter ausfüllbar (das Bild wird das
|
||||||
Aussehen des Knopfes und lässt sich später austauschen) oder
|
Aussehen des Knopfes und lässt sich später austauschen) oder
|
||||||
abgeschlossen (alle Felder werden Teil der Seite). #}
|
abgeschlossen (alle Felder werden Teil der Seite). #}
|
||||||
|
{# Nur, was das Recht hergibt: "weiter ausfüllbar" hängt an
|
||||||
|
PDF-Formulare/Bearbeiten, "abgeschlossen" an Anlegen. Wer nur
|
||||||
|
anlegen darf, sieht eine Zeile statt einer Auswahl. #}
|
||||||
<select class="select" data-viewer-modus>
|
<select class="select" data-viewer-modus>
|
||||||
<option value="formular">Formular – weiter ausfüllbar</option>
|
{% if current_user.has('fileshare_pdf.edit') %}
|
||||||
|
<option value="formular">Formular – weiter ausfüllbar</option>
|
||||||
|
{% endif %}
|
||||||
<option value="fertig">Abgeschlossen – Felder gesperrt</option>
|
<option value="fertig">Abgeschlossen – Felder gesperrt</option>
|
||||||
</select>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
<label class="check">
|
{# Haken über den Knöpfen und beides rechts: gelesen wird die Zeile von
|
||||||
<input type="checkbox" data-viewer-ersetzen>
|
links nach rechts (wohin, wie es heißt, wie gespeichert), und die
|
||||||
<span>Vorhandene ersetzen</span>
|
Entscheidung steht am Ende. #}
|
||||||
</label>
|
<div class="viewer__saveaktionen">
|
||||||
<button class="btn btn--sm btn--primary" type="button" data-viewer-jetzt>Speichern</button>
|
<label class="check" data-viewer-ersetzenfeld>
|
||||||
<button class="btn btn--sm btn--ghost" type="button" data-viewer-abbrechen>Abbrechen</button>
|
<input type="checkbox" data-viewer-ersetzen>
|
||||||
|
<span>Vorhandene ersetzen</span>
|
||||||
|
</label>
|
||||||
|
<div class="viewer__saveknoepfe">
|
||||||
|
<button class="btn btn--sm btn--ghost" type="button" data-viewer-abbrechen>
|
||||||
|
Abbrechen
|
||||||
|
</button>
|
||||||
|
<button class="btn btn--sm btn--primary" type="button" data-viewer-jetzt>
|
||||||
|
Speichern
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{# Das Ergebnis der Pruefung steht über der Leiste – wer eine
|
||||||
|
unterschriebene Fassung öffnet, soll es sehen, bevor er liest. #}
|
||||||
|
<p class="viewer__pruefung" data-viewer-pruefung hidden></p>
|
||||||
<div class="viewer__bar" data-viewer-leiste hidden>
|
<div class="viewer__bar" data-viewer-leiste hidden>
|
||||||
<button class="btn btn--sm btn--ghost" type="button" data-viewer-zoom="aus"
|
<button class="btn btn--sm btn--ghost" type="button" data-viewer-zoom="aus"
|
||||||
aria-label="Verkleinern">{{ icon('minus', size=15) }}</button>
|
aria-label="Verkleinern">{{ icon('minus', size=15) }}</button>
|
||||||
@@ -266,22 +369,23 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="modal__footer">
|
<div class="modal__footer">
|
||||||
<a class="btn btn--sm" data-fill-target="Download" data-fill-as="href" href="#" download>
|
{% if current_user.has('fileshare.export') %}
|
||||||
{{ icon('download', size=15) }} Herunterladen
|
<a class="btn btn--sm" data-fill-target="Download" data-fill-as="href" href="#" download>
|
||||||
</a>
|
{{ icon('download', size=15) }} Herunterladen
|
||||||
{% if current_user.has('fileshare.create') %}
|
</a>
|
||||||
|
{% endif %}
|
||||||
|
{% if current_user.has('fileshare_pdf.create') %}
|
||||||
|
<button class="btn btn--sm" type="button" data-viewer-unterschreiben hidden>
|
||||||
|
{{ icon('pencil', size=15) }} Unterschreiben
|
||||||
|
</button>
|
||||||
<button class="btn btn--sm" type="button" data-viewer-speichern hidden
|
<button class="btn btn--sm" type="button" data-viewer-speichern hidden
|
||||||
data-url="{{ url_for('fileshare.save_form', token=token) }}">
|
data-url="{{ url_for('fileshare.save_form', token=token) }}">
|
||||||
{{ icon('save', size=15) }} Ausgefüllt speichern
|
{{ icon('save', size=15) }} Ausgefüllt speichern
|
||||||
</button>
|
</button>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<button class="btn btn--primary" type="button" data-dialog-close>Schliessen</button>
|
<button class="btn btn--primary" type="button" data-dialog-close>Schließen</button>
|
||||||
</div>
|
</div>
|
||||||
{% endcall %}
|
{% endcall %}
|
||||||
{# Die Ordner der Freigabe als Vorschlag; getippt werden darf trotzdem. #}
|
|
||||||
<datalist id="freigabe-ordner">
|
|
||||||
{% for eintrag in verzeichnisse %}<option value="{{ eintrag }}"></option>{% endfor %}
|
|
||||||
</datalist>
|
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
{% if listing and current_user.has('fileshare.edit') %}
|
{% if listing and current_user.has('fileshare.edit') %}
|
||||||
@@ -289,7 +393,7 @@
|
|||||||
<form method="post" action="{{ url_for('fileshare.rename', token=token) }}">
|
<form method="post" action="{{ url_for('fileshare.rename', token=token) }}">
|
||||||
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
|
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
|
||||||
<input type="hidden" name="parent" value="{{ listing.relative }}">
|
<input type="hidden" name="parent" value="{{ listing.relative }}">
|
||||||
<input type="hidden" name="pfad" data-fill-target="Pfad" value="">
|
<input type="hidden" name="path" data-fill-target="Pfad" value="">
|
||||||
<div class="modal__body">
|
<div class="modal__body">
|
||||||
{{ field('name', 'Neuer Name (ohne Endung)', required=True,
|
{{ field('name', 'Neuer Name (ohne Endung)', required=True,
|
||||||
attrs='data-fill-target="Name"') }}
|
attrs='data-fill-target="Name"') }}
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ wird nie stillschweigend übernommen: das ist der Fall, der bei einem Angriff au
|
|||||||
<div class="alert__title">Gelesener Schlüssel von {{ probed_host }}:{{ probed_port }}</div>
|
<div class="alert__title">Gelesener Schlüssel von {{ probed_host }}:{{ probed_port }}</div>
|
||||||
<div class="mono fs-sm break-all">{{ probed_type }} {{ probed_fingerprint }}</div>
|
<div class="mono fs-sm break-all">{{ probed_type }} {{ probed_fingerprint }}</div>
|
||||||
<p class="fs-sm mt-2">
|
<p class="fs-sm mt-2">
|
||||||
Vergleichen Sie den Fingerprint mit einer unabhängigen Quelle -- Konsolenzugang am
|
Vergleichen Sie den Fingerprint mit einer unabhängigen Quelle | Konsolenzugang am
|
||||||
Gerät, Inventardokumentation --, bevor Sie ihn unten freigeben.
|
Gerät, Inventardokumentation --, bevor Sie ihn unten freigeben.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -145,7 +145,7 @@ wird nie stillschweigend übernommen: das ist der Fall, der bei einem Angriff au
|
|||||||
<div class="flex-1">
|
<div class="flex-1">
|
||||||
<div class="card__title">{{ icon('alert', size=17) }} Verwaiste Freigaben</div>
|
<div class="card__title">{{ icon('alert', size=17) }} Verwaiste Freigaben</div>
|
||||||
<div class="card__sub">
|
<div class="card__sub">
|
||||||
Zu diesen Einträgen gibt es kein Ziel mehr -- gelöscht oder mit neuer IP.
|
Zu diesen Einträgen gibt es kein Ziel mehr – gelöscht oder mit neuer IP.
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -125,7 +125,7 @@
|
|||||||
{% if pending.code %}
|
{% if pending.code %}
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<span class="field__label">
|
<span class="field__label">
|
||||||
Anfragecode -- an den Anbieter übermitteln
|
Anfragecode – an den Anbieter übermitteln
|
||||||
</span>
|
</span>
|
||||||
<pre class="log-view" id="offline-request" style="max-height:14dvh">{{ pending.code }}</pre>
|
<pre class="log-view" id="offline-request" style="max-height:14dvh">{{ pending.code }}</pre>
|
||||||
<div class="row row--end mt-2">
|
<div class="row row--end mt-2">
|
||||||
@@ -234,7 +234,7 @@
|
|||||||
</dl>
|
</dl>
|
||||||
<p class="fs-xs faint mt-3">
|
<p class="fs-xs faint mt-3">
|
||||||
Der Fingerprint stammt aus der stabilen Maschinenidentität. Eine Umbenennung des Hosts
|
Der Fingerprint stammt aus der stabilen Maschinenidentität. Eine Umbenennung des Hosts
|
||||||
ändert ihn <strong>nicht</strong> mehr -- im Vorgänger führte genau das zu
|
ändert ihn <strong>nicht</strong> mehr – im Vorgänger führte genau das zu
|
||||||
„bereits anderweitig gebunden“.
|
„bereits anderweitig gebunden“.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{% extends "tesm_core/base.html" %}
|
{% extends "tesm_core/base.html" %}
|
||||||
{% from "tesm_core/macros.html" import alert %}
|
{% from "tesm_core/macros.html" import alert %}
|
||||||
{% block page_title %}Kea-DHCP-Protokoll{% endblock %}
|
{% block page_title %}DHCP-Protokoll{% endblock %}
|
||||||
{% block page_sub %}{{ path }}{% endblock %}
|
{% block page_sub %}{{ path }}{% endblock %}
|
||||||
{% block content %}
|
{% block content %}
|
||||||
{% if error %}{{ alert(error, 'warning') }}{% endif %}
|
{% if error %}{{ alert(error, 'warning') }}{% endif %}
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ Der Vorgänger protokollierte Neustarts ausdrücklich nicht.', 'info') }}
|
|||||||
<div class="card__sub">
|
<div class="card__sub">
|
||||||
{% for value, label in methods %}
|
{% for value, label in methods %}
|
||||||
<a class="btn btn--sm {{ 'btn--ghost' if method != value else '' }}"
|
<a class="btn btn--sm {{ 'btn--ghost' if method != value else '' }}"
|
||||||
href="{{ url_for('tesmlogs.restarts', methode=value) }}">{{ label }}</a>
|
href="{{ url_for('tesmlogs.restarts', method=value) }}">{{ label }}</a>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
<a class="btn btn--sm {{ 'btn--ghost' if method else '' }}"
|
<a class="btn btn--sm {{ 'btn--ghost' if method else '' }}"
|
||||||
href="{{ url_for('tesmlogs.restarts') }}">Alle</a>
|
href="{{ url_for('tesmlogs.restarts') }}">Alle</a>
|
||||||
|
|||||||
@@ -151,7 +151,7 @@ Druckerfreigabe. Der PoE-Neustart ist davon getrennt: er schaltet den Switch-Por
|
|||||||
<div class="flex-1">
|
<div class="flex-1">
|
||||||
<div class="card__title">{{ icon('clock', size=17) }} Aufträge</div>
|
<div class="card__title">{{ icon('clock', size=17) }} Aufträge</div>
|
||||||
<div class="card__sub">
|
<div class="card__sub">
|
||||||
Zustand liegt in der Datenbank -- ein Dienst-Neustart verliert ihn nicht mehr
|
Zustand liegt in der Datenbank – ein Dienst-Neustart verliert ihn nicht mehr
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -194,7 +194,7 @@ Druckerfreigabe. Der PoE-Neustart ist davon getrennt: er schaltet den Switch-Por
|
|||||||
{% block scripts %}
|
{% block scripts %}
|
||||||
<script type="module" nonce="{{ csp_nonce }}">
|
<script type="module" nonce="{{ csp_nonce }}">
|
||||||
// Auswahl beim Laden bewusst zurücksetzen: Browser stellen den Checked-Zustand
|
// Auswahl beim Laden bewusst zurücksetzen: Browser stellen den Checked-Zustand
|
||||||
// sonst aus dem Verlauf wieder her -- und dann wird bei "Neustarten"
|
// sonst aus dem Verlauf wieder her – und dann wird bei "Neustarten"
|
||||||
// versehentlich das falsche Gerät erwischt.
|
// versehentlich das falsche Gerät erwischt.
|
||||||
const boxes = Array.from(document.querySelectorAll(".device-select"));
|
const boxes = Array.from(document.querySelectorAll(".device-select"));
|
||||||
boxes.forEach((box) => (box.checked = false));
|
boxes.forEach((box) => (box.checked = false));
|
||||||
@@ -211,7 +211,7 @@ Druckerfreigabe. Der PoE-Neustart ist davon getrennt: er schaltet den Switch-Por
|
|||||||
const state = await window.tesm.api("{{ url_for('maintenance.status') }}");
|
const state = await window.tesm.api("{{ url_for('maintenance.status') }}");
|
||||||
if (state.running) window.setTimeout(() => location.reload(), state.poll_ms);
|
if (state.running) window.setTimeout(() => location.reload(), state.poll_ms);
|
||||||
} catch {
|
} catch {
|
||||||
/* stumm bleiben -- die Seite ist auch ohne Aktualisierung nutzbar */
|
/* stumm bleiben – die Seite ist auch ohne Aktualisierung nutzbar */
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
poll();
|
poll();
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
{% extends "tesm_core/base.html" %}
|
{% extends "tesm_core/base.html" %}
|
||||||
{% from "tesm_core/macros.html" import field, select_field, checkbox, alert, pill %}
|
{% from "tesm_core/macros.html" import field, select_field, checkbox, alert, pill, nav_order_card %}
|
||||||
|
|
||||||
{% block page_title %}Systemeinstellungen{% endblock %}
|
{% block page_title %}Systemeinstellungen{% endblock %}
|
||||||
{% block page_sub %}Überwachung, Protokolle, Host, Navigation{% endblock %}
|
{% block page_sub %}Überwachung, Protokolle, Host, Navigation{% endblock %}
|
||||||
@@ -54,18 +54,23 @@
|
|||||||
<section class="card">
|
<section class="card">
|
||||||
<div class="card__header">
|
<div class="card__header">
|
||||||
<div class="flex-1">
|
<div class="flex-1">
|
||||||
<div class="card__title">{{ icon('terminal', size=17) }} Switch-Konsole</div>
|
<div class="card__title">{{ icon('terminal', size=17) }} Terminal</div>
|
||||||
<div class="card__sub">
|
<div class="card__sub">
|
||||||
Auch bei aktiver Konsole gilt: eigenes Recht, frische Passwortbestätigung,
|
Auch bei aktiver Konsole gilt: eigenes Recht, frische Passwortbestätigung,
|
||||||
vollständige Mitschrift
|
vollständige Mitschrift. Abgeschaltet bleibt das Freigeben von
|
||||||
|
SSH-Host-Schlüsseln möglich – es wird für den PoE-Neustart gebraucht.
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<form method="post" action="{{ url_for('settings.system_save') }}">
|
<form method="post" action="{{ url_for('settings.system_save') }}">
|
||||||
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
|
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
|
||||||
<div class="card__body stack">
|
<div class="card__body stack">
|
||||||
{{ checkbox('terminal_enabled', 'Browser-Konsole für Switche zulassen',
|
{{ checkbox('terminal_switches_enabled', 'Browser-Konsole für Switche zulassen',
|
||||||
checked=values.terminal_enabled) }}
|
checked=values.terminal_switches_enabled,
|
||||||
|
hint_text=specs['terminal_switches_enabled'].hint) }}
|
||||||
|
{{ checkbox('terminal_clients_enabled', 'Browser-Konsole für Clients zulassen',
|
||||||
|
checked=values.terminal_clients_enabled,
|
||||||
|
hint_text=specs['terminal_clients_enabled'].hint) }}
|
||||||
{{ field('terminal_idle_timeout', 'Trennen bei Untätigkeit (Sekunden)',
|
{{ field('terminal_idle_timeout', 'Trennen bei Untätigkeit (Sekunden)',
|
||||||
value=values.terminal_idle_timeout, type='number') }}
|
value=values.terminal_idle_timeout, type='number') }}
|
||||||
</div>
|
</div>
|
||||||
@@ -73,6 +78,60 @@
|
|||||||
</form>
|
</form>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<section class="card">
|
||||||
|
<div class="card__header">
|
||||||
|
<div class="flex-1">
|
||||||
|
<div class="card__title">{{ icon('pencil', size=17) }} Unterschriften</div>
|
||||||
|
<div class="card__sub">
|
||||||
|
Der Name in der Unterschrift ist eine Angabe des Unterschreibenden – wie auf
|
||||||
|
Papier. Wer das Dokument abgelegt hat, ist dagegen belegt: in den
|
||||||
|
Dokumenteigenschaften und im Änderungsprotokoll, auf Wunsch zusätzlich
|
||||||
|
sichtbar unter der ersten Unterschrift.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<form method="post" action="{{ url_for('settings.system_save') }}">
|
||||||
|
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
|
||||||
|
<div class="card__body stack">
|
||||||
|
{{ checkbox('signature_witness_enabled', 'Unterschriften mit dem ablegenden Konto bezeugen',
|
||||||
|
checked=values.signature_witness_enabled,
|
||||||
|
hint_text=specs['signature_witness_enabled'].hint) }}
|
||||||
|
</div>
|
||||||
|
<div class="card__footer"><button class="btn btn--primary" type="submit">Speichern</button></div>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{# Eigene Karte: die Abmeldung stand zwischen Protokollrotation und
|
||||||
|
Papierkorb -- drei Dinge ohne Zusammenhang. Wer die Sitzungsdauer sucht,
|
||||||
|
sucht sie nicht unter "Protokolle". #}
|
||||||
|
<section class="card">
|
||||||
|
<div class="card__header">
|
||||||
|
<div class="flex-1">
|
||||||
|
<div class="card__title">{{ icon('clock', size=17) }} Sitzungen</div>
|
||||||
|
<div class="card__sub">
|
||||||
|
Wie lange eine Anmeldung gilt. Beides bestimmt auch, wie lange eine
|
||||||
|
eingebundene Dateifreigabe bestehen bleibt – sie endet mit der Sitzung.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<form method="post" action="{{ url_for('settings.system_save') }}">
|
||||||
|
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
|
||||||
|
<div class="card__body">
|
||||||
|
<div class="form-grid">
|
||||||
|
{{ field('session_idle_minutes', 'Automatische Abmeldung nach Leerlauf (Minuten)',
|
||||||
|
value=values.session_idle_minutes, type='number',
|
||||||
|
attrs='min="5" max="1440" step="1"',
|
||||||
|
hint_text=specs['session_idle_minutes'].hint) }}
|
||||||
|
{{ field('session_absolute_hours', 'Längstmögliche Sitzungsdauer (Stunden)',
|
||||||
|
value=values.session_absolute_hours, type='number',
|
||||||
|
attrs='min="1" max="168" step="1"',
|
||||||
|
hint_text=specs['session_absolute_hours'].hint) }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="card__footer"><button class="btn btn--primary" type="submit">Speichern</button></div>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
|
||||||
<section class="card">
|
<section class="card">
|
||||||
<div class="card__header">
|
<div class="card__header">
|
||||||
<div class="flex-1">
|
<div class="flex-1">
|
||||||
@@ -87,9 +146,6 @@
|
|||||||
specs['log_rotate_interval'].choices, selected=values.log_rotate_interval) }}
|
specs['log_rotate_interval'].choices, selected=values.log_rotate_interval) }}
|
||||||
{{ field('log_rotate_keep', 'Aufbewahrte Rotationen', value=values.log_rotate_keep,
|
{{ field('log_rotate_keep', 'Aufbewahrte Rotationen', value=values.log_rotate_keep,
|
||||||
type='number') }}
|
type='number') }}
|
||||||
{{ field('session_idle_minutes', 'Automatische Abmeldung (Minuten)',
|
|
||||||
value=values.session_idle_minutes, type='number',
|
|
||||||
hint_text=specs['session_idle_minutes'].hint) }}
|
|
||||||
{{ field('trash_retention_days', 'Papierkorb-Aufbewahrung (Tage)',
|
{{ field('trash_retention_days', 'Papierkorb-Aufbewahrung (Tage)',
|
||||||
value=values.trash_retention_days, type='number',
|
value=values.trash_retention_days, type='number',
|
||||||
hint_text=specs['trash_retention_days'].hint) }}
|
hint_text=specs['trash_retention_days'].hint) }}
|
||||||
@@ -133,39 +189,8 @@
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
{% if current_user.is_admin %}
|
{% if current_user.is_admin %}
|
||||||
<section class="card">
|
{{ nav_order_card(nav_items, nav_order, nav_child_order,
|
||||||
<div class="card__header">
|
url_for('admin.nav_order'), csrf_field_name, csrf_token()) }}
|
||||||
<div class="flex-1">
|
|
||||||
<div class="card__title">{{ icon('sliders', size=17) }} Navigationsreihenfolge</div>
|
|
||||||
<div class="card__sub">Gilt für alle Benutzer; jeder sieht weiter nur, was er darf</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<form method="post" action="{{ url_for('admin.nav_order') }}">
|
|
||||||
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
|
|
||||||
<div class="card__body">
|
|
||||||
<ol class="stack stack--tight">
|
|
||||||
{% for key in nav_order %}
|
|
||||||
{% set item = nav_items | selectattr('key', 'equalto', key) | list | first %}
|
|
||||||
{% if item %}
|
|
||||||
<li class="row row--tight">
|
|
||||||
<input class="input" type="number" name="position_{{ item.key }}" value="{{ loop.index }}"
|
|
||||||
min="1" style="width:70px" disabled>
|
|
||||||
<input type="hidden" name="order" value="{{ item.key }}">
|
|
||||||
<span class="flex-1">{{ icon(item.icon, size=15) }} {{ item.label }}</span>
|
|
||||||
</li>
|
|
||||||
{% endif %}
|
|
||||||
{% endfor %}
|
|
||||||
</ol>
|
|
||||||
<p class="fs-xs faint mt-3">
|
|
||||||
Die Reihenfolge folgt der Liste. Zum Umsortieren die Einträge in der gewünschten
|
|
||||||
Reihenfolge speichern (Drag-and-drop folgt in einer späteren Version).
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div class="card__footer">
|
|
||||||
<button class="btn btn--sm" type="submit">Reihenfolge speichern</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</section>
|
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ class MonitorDaemon:
|
|||||||
self.running = True
|
self.running = True
|
||||||
|
|
||||||
def stop(self, signum: int, _frame: FrameType | None) -> None:
|
def stop(self, signum: int, _frame: FrameType | None) -> None:
|
||||||
logger.info("Signal %s empfangen -- beende nach dem laufenden Durchlauf.", signum)
|
logger.info("Signal %s empfangen – beende nach dem laufenden Durchlauf.", signum)
|
||||||
self.running = False
|
self.running = False
|
||||||
|
|
||||||
# ------------------------------------------------------------------ #
|
# ------------------------------------------------------------------ #
|
||||||
@@ -68,8 +68,8 @@ class MonitorDaemon:
|
|||||||
|
|
||||||
def run(self) -> int:
|
def run(self) -> int:
|
||||||
live = self.extension.registry["live_log"]
|
live = self.extension.registry["live_log"]
|
||||||
live.info("monitor", "Ueberwachungsprozess gestartet")
|
live.info("monitor", "Überwachungsprozess gestartet")
|
||||||
logger.info("Ueberwachung laeuft, Datenbank: %s", self.extension.config.db_path)
|
logger.info("Überwachung läuft, Datenbank: %s", self.extension.config.db_path)
|
||||||
|
|
||||||
while self.running:
|
while self.running:
|
||||||
interval = self._interval()
|
interval = self._interval()
|
||||||
@@ -84,8 +84,8 @@ class MonitorDaemon:
|
|||||||
failures_before_restart=self._failures_before_restart(),
|
failures_before_restart=self._failures_before_restart(),
|
||||||
)
|
)
|
||||||
logger.info(
|
logger.info(
|
||||||
"Durchlauf: %s geprueft, %s online, %s offline, %s Neustarts (%s erfolgreich), "
|
"Durchlauf: %s geprüft, %s online, %s offline, %s Neustarts (%s erfolgreich), "
|
||||||
"%s ohne Lizenz uebersprungen, %sms",
|
"%s ohne Lizenz übersprungen, %sms",
|
||||||
report.checked,
|
report.checked,
|
||||||
report.online,
|
report.online,
|
||||||
report.offline,
|
report.offline,
|
||||||
@@ -96,7 +96,7 @@ class MonitorDaemon:
|
|||||||
)
|
)
|
||||||
except Exception: # noqa: BLE001 - der Dienst darf nie sterben
|
except Exception: # noqa: BLE001 - der Dienst darf nie sterben
|
||||||
logger.exception("Durchlauf fehlgeschlagen")
|
logger.exception("Durchlauf fehlgeschlagen")
|
||||||
live.error("monitor", "Durchlauf fehlgeschlagen -- siehe app.log")
|
live.error("monitor", "Durchlauf fehlgeschlagen – siehe app.log")
|
||||||
|
|
||||||
# Das Aufraeumen abgelaufener Freigabe-Einbindungen laeuft **nicht**
|
# Das Aufraeumen abgelaufener Freigabe-Einbindungen laeuft **nicht**
|
||||||
# hier, sondern im Webprozess: eingebunden wird in dessen
|
# hier, sondern im Webprozess: eingebunden wird in dessen
|
||||||
@@ -112,7 +112,7 @@ class MonitorDaemon:
|
|||||||
time.sleep(min(1.0, remaining))
|
time.sleep(min(1.0, remaining))
|
||||||
remaining -= 1.0
|
remaining -= 1.0
|
||||||
|
|
||||||
live.info("monitor", "Ueberwachungsprozess beendet")
|
live.info("monitor", "Überwachungsprozess beendet")
|
||||||
return 0
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+10
-1
@@ -168,6 +168,8 @@ LOG_DIR="/var/log/$NAME"
|
|||||||
SERVICE_USER="$APP"
|
SERVICE_USER="$APP"
|
||||||
UNIT="$NAME.service"
|
UNIT="$NAME.service"
|
||||||
MONITOR_UNIT="$NAME-monitor.service"
|
MONITOR_UNIT="$NAME-monitor.service"
|
||||||
|
CLEANUP_UNIT="$NAME-cleanup.service"
|
||||||
|
CLEANUP_TIMER="$NAME-cleanup.timer"
|
||||||
NGINX_SITE="/etc/nginx/sites-available/$NAME"
|
NGINX_SITE="/etc/nginx/sites-available/$NAME"
|
||||||
ENV_PREFIX="TESM_"
|
ENV_PREFIX="TESM_"
|
||||||
[[ "$APP" == "tesm-license" ]] && ENV_PREFIX="TESM_LICENSE_"
|
[[ "$APP" == "tesm-license" ]] && ENV_PREFIX="TESM_LICENSE_"
|
||||||
@@ -539,6 +541,11 @@ render_unit "$ROOT/deploy/systemd/$APP.service" "/etc/systemd/system/$UNIT"
|
|||||||
if [[ "$APP" == "tesm" ]]; then
|
if [[ "$APP" == "tesm" ]]; then
|
||||||
render_unit "$ROOT/deploy/systemd/tesm-monitor.service" "/etc/systemd/system/$MONITOR_UNIT"
|
render_unit "$ROOT/deploy/systemd/tesm-monitor.service" "/etc/systemd/system/$MONITOR_UNIT"
|
||||||
fi
|
fi
|
||||||
|
# Aufraeumen lief bisher nur von Hand -- also praktisch nie. Auf einer
|
||||||
|
# laufenden Installation lagen deshalb hunderte abgelaufene Sitzungszeilen in
|
||||||
|
# der Datenbank, und das Aenderungsprotokoll waere nie archiviert worden.
|
||||||
|
render_unit "$ROOT/deploy/systemd/$APP-cleanup.service" "/etc/systemd/system/$CLEANUP_UNIT"
|
||||||
|
render_unit "$ROOT/deploy/systemd/$APP-cleanup.timer" "/etc/systemd/system/$CLEANUP_TIMER"
|
||||||
[[ $UNITS_CHANGED -eq 1 ]] && systemctl daemon-reload && info "Units neu geladen" \
|
[[ $UNITS_CHANGED -eq 1 ]] && systemctl daemon-reload && info "Units neu geladen" \
|
||||||
|| info "Units unveraendert"
|
|| info "Units unveraendert"
|
||||||
|
|
||||||
@@ -611,6 +618,8 @@ systemctl enable --now "$UNIT" >/dev/null
|
|||||||
if [[ "$APP" == "tesm" ]]; then
|
if [[ "$APP" == "tesm" ]]; then
|
||||||
systemctl enable --now "$MONITOR_UNIT" >/dev/null
|
systemctl enable --now "$MONITOR_UNIT" >/dev/null
|
||||||
fi
|
fi
|
||||||
|
# Der Timer, nicht der Dienst: der Dienst laeuft einmal und ist fertig.
|
||||||
|
systemctl enable --now "$CLEANUP_TIMER" >/dev/null 2>&1 && info "Aufraeumen: taeglich 03:30" || info "Aufraeum-Timer nicht aktiviert -- 'systemctl status $CLEANUP_TIMER' zeigt warum"
|
||||||
|
|
||||||
# -- Health-Check und Rollback ----------------------------------------------
|
# -- Health-Check und Rollback ----------------------------------------------
|
||||||
step "Gesundheitspruefung"
|
step "Gesundheitspruefung"
|
||||||
@@ -620,7 +629,7 @@ for attempt in $(seq 1 20); do
|
|||||||
# pruefen ging schon einmal schief -- auf einem gemeinsamen Port antwortete
|
# pruefen ging schon einmal schief -- auf einem gemeinsamen Port antwortete
|
||||||
# eine andere Instanz, und ein toter Dienst galt als gesund.
|
# eine andere Instanz, und ein toter Dienst galt als gesund.
|
||||||
if systemctl is-active --quiet "$UNIT" &&
|
if systemctl is-active --quiet "$UNIT" &&
|
||||||
curl -fsS --max-time 3 "http://127.0.0.1:$PORT/gesundheit" >/dev/null 2>&1; then
|
curl -fsS --max-time 3 "http://127.0.0.1:$PORT/health" >/dev/null 2>&1; then
|
||||||
HEALTHY=1
|
HEALTHY=1
|
||||||
info "Antwort nach ${attempt}x"
|
info "Antwort nach ${attempt}x"
|
||||||
break
|
break
|
||||||
|
|||||||
@@ -0,0 +1,34 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=TESM -- Aufraeumen: Sitzungen, Ratenereignisse, Protokollarchiv
|
||||||
|
After=tesm.service
|
||||||
|
# Ohne die laufende Anwendung ist nichts kaputt -- der Befehl arbeitet direkt
|
||||||
|
# auf der Datenbank. "After" sorgt nur fuer eine sinnvolle Reihenfolge beim
|
||||||
|
# Hochfahren.
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=oneshot
|
||||||
|
User=tesm
|
||||||
|
Group=tesm
|
||||||
|
WorkingDirectory=/srv/tesm
|
||||||
|
Environment=TESM_BASE_DIR=/srv/tesm
|
||||||
|
Environment=TESM_DATA_DIR=/srv/tesm/data
|
||||||
|
Environment=TESM_LOG_DIR=/var/log/tesm
|
||||||
|
Environment=PYTHONUNBUFFERED=1
|
||||||
|
ExecStart=/srv/tesm/venv/bin/tesm-admin housekeeping
|
||||||
|
|
||||||
|
# Hier darf voll gehaertet werden: dieser Aufruf fasst nur die Datenbank und
|
||||||
|
# das Protokollverzeichnis an, keine Systemdateien und keinen Helfer per sudo.
|
||||||
|
NoNewPrivileges=yes
|
||||||
|
PrivateTmp=yes
|
||||||
|
ProtectSystem=strict
|
||||||
|
ProtectHome=yes
|
||||||
|
ProtectKernelTunables=yes
|
||||||
|
ProtectKernelModules=yes
|
||||||
|
ProtectControlGroups=yes
|
||||||
|
RestrictSUIDSGID=yes
|
||||||
|
RestrictNamespaces=yes
|
||||||
|
RestrictAddressFamilies=AF_UNIX
|
||||||
|
LockPersonality=yes
|
||||||
|
SystemCallArchitectures=native
|
||||||
|
UMask=0027
|
||||||
|
ReadWritePaths=/srv/tesm/data /var/log/tesm
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=TESM -- taegliches Aufraeumen
|
||||||
|
|
||||||
|
[Timer]
|
||||||
|
# Nachts, wenn niemand arbeitet: abgelaufene Sitzungen, alte Ratenereignisse
|
||||||
|
# und die Archivierung des Aenderungsprotokolls.
|
||||||
|
#
|
||||||
|
# Vorher lief das nur, wenn jemand "tesm-admin housekeeping" aufrief oder in
|
||||||
|
# der Diagnose darauf klickte -- also praktisch nie. Gemessen auf einer
|
||||||
|
# Installation nach vier Wochen: 747 Sitzungszeilen, 682 davon abgelaufen.
|
||||||
|
OnCalendar=*-*-* 03:30:00
|
||||||
|
# Streuung, damit nicht jede Installation im Netz zur selben Sekunde loslegt.
|
||||||
|
RandomizedDelaySec=15m
|
||||||
|
# Nachgeholt, wenn die Maschine nachts aus war.
|
||||||
|
Persistent=true
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=timers.target
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=TESM-Lizenzserver -- Aufraeumen: Sitzungen, Ratenereignisse, Protokollarchiv
|
||||||
|
After=tesm-license.service
|
||||||
|
# Ohne die laufende Anwendung ist nichts kaputt -- der Befehl arbeitet direkt
|
||||||
|
# auf der Datenbank. "After" sorgt nur fuer eine sinnvolle Reihenfolge beim
|
||||||
|
# Hochfahren.
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=oneshot
|
||||||
|
User=tesm-license
|
||||||
|
Group=tesm-license
|
||||||
|
WorkingDirectory=/srv/tesm-license
|
||||||
|
Environment=TESM_LICENSE_BASE_DIR=/srv/tesm-license
|
||||||
|
Environment=TESM_LICENSE_DATA_DIR=/srv/tesm-license/data
|
||||||
|
Environment=TESM_LICENSE_LOG_DIR=/var/log/tesm-license
|
||||||
|
Environment=PYTHONUNBUFFERED=1
|
||||||
|
ExecStart=/srv/tesm-license/venv/bin/tesm-license-admin housekeeping
|
||||||
|
|
||||||
|
# Hier darf voll gehaertet werden: dieser Aufruf fasst nur die Datenbank und
|
||||||
|
# das Protokollverzeichnis an, keine Systemdateien und keinen Helfer per sudo.
|
||||||
|
NoNewPrivileges=yes
|
||||||
|
PrivateTmp=yes
|
||||||
|
ProtectSystem=strict
|
||||||
|
ProtectHome=yes
|
||||||
|
ProtectKernelTunables=yes
|
||||||
|
ProtectKernelModules=yes
|
||||||
|
ProtectControlGroups=yes
|
||||||
|
RestrictSUIDSGID=yes
|
||||||
|
RestrictNamespaces=yes
|
||||||
|
RestrictAddressFamilies=AF_UNIX
|
||||||
|
LockPersonality=yes
|
||||||
|
SystemCallArchitectures=native
|
||||||
|
UMask=0027
|
||||||
|
ReadWritePaths=/srv/tesm-license/data /var/log/tesm-license
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=TESM-Lizenzserver -- taegliches Aufraeumen
|
||||||
|
|
||||||
|
[Timer]
|
||||||
|
# Nachts, wenn niemand arbeitet: abgelaufene Sitzungen, alte Ratenereignisse
|
||||||
|
# und die Archivierung des Aenderungsprotokolls.
|
||||||
|
#
|
||||||
|
# Vorher lief das nur, wenn jemand "tesm-license-admin housekeeping" aufrief oder in
|
||||||
|
# der Diagnose darauf klickte -- also praktisch nie. Gemessen auf einer
|
||||||
|
# Installation nach vier Wochen: 747 Sitzungszeilen, 682 davon abgelaufen.
|
||||||
|
OnCalendar=*-*-* 03:30:00
|
||||||
|
# Streuung, damit nicht jede Installation im Netz zur selben Sekunde loslegt.
|
||||||
|
RandomizedDelaySec=15m
|
||||||
|
# Nachgeholt, wenn die Maschine nachts aus war.
|
||||||
|
Persistent=true
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=timers.target
|
||||||
+1
-1
@@ -446,7 +446,7 @@ Unverzichtbar:
|
|||||||
**Die Anwendung antwortet nicht.**
|
**Die Anwendung antwortet nicht.**
|
||||||
```bash
|
```bash
|
||||||
journalctl -u tesm -n 200 --no-pager
|
journalctl -u tesm -n 200 --no-pager
|
||||||
curl -s http://127.0.0.1:5000/gesundheit
|
curl -s http://127.0.0.1:5000/health
|
||||||
```
|
```
|
||||||
Antwortet gunicorn, aber der Browser nicht, liegt es an nginx.
|
Antwortet gunicorn, aber der Browser nicht, liegt es an nginx.
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "tesm-core"
|
name = "tesm-core"
|
||||||
version = "2.3.3"
|
version = "2.7.6"
|
||||||
description = "Gemeinsames Anwendungsgeruest (Auth, RBAC, Navigation, Design-System, Migrationen) fuer TESM und TESM-Lizenzserver"
|
description = "Gemeinsames Anwendungsgeruest (Auth, RBAC, Navigation, Design-System, Migrationen) fuer TESM und TESM-Lizenzserver"
|
||||||
requires-python = ">=3.11"
|
requires-python = ">=3.11"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
|||||||
@@ -13,10 +13,12 @@ import secrets
|
|||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
from typing import Any, Sequence
|
from typing import Any, Sequence
|
||||||
|
|
||||||
from flask import Blueprint, Flask, g, render_template, request
|
from urllib.parse import urlencode
|
||||||
|
|
||||||
|
from flask import Blueprint, Flask, g, redirect, render_template, request
|
||||||
from werkzeug.exceptions import HTTPException
|
from werkzeug.exceptions import HTTPException
|
||||||
|
|
||||||
from . import audit, logging_setup, ui
|
from . import audit, legacy_urls, logging_setup, ui
|
||||||
from .config import CoreConfig
|
from .config import CoreConfig
|
||||||
from .core_schema import CORE_MIGRATIONS
|
from .core_schema import CORE_MIGRATIONS
|
||||||
from .db import Database
|
from .db import Database
|
||||||
@@ -57,18 +59,29 @@ CORE_SETTINGS: tuple[SettingSpec, ...] = (
|
|||||||
30,
|
30,
|
||||||
"int",
|
"int",
|
||||||
"Aufbewahrung im Papierkorb (Tage)",
|
"Aufbewahrung im Papierkorb (Tage)",
|
||||||
hint="Nach dieser Frist werden Eintraege beim naechsten Aufruf endgueltig entfernt.",
|
hint="Nach dieser Frist werden Einträge beim nächsten Aufruf endgültig entfernt.",
|
||||||
section="system",
|
section="system",
|
||||||
minimum=1,
|
minimum=1,
|
||||||
maximum=3650,
|
maximum=3650,
|
||||||
),
|
),
|
||||||
|
SettingSpec(
|
||||||
|
"session_absolute_hours",
|
||||||
|
12,
|
||||||
|
"int",
|
||||||
|
"Längstmögliche Sitzungsdauer (Stunden)",
|
||||||
|
hint="Danach ist Schluss, auch bei ständiger Arbeit – eine Sitzung soll nicht "
|
||||||
|
"ewig leben. Die eingebundenen Dateifreigaben enden mit ihr.",
|
||||||
|
section="system",
|
||||||
|
minimum=1,
|
||||||
|
maximum=168,
|
||||||
|
),
|
||||||
SettingSpec(
|
SettingSpec(
|
||||||
"session_idle_minutes",
|
"session_idle_minutes",
|
||||||
30,
|
30,
|
||||||
"int",
|
"int",
|
||||||
"Automatische Abmeldung nach Leerlauf (Minuten)",
|
"Automatische Abmeldung nach Leerlauf (Minuten)",
|
||||||
hint="Jede Anfrage setzt den Zaehler zurueck -- gezaehlt wird nur Untaetigkeit. "
|
hint="Jede Anfrage setzt den Zähler zurück – gezählt wird nur Untätigkeit. "
|
||||||
"Gilt auch fuer die eingebundenen Dateifreigaben: mit der Sitzung endet die "
|
"Gilt auch für die eingebundenen Dateifreigaben: mit der Sitzung endet die "
|
||||||
"Einbindung.",
|
"Einbindung.",
|
||||||
section="system",
|
section="system",
|
||||||
minimum=5,
|
minimum=5,
|
||||||
@@ -101,8 +114,8 @@ CORE_SETTINGS: tuple[SettingSpec, ...] = (
|
|||||||
SettingSpec("web_hsts_enabled", False, "bool", "HSTS senden", section="web"),
|
SettingSpec("web_hsts_enabled", False, "bool", "HSTS senden", section="web"),
|
||||||
SettingSpec("web_redirect_http", True, "bool", "HTTP auf HTTPS umleiten", section="web"),
|
SettingSpec("web_redirect_http", True, "bool", "HTTP auf HTTPS umleiten", section="web"),
|
||||||
SettingSpec("web_cert_path", "", "str", "Zertifikat (Pfad)", section="web"),
|
SettingSpec("web_cert_path", "", "str", "Zertifikat (Pfad)", section="web"),
|
||||||
SettingSpec("web_key_path", "", "str", "Privater Schluessel (Pfad)", section="web"),
|
SettingSpec("web_key_path", "", "str", "Privater Schlüssel (Pfad)", section="web"),
|
||||||
SettingSpec("web_acme_email", "", "str", "E-Mail fuer Let's Encrypt", section="web"),
|
SettingSpec("web_acme_email", "", "str", "E-Mail für Let's Encrypt", section="web"),
|
||||||
SettingSpec("web_acme_webroot", "", "str", "ACME-Webroot", section="web"),
|
SettingSpec("web_acme_webroot", "", "str", "ACME-Webroot", section="web"),
|
||||||
# Abweichende Ports braucht nur eine Testinstanz neben der Produktion --
|
# Abweichende Ports braucht nur eine Testinstanz neben der Produktion --
|
||||||
# im Regelbetrieb bleibt es bei 80 und 443.
|
# im Regelbetrieb bleibt es bei 80 und 443.
|
||||||
@@ -113,7 +126,7 @@ CORE_SETTINGS: tuple[SettingSpec, ...] = (
|
|||||||
"web_https_port", 443, "int", "HTTPS-Port", section="web", minimum=1, maximum=65535
|
"web_https_port", 443, "int", "HTTPS-Port", section="web", minimum=1, maximum=65535
|
||||||
),
|
),
|
||||||
SettingSpec(
|
SettingSpec(
|
||||||
"web_max_body_mb", 128, "int", "Maximale Anfragegroesse (MB)", section="web",
|
"web_max_body_mb", 128, "int", "Maximale Anfragegröße (MB)", section="web",
|
||||||
minimum=1, maximum=8192,
|
minimum=1, maximum=8192,
|
||||||
),
|
),
|
||||||
*directory.SETTINGS,
|
*directory.SETTINGS,
|
||||||
@@ -135,7 +148,7 @@ def create_base_app(
|
|||||||
#: Wortmarke, ohne Zeile darunter; die Marke selbst zeigen beide
|
#: Wortmarke, ohne Zeile darunter; die Marke selbst zeigen beide
|
||||||
#: Anwendungen.
|
#: Anwendungen.
|
||||||
auth_tagline: str = "",
|
auth_tagline: str = "",
|
||||||
app_version: str = "2.3.3",
|
app_version: str = "2.7.6",
|
||||||
built_in_trusted_keys: tuple[str, ...] = (),
|
built_in_trusted_keys: tuple[str, ...] = (),
|
||||||
enable_license: bool = True,
|
enable_license: bool = True,
|
||||||
template_folder: str | None = None,
|
template_folder: str | None = None,
|
||||||
@@ -147,6 +160,10 @@ def create_base_app(
|
|||||||
static_folder=static_folder or "static",
|
static_folder=static_folder or "static",
|
||||||
static_url_path="/assets",
|
static_url_path="/assets",
|
||||||
)
|
)
|
||||||
|
# Umlaute bleiben Umlaute -- auch in JSON. Flask wuerde sonst nach ASCII
|
||||||
|
# ausweichen ("ü"), und genau darin stehen die Flash-Meldungen in der
|
||||||
|
# Seite: eine Meldung mit "ue" war lesbar, eine mit "ü" wurde zu Zahlen.
|
||||||
|
app.json.ensure_ascii = False
|
||||||
|
|
||||||
# Templates und Design-System des Kerns liegen im Paket ``tesm_core`` und
|
# Templates und Design-System des Kerns liegen im Paket ``tesm_core`` und
|
||||||
# werden ueber einen eigenen Blueprint bereitgestellt. Dadurch kann jede
|
# werden ueber einen eigenen Blueprint bereitgestellt. Dadurch kann jede
|
||||||
@@ -230,12 +247,21 @@ def create_base_app(
|
|||||||
wert = 0
|
wert = 0
|
||||||
return wert if wert > 0 else config.security.session_idle_minutes
|
return wert if wert > 0 else config.security.session_idle_minutes
|
||||||
|
|
||||||
|
def _gesamtdauer(conn: Any) -> int:
|
||||||
|
"""Gesamtdauer aus den Einstellungen -- ueber dieselbe offene Verbindung."""
|
||||||
|
try:
|
||||||
|
wert = int(settings.get(conn, "session_absolute_hours") or 0)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
wert = 0
|
||||||
|
return wert if wert > 0 else config.security.session_absolute_hours
|
||||||
|
|
||||||
app.session_interface = SqliteSessionInterface(
|
app.session_interface = SqliteSessionInterface(
|
||||||
database,
|
database,
|
||||||
cookie_name=app.config["SESSION_COOKIE_NAME"],
|
cookie_name=app.config["SESSION_COOKIE_NAME"],
|
||||||
idle_minutes=config.security.session_idle_minutes,
|
idle_minutes=config.security.session_idle_minutes,
|
||||||
absolute_hours=config.security.session_absolute_hours,
|
absolute_hours=config.security.session_absolute_hours,
|
||||||
idle_lookup=_leerlauf,
|
idle_lookup=_leerlauf,
|
||||||
|
absolute_lookup=_gesamtdauer,
|
||||||
)
|
)
|
||||||
|
|
||||||
# -- Schema und Startzustand ------------------------------------------ #
|
# -- Schema und Startzustand ------------------------------------------ #
|
||||||
@@ -336,14 +362,24 @@ def create_base_app(
|
|||||||
# -- Fehlerseiten ------------------------------------------------------ #
|
# -- Fehlerseiten ------------------------------------------------------ #
|
||||||
@app.errorhandler(HTTPException)
|
@app.errorhandler(HTTPException)
|
||||||
def _http_error(error: HTTPException): # type: ignore[no-untyped-def]
|
def _http_error(error: HTTPException): # type: ignore[no-untyped-def]
|
||||||
|
if error.code == 404:
|
||||||
|
# Vor der Fehlerseite: war das eine Adresse aus der Zeit vor 2.5.0?
|
||||||
|
# Erst hier gefragt, damit die Tabelle nie eine bestehende Route
|
||||||
|
# verdeckt -- auf dem Lizenzserver ist /lizenz/<ticket> echt.
|
||||||
|
ziel = legacy_urls.neue_adresse(request.path)
|
||||||
|
if ziel is not None:
|
||||||
|
parameter = legacy_urls.neue_parameter(request.args.to_dict(flat=True))
|
||||||
|
if parameter:
|
||||||
|
ziel = ziel + "?" + urlencode(parameter)
|
||||||
|
return redirect(ziel)
|
||||||
if error.code == 413:
|
if error.code == 413:
|
||||||
# "Request Entity Too Large" allein hilft niemandem weiter: die
|
# "Request Entity Too Large" allein hilft niemandem weiter: die
|
||||||
# Zahl steht in einer Einstellung, und wer hochlaedt, soll sie
|
# Zahl steht in einer Einstellung, und wer hochlaedt, soll sie
|
||||||
# sehen. Vorher stand hier nur der nackte Status.
|
# sehen. Vorher stand hier nur der nackte Status.
|
||||||
grenze = uploads.limit_mb()
|
grenze = uploads.limit_mb()
|
||||||
error.description = (
|
error.description = (
|
||||||
f"Diese Datei ist groesser als erlaubt. Zurzeit sind {grenze} MB je Upload "
|
f"Diese Datei ist größer als erlaubt. Zurzeit sind {grenze} MB je Upload "
|
||||||
"eingestellt; aendern kann das ein Administrator unter "
|
"eingestellt; ändern kann das ein Administrator unter "
|
||||||
'"Einstellungen -> Webserver und TLS".'
|
'"Einstellungen -> Webserver und TLS".'
|
||||||
)
|
)
|
||||||
if _wants_json():
|
if _wants_json():
|
||||||
@@ -399,7 +435,7 @@ def create_base_app(
|
|||||||
with app.app_context():
|
with app.app_context():
|
||||||
trash_views.register_core_providers()
|
trash_views.register_core_providers()
|
||||||
|
|
||||||
app.add_url_rule("/gesundheit", "health", _health, methods=["GET"])
|
app.add_url_rule("/health", "health", _health, methods=["GET"])
|
||||||
|
|
||||||
return app
|
return app
|
||||||
|
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ import sqlite3
|
|||||||
from collections import defaultdict
|
from collections import defaultdict
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any, Iterable
|
from typing import Any, Collection, Iterable, Mapping, Sequence
|
||||||
|
|
||||||
from .db import Database
|
from .db import Database
|
||||||
|
|
||||||
@@ -116,6 +116,49 @@ def log(
|
|||||||
return int(cursor.lastrowid or 0)
|
return int(cursor.lastrowid or 0)
|
||||||
|
|
||||||
|
|
||||||
|
def diff(
|
||||||
|
vorher: Mapping[str, Any] | None,
|
||||||
|
nachher: Mapping[str, Any] | None,
|
||||||
|
*,
|
||||||
|
felder: Sequence[tuple[str, str]] | None = None,
|
||||||
|
geheim: Collection[str] = (),
|
||||||
|
) -> str:
|
||||||
|
"""Was sich geaendert hat: ``Feld: alt -> neu``, mit Komma getrennt.
|
||||||
|
|
||||||
|
Damit steht im Protokoll nicht nur *dass* etwas geaendert wurde, sondern
|
||||||
|
auch *von was auf was* -- der Weg zurueck steht damit in der Zeile selbst.
|
||||||
|
|
||||||
|
``felder`` gibt Auswahl und Beschriftung vor (``[("ssh_port", "SSH-Port")]``);
|
||||||
|
ohne Angabe werden alle gemeinsamen Felder verglichen. Namen in ``geheim``
|
||||||
|
werden nie im Klartext geschrieben -- dort steht nur, dass sie sich
|
||||||
|
geaendert haben.
|
||||||
|
"""
|
||||||
|
if vorher is None or nachher is None:
|
||||||
|
return ""
|
||||||
|
paare = felder or [(name, name) for name in sorted(set(vorher) & set(nachher))]
|
||||||
|
teile: list[str] = []
|
||||||
|
for name, beschriftung in paare:
|
||||||
|
if name not in vorher or name not in nachher:
|
||||||
|
continue
|
||||||
|
alt, neu = vorher[name], nachher[name]
|
||||||
|
if alt == neu:
|
||||||
|
continue
|
||||||
|
if name in geheim:
|
||||||
|
teile.append(f"{beschriftung}: geaendert")
|
||||||
|
continue
|
||||||
|
teile.append(f"{beschriftung}: {_wert(alt)} -> {_wert(neu)}")
|
||||||
|
return ", ".join(teile)
|
||||||
|
|
||||||
|
|
||||||
|
def _wert(wert: Any) -> str:
|
||||||
|
if wert is None or wert == "":
|
||||||
|
return "(leer)"
|
||||||
|
if isinstance(wert, bool):
|
||||||
|
return "ein" if wert else "aus"
|
||||||
|
text = " ".join(str(wert).split())
|
||||||
|
return text if len(text) <= 80 else text[:79] + "…"
|
||||||
|
|
||||||
|
|
||||||
def verify_chain(conn: sqlite3.Connection, *, limit: int | None = None) -> dict[str, Any]:
|
def verify_chain(conn: sqlite3.Connection, *, limit: int | None = None) -> dict[str, Any]:
|
||||||
"""Prueft die Hash-Kette. Rueckgabe: ``{"ok", "checked", "broken_at", "reason"}``."""
|
"""Prueft die Hash-Kette. Rueckgabe: ``{"ok", "checked", "broken_at", "reason"}``."""
|
||||||
sql = "SELECT * FROM audit_log ORDER BY id"
|
sql = "SELECT * FROM audit_log ORDER BY id"
|
||||||
@@ -133,14 +176,14 @@ def verify_chain(conn: sqlite3.Connection, *, limit: int | None = None) -> dict[
|
|||||||
"ok": False,
|
"ok": False,
|
||||||
"checked": checked,
|
"checked": checked,
|
||||||
"broken_at": data["id"],
|
"broken_at": data["id"],
|
||||||
"reason": "Vorgaenger-Hash passt nicht -- eine Zeile wurde entfernt oder eingefuegt.",
|
"reason": "Vorgänger-Hash passt nicht – eine Zeile wurde entfernt oder eingefügt.",
|
||||||
}
|
}
|
||||||
if compute_hash(str(data["prev_hash"]), data) != data["hash"]:
|
if compute_hash(str(data["prev_hash"]), data) != data["hash"]:
|
||||||
return {
|
return {
|
||||||
"ok": False,
|
"ok": False,
|
||||||
"checked": checked,
|
"checked": checked,
|
||||||
"broken_at": data["id"],
|
"broken_at": data["id"],
|
||||||
"reason": "Zeileninhalt passt nicht zum gespeicherten Hash -- Eintrag geaendert.",
|
"reason": "Zeileninhalt passt nicht zum gespeicherten Hash | Eintrag geändert.",
|
||||||
}
|
}
|
||||||
expected_prev = str(data["hash"])
|
expected_prev = str(data["hash"])
|
||||||
checked += 1
|
checked += 1
|
||||||
|
|||||||
@@ -59,20 +59,20 @@ SETTINGS: tuple[SettingSpec, ...] = (
|
|||||||
section="ldap",
|
section="ldap",
|
||||||
choices=(("ldaps", "LDAPS (Port 636)"), ("starttls", "StartTLS (Port 389)"), ("none", "Unverschluesselt")),
|
choices=(("ldaps", "LDAPS (Port 636)"), ("starttls", "StartTLS (Port 389)"), ("none", "Unverschluesselt")),
|
||||||
),
|
),
|
||||||
SettingSpec("ldap_verify_cert", True, "bool", "Serverzertifikat pruefen", section="ldap"),
|
SettingSpec("ldap_verify_cert", True, "bool", "Serverzertifikat prüfen", section="ldap"),
|
||||||
SettingSpec("ldap_ca_file", "", "str", "CA-Zertifikat (Pfad)", section="ldap"),
|
SettingSpec("ldap_ca_file", "", "str", "CA-Zertifikat (Pfad)", section="ldap"),
|
||||||
SettingSpec(
|
SettingSpec(
|
||||||
"ldap_allow_insecure",
|
"ldap_allow_insecure",
|
||||||
False,
|
False,
|
||||||
"bool",
|
"bool",
|
||||||
"Unverschluesselte Verbindung ausdruecklich erlauben",
|
"Unverschlüsselte Verbindung ausdrücklich erlauben",
|
||||||
hint="Nur fuer Testumgebungen. Das Passwort geht dabei im Klartext ueber das Netz.",
|
hint="Nur für Testumgebungen. Das Passwort geht dabei im Klartext über das Netz.",
|
||||||
section="ldap",
|
section="ldap",
|
||||||
),
|
),
|
||||||
SettingSpec("ldap_base_dn", "", "str", "Basis-DN (leer = automatisch)", section="ldap"),
|
SettingSpec("ldap_base_dn", "", "str", "Basis-DN (leer = automatisch)", section="ldap"),
|
||||||
SettingSpec("ldap_user_attribute", "sAMAccountName", "str", "Anmeldeattribut", section="ldap"),
|
SettingSpec("ldap_user_attribute", "sAMAccountName", "str", "Anmeldeattribut", section="ldap"),
|
||||||
SettingSpec("ldap_email_attribute", "userPrincipalName", "str", "E-Mail-Attribut", section="ldap"),
|
SettingSpec("ldap_email_attribute", "userPrincipalName", "str", "E-Mail-Attribut", section="ldap"),
|
||||||
SettingSpec("ldap_group_sync", True, "bool", "Gruppen aus dem Verzeichnis uebernehmen", section="ldap"),
|
SettingSpec("ldap_group_sync", True, "bool", "Gruppen aus dem Verzeichnis übernehmen", section="ldap"),
|
||||||
SettingSpec("ldap_timeout", 8, "int", "Zeitlimit (Sekunden)", section="ldap", minimum=1, maximum=60),
|
SettingSpec("ldap_timeout", 8, "int", "Zeitlimit (Sekunden)", section="ldap", minimum=1, maximum=60),
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -123,7 +123,7 @@ def bind_credentials(conn: sqlite3.Connection) -> tuple[str, str]:
|
|||||||
try:
|
try:
|
||||||
secret = core().keystore.decrypt(encrypted, aad=AAD_LDAP_BIND)
|
secret = core().keystore.decrypt(encrypted, aad=AAD_LDAP_BIND)
|
||||||
except Exception: # noqa: BLE001 - Schluessel gewechselt/Datei kaputt
|
except Exception: # noqa: BLE001 - Schluessel gewechselt/Datei kaputt
|
||||||
logger.error("Bind-Passwort des Verzeichnisdienstes konnte nicht entschluesselt werden")
|
logger.error("Bind-Passwort des Verzeichnisdienstes konnte nicht entschlüsselt werden")
|
||||||
return str(row.get("username") or ""), ""
|
return str(row.get("username") or ""), ""
|
||||||
return str(row.get("username") or ""), secret
|
return str(row.get("username") or ""), secret
|
||||||
|
|
||||||
@@ -182,7 +182,7 @@ def _connect(config: dict[str, Any], user: str, password: str):
|
|||||||
security = str(config["ldap_security"])
|
security = str(config["ldap_security"])
|
||||||
if security == "none" and not config["ldap_allow_insecure"]:
|
if security == "none" and not config["ldap_allow_insecure"]:
|
||||||
raise PermissionError(
|
raise PermissionError(
|
||||||
"Unverschluesselte Verzeichnisverbindungen sind deaktiviert. "
|
"Unverschlüsselte Verzeichnisverbindungen sind deaktiviert. "
|
||||||
"Nutzen Sie LDAPS oder StartTLS."
|
"Nutzen Sie LDAPS oder StartTLS."
|
||||||
)
|
)
|
||||||
connection = Connection(
|
connection = Connection(
|
||||||
@@ -249,9 +249,9 @@ def authenticate(conn: sqlite3.Connection, username: str, password: str) -> Dire
|
|||||||
bind_user, bind_password = bind_credentials(conn)
|
bind_user, bind_password = bind_credentials(conn)
|
||||||
warnings: list[str] = []
|
warnings: list[str] = []
|
||||||
if str(config["ldap_security"]) == "none":
|
if str(config["ldap_security"]) == "none":
|
||||||
warnings.append("Unverschluesselte Verzeichnisverbindung in Benutzung")
|
warnings.append("Unverschlüsselte Verzeichnisverbindung in Benutzung")
|
||||||
if not config["ldap_verify_cert"]:
|
if not config["ldap_verify_cert"]:
|
||||||
warnings.append("Serverzertifikat des Verzeichnisdienstes wird nicht geprueft")
|
warnings.append("Serverzertifikat des Verzeichnisdienstes wird nicht geprüft")
|
||||||
|
|
||||||
try:
|
try:
|
||||||
service_conn, bound = _connect(config, bind_user, bind_password)
|
service_conn, bound = _connect(config, bind_user, bind_password)
|
||||||
@@ -359,7 +359,7 @@ def _resolve_groups(connection: Any, base: str, user_dn: str) -> tuple[str, ...]
|
|||||||
)
|
)
|
||||||
return tuple(str(entry.entry_dn) for entry in connection.entries)
|
return tuple(str(entry.entry_dn) for entry in connection.entries)
|
||||||
except Exception as exc: # noqa: BLE001
|
except Exception as exc: # noqa: BLE001
|
||||||
logger.warning("Gruppenaufloesung fehlgeschlagen: %s", exc)
|
logger.warning("Gruppenauflösung fehlgeschlagen: %s", exc)
|
||||||
return ()
|
return ()
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -220,6 +220,37 @@ def login_required(view: Callable[..., Any]) -> Callable[..., Any]:
|
|||||||
return wrapper
|
return wrapper
|
||||||
|
|
||||||
|
|
||||||
|
def _verlangt_wiederanmeldung(permission: str) -> bool:
|
||||||
|
"""Fragt das Rechtemodell -- ausserhalb einer Anwendung nie."""
|
||||||
|
try:
|
||||||
|
from ..extension import core
|
||||||
|
|
||||||
|
return bool(core().permissions.verlangt_wiederanmeldung(permission))
|
||||||
|
except Exception: # noqa: BLE001 - ohne Modell keine zusaetzliche Huerde
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _wiederanmeldung_frisch() -> bool:
|
||||||
|
from .service import reauth_is_fresh # zyklischen Import vermeiden
|
||||||
|
|
||||||
|
return reauth_is_fresh()
|
||||||
|
|
||||||
|
|
||||||
|
def _bitte_bestaetigen(permission: str) -> Any:
|
||||||
|
"""Zur Passwortbestaetigung schicken -- oder es einem Skript sagen.
|
||||||
|
|
||||||
|
Ein Aufruf per fetch bekaeme sonst die Anmeldeseite als Antwort und wuesste
|
||||||
|
nichts damit anzufangen.
|
||||||
|
"""
|
||||||
|
if is_automatic(request) or (
|
||||||
|
request.accept_mimetypes.accept_json and not request.accept_mimetypes.accept_html
|
||||||
|
):
|
||||||
|
abort(403, description="Bitte bestätigen Sie zur Sicherheit Ihr Passwort.")
|
||||||
|
session["_after_reauth"] = request.full_path if request.query_string else request.path
|
||||||
|
flash("Bitte bestätigen Sie zur Sicherheit Ihr Passwort.", "info")
|
||||||
|
return redirect(url_for("auth.reauth"))
|
||||||
|
|
||||||
|
|
||||||
def permission_required(*permissions: str, mode: str = "all") -> Callable[..., Any]:
|
def permission_required(*permissions: str, mode: str = "all") -> Callable[..., Any]:
|
||||||
"""Erzwingt Rechte serverseitig.
|
"""Erzwingt Rechte serverseitig.
|
||||||
|
|
||||||
@@ -237,7 +268,15 @@ def permission_required(*permissions: str, mode: str = "all") -> Callable[..., A
|
|||||||
else current_user.has(*permissions)
|
else current_user.has(*permissions)
|
||||||
)
|
)
|
||||||
if not ok:
|
if not ok:
|
||||||
return _deny("Fuer diese Seite fehlt Ihnen die Berechtigung.")
|
return _deny("Für diese Seite fehlt Ihnen die Berechtigung.")
|
||||||
|
# Geheimnisse und Exporte verlangen eine frische Bestaetigung --
|
||||||
|
# das steht im Rechtemodell und wird deshalb hier abgefragt, nicht
|
||||||
|
# in jeder Route einzeln. Vorher hing es daran, dass jemand an den
|
||||||
|
# Dekorator dachte: dieselbe Lizenz herunterzuladen fragte nach,
|
||||||
|
# sie per Mail zu verschicken nicht.
|
||||||
|
heikel = [p for p in permissions if _verlangt_wiederanmeldung(p)]
|
||||||
|
if heikel and not _wiederanmeldung_frisch():
|
||||||
|
return _bitte_bestaetigen(heikel[0])
|
||||||
return view(*args, **kwargs)
|
return view(*args, **kwargs)
|
||||||
|
|
||||||
wrapper._required_permissions = permissions # type: ignore[attr-defined]
|
wrapper._required_permissions = permissions # type: ignore[attr-defined]
|
||||||
@@ -279,7 +318,7 @@ def reauth_required(view: Callable[..., Any]) -> Callable[..., Any]:
|
|||||||
return _deny("Anmeldung erforderlich.")
|
return _deny("Anmeldung erforderlich.")
|
||||||
if not reauth_is_fresh():
|
if not reauth_is_fresh():
|
||||||
session["_after_reauth"] = request.full_path if request.query_string else request.path
|
session["_after_reauth"] = request.full_path if request.query_string else request.path
|
||||||
flash("Bitte bestaetigen Sie zur Sicherheit Ihr Passwort.", "info")
|
flash("Bitte bestätigen Sie zur Sicherheit Ihr Passwort.", "info")
|
||||||
return redirect(url_for("auth.reauth"))
|
return redirect(url_for("auth.reauth"))
|
||||||
return view(*args, **kwargs)
|
return view(*args, **kwargs)
|
||||||
|
|
||||||
|
|||||||
@@ -66,7 +66,7 @@ def login():
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@bp.route("/login/zweiter-faktor", methods=["GET", "POST"])
|
@bp.route("/login/second-factor", methods=["GET", "POST"])
|
||||||
def second_factor():
|
def second_factor():
|
||||||
conn = db()
|
conn = db()
|
||||||
user_row = service.pending_totp_user(conn)
|
user_row = service.pending_totp_user(conn)
|
||||||
@@ -116,7 +116,7 @@ def logout_get():
|
|||||||
return redirect(url_for("auth.login"))
|
return redirect(url_for("auth.login"))
|
||||||
|
|
||||||
|
|
||||||
@bp.route("/bestaetigen", methods=["GET", "POST"])
|
@bp.route("/confirm", methods=["GET", "POST"])
|
||||||
@login_required
|
@login_required
|
||||||
def reauth():
|
def reauth():
|
||||||
target = _safe_next(session.get("_after_reauth"))
|
target = _safe_next(session.get("_after_reauth"))
|
||||||
|
|||||||
@@ -151,7 +151,7 @@ def authenticate(
|
|||||||
if ip_verdict.blocked:
|
if ip_verdict.blocked:
|
||||||
return AuthOutcome(
|
return AuthOutcome(
|
||||||
"rate_limited",
|
"rate_limited",
|
||||||
"Zu viele Anmeldeversuche von dieser Adresse. Bitte spaeter erneut versuchen.",
|
"Zu viele Anmeldeversuche von dieser Adresse. Bitte später erneut versuchen.",
|
||||||
retry_after=ip_verdict.retry_after_seconds,
|
retry_after=ip_verdict.retry_after_seconds,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -165,7 +165,7 @@ def authenticate(
|
|||||||
if user_verdict.blocked:
|
if user_verdict.blocked:
|
||||||
return AuthOutcome(
|
return AuthOutcome(
|
||||||
"rate_limited",
|
"rate_limited",
|
||||||
"Dieses Konto ist nach mehreren Fehlversuchen voruebergehend gesperrt.",
|
"Dieses Konto ist nach mehreren Fehlversuchen vorübergehend gesperrt.",
|
||||||
retry_after=user_verdict.retry_after_seconds,
|
retry_after=user_verdict.retry_after_seconds,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -248,7 +248,7 @@ def _verify_credentials(
|
|||||||
return AuthOutcome(
|
return AuthOutcome(
|
||||||
"directory_unavailable",
|
"directory_unavailable",
|
||||||
"Der Verzeichnisdienst ist derzeit nicht erreichbar. "
|
"Der Verzeichnisdienst ist derzeit nicht erreichbar. "
|
||||||
"Bitte versuchen Sie es spaeter erneut.",
|
"Bitte versuchen Sie es später erneut.",
|
||||||
)
|
)
|
||||||
if result.status != "ok" or result.profile is None:
|
if result.status != "ok" or result.profile is None:
|
||||||
return AuthOutcome("invalid", GENERIC_FAILURE)
|
return AuthOutcome("invalid", GENERIC_FAILURE)
|
||||||
@@ -257,6 +257,24 @@ def _verify_credentials(
|
|||||||
return AuthOutcome("ok", user_row=user_row, group_dns=result.profile.group_dns)
|
return AuthOutcome("ok", user_row=user_row, group_dns=result.profile.group_dns)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
def _ist_erstanmeldung(user_row: dict[str, Any]) -> bool:
|
||||||
|
"""Meldet sich dieses Konto zum ersten Mal ueberhaupt an?
|
||||||
|
|
||||||
|
Das Aenderungsprotokoll soll Aenderungen zeigen. Eine taegliche Anmeldung
|
||||||
|
ist keine -- sie erzeugte aber jedes Mal eine Zeile und schob damit echte
|
||||||
|
Eintraege aus dem Blickfeld. Interessant ist genau die erste: mit ihr
|
||||||
|
entsteht das Konto aus dem Verzeichnisdienst und betritt jemand die
|
||||||
|
Anwendung, der vorher nicht darin war.
|
||||||
|
|
||||||
|
Nicht betroffen: fehlgeschlagene Anmeldungen und Sperren. Die stehen
|
||||||
|
weiterhin **jedes Mal** im Protokoll -- dort liegt der sicherheitsrelevante
|
||||||
|
Teil, und Haeufung ist dort das Signal. Wer wann zuletzt angemeldet war,
|
||||||
|
steht ausserdem unveraendert am Konto und in der Sitzungsuebersicht.
|
||||||
|
"""
|
||||||
|
return not str(user_row.get("last_login_at") or "").strip()
|
||||||
|
|
||||||
|
|
||||||
def complete_login(
|
def complete_login(
|
||||||
conn: sqlite3.Connection,
|
conn: sqlite3.Connection,
|
||||||
user_row: dict[str, Any],
|
user_row: dict[str, Any],
|
||||||
@@ -275,21 +293,26 @@ def complete_login(
|
|||||||
"""
|
"""
|
||||||
from ..security import csrf
|
from ..security import csrf
|
||||||
|
|
||||||
|
# Vor dem UPDATE lesen -- danach steht ueberall ein Zeitstempel.
|
||||||
|
erstanmeldung = _ist_erstanmeldung(user_row)
|
||||||
|
|
||||||
with core().database.transaction(conn):
|
with core().database.transaction(conn):
|
||||||
session_store.rotate(conn, session, reason="login") # type: ignore[arg-type]
|
session_store.rotate(conn, session, reason="login") # type: ignore[arg-type]
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"UPDATE users SET last_login_at=? WHERE id=?", (_iso(_now()), user_row["id"])
|
"UPDATE users SET last_login_at=? WHERE id=?", (_iso(_now()), user_row["id"])
|
||||||
)
|
)
|
||||||
audit.log(
|
if erstanmeldung:
|
||||||
conn,
|
audit.log(
|
||||||
actor=str(user_row["username"]),
|
conn,
|
||||||
actor_id=int(user_row["id"]),
|
actor=str(user_row["username"]),
|
||||||
action="auth.login",
|
actor_id=int(user_row["id"]),
|
||||||
target=str(user_row["username"]),
|
action="auth.first_login",
|
||||||
detail=f"Quelle: {user_row.get('auth_source') or 'local'}",
|
target=str(user_row["username"]),
|
||||||
ip=ip,
|
detail=f"Erste Anmeldung | Quelle: {user_row.get('auth_source') or 'local'}",
|
||||||
request_id=getattr(g, "request_id", ""),
|
ip=ip,
|
||||||
)
|
request_id=getattr(g, "request_id", ""),
|
||||||
|
severity="notice",
|
||||||
|
)
|
||||||
|
|
||||||
session.clear()
|
session.clear()
|
||||||
session[SESSION_USER_KEY] = int(user_row["id"])
|
session[SESSION_USER_KEY] = int(user_row["id"])
|
||||||
@@ -336,15 +359,11 @@ def logout(conn: sqlite3.Connection) -> None:
|
|||||||
current_app.logger.exception(
|
current_app.logger.exception(
|
||||||
"Abmeldehaken %r fehlgeschlagen", getattr(hook, "__name__", hook)
|
"Abmeldehaken %r fehlgeschlagen", getattr(hook, "__name__", hook)
|
||||||
)
|
)
|
||||||
with core().database.transaction(conn):
|
# Kein Protokolleintrag fuer das Abmelden: eine Abmeldung aendert
|
||||||
audit.log(
|
# nichts. Sie stand als "Info" in jeder zweiten Zeile und verdraengte
|
||||||
conn,
|
# die Eintraege, um die es geht. Wer wissen will, wer wann da war,
|
||||||
actor=user.username,
|
# liest die Sitzungsuebersicht; fehlgeschlagene Anmeldungen und
|
||||||
actor_id=user.id,
|
# Sperren stehen weiterhin jedes Mal im Protokoll.
|
||||||
action="auth.logout",
|
|
||||||
target=user.username,
|
|
||||||
ip=client_ip(),
|
|
||||||
)
|
|
||||||
session.destroy() if hasattr(session, "destroy") else session.clear() # type: ignore[attr-defined]
|
session.destroy() if hasattr(session, "destroy") else session.clear() # type: ignore[attr-defined]
|
||||||
g.user = ANONYMOUS
|
g.user = ANONYMOUS
|
||||||
|
|
||||||
|
|||||||
@@ -83,7 +83,7 @@ def run_web_setup(app: Any, extension: Any, args: argparse.Namespace) -> int:
|
|||||||
port = getattr(args, flag, None)
|
port = getattr(args, flag, None)
|
||||||
if port:
|
if port:
|
||||||
if not 1 <= int(port) <= 65535:
|
if not 1 <= int(port) <= 65535:
|
||||||
print(f"Ungueltiger Port: {port}", file=sys.stderr)
|
print(f"Ungültiger Port: {port}", file=sys.stderr)
|
||||||
return 2
|
return 2
|
||||||
values[key] = int(port)
|
values[key] = int(port)
|
||||||
|
|
||||||
@@ -93,11 +93,11 @@ def run_web_setup(app: Any, extension: Any, args: argparse.Namespace) -> int:
|
|||||||
|
|
||||||
# -- Zertifikat --------------------------------------------------- #
|
# -- Zertifikat --------------------------------------------------- #
|
||||||
config = _config_from_settings(extension, conn)
|
config = _config_from_settings(extension, conn)
|
||||||
cert: dict[str, Any] = {"ok": True, "message": "Zertifikat unveraendert."}
|
cert: dict[str, Any] = {"ok": True, "message": "Zertifikat unverändert."}
|
||||||
|
|
||||||
if args.acme:
|
if args.acme:
|
||||||
if sysops is None or not sysops.available:
|
if sysops is None or not sysops.available:
|
||||||
print("Der Systemhelfer ist nicht verfuegbar.", file=sys.stderr)
|
print("Der Systemhelfer ist nicht verfügbar.", file=sys.stderr)
|
||||||
return 2
|
return 2
|
||||||
cert = webserver.request_certificate(
|
cert = webserver.request_certificate(
|
||||||
sysops,
|
sysops,
|
||||||
@@ -118,7 +118,7 @@ def run_web_setup(app: Any, extension: Any, args: argparse.Namespace) -> int:
|
|||||||
|
|
||||||
if args.self_signed and not args.acme:
|
if args.self_signed and not args.acme:
|
||||||
if sysops is None or not sysops.available:
|
if sysops is None or not sysops.available:
|
||||||
print("Der Systemhelfer ist nicht verfuegbar.", file=sys.stderr)
|
print("Der Systemhelfer ist nicht verfügbar.", file=sys.stderr)
|
||||||
return 2
|
return 2
|
||||||
suggested = webserver.local_names()
|
suggested = webserver.local_names()
|
||||||
cert = webserver.create_self_signed(
|
cert = webserver.create_self_signed(
|
||||||
@@ -144,7 +144,7 @@ def run_web_setup(app: Any, extension: Any, args: argparse.Namespace) -> int:
|
|||||||
if args.https:
|
if args.https:
|
||||||
if not (config.cert_path and config.key_path):
|
if not (config.cert_path and config.key_path):
|
||||||
print(
|
print(
|
||||||
"HTTPS verlangt Zertifikat und Schluessel -- bitte --self-signed "
|
"HTTPS verlangt Zertifikat und Schlüssel – bitte --self-signed "
|
||||||
"oder --acme angeben.",
|
"oder --acme angeben.",
|
||||||
file=sys.stderr,
|
file=sys.stderr,
|
||||||
)
|
)
|
||||||
@@ -172,7 +172,7 @@ def run_web_setup(app: Any, extension: Any, args: argparse.Namespace) -> int:
|
|||||||
return 0
|
return 0
|
||||||
|
|
||||||
if sysops is None or not sysops.available:
|
if sysops is None or not sysops.available:
|
||||||
print("Der Systemhelfer ist nicht verfuegbar.", file=sys.stderr)
|
print("Der Systemhelfer ist nicht verfügbar.", file=sys.stderr)
|
||||||
return 2
|
return 2
|
||||||
result = webserver.apply_config(sysops, rendered)
|
result = webserver.apply_config(sysops, rendered)
|
||||||
print(result["message"])
|
print(result["message"])
|
||||||
@@ -194,7 +194,7 @@ def add_web_setup(sub: Any, runner: Any) -> None:
|
|||||||
help="nginx und TLS einrichten (Port 80/443)",
|
help="nginx und TLS einrichten (Port 80/443)",
|
||||||
description=(
|
description=(
|
||||||
"Setzt die Webserver-Einstellungen und erzeugt daraus die nginx-Site. "
|
"Setzt die Webserver-Einstellungen und erzeugt daraus die nginx-Site. "
|
||||||
"Die Datei wird immer vollstaendig neu geschrieben -- eine von Hand "
|
"Die Datei wird immer vollständig neu geschrieben – eine von Hand "
|
||||||
"bearbeitete Site-Datei gibt es in diesem Projekt bewusst nicht."
|
"bearbeitete Site-Datei gibt es in diesem Projekt bewusst nicht."
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
@@ -207,20 +207,20 @@ def add_web_setup(sub: Any, runner: Any) -> None:
|
|||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
"--acme",
|
"--acme",
|
||||||
action="store_true",
|
action="store_true",
|
||||||
help="Zertifikat per Let's Encrypt anfordern (Domain muss oeffentlich zeigen)",
|
help="Zertifikat per Let's Encrypt anfordern (Domain muss öffentlich zeigen)",
|
||||||
)
|
)
|
||||||
parser.add_argument("--domain", help="Oeffentlicher Name dieser Installation")
|
parser.add_argument("--domain", help="Öffentlicher Name dieser Installation")
|
||||||
parser.add_argument("--acme-email", help="Kontaktadresse fuer Let's Encrypt")
|
parser.add_argument("--acme-email", help="Kontaktadresse für Let's Encrypt")
|
||||||
parser.add_argument("--hsts", action="store_true", help="HSTS-Header setzen")
|
parser.add_argument("--hsts", action="store_true", help="HSTS-Header setzen")
|
||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
"--no-redirect",
|
"--no-redirect",
|
||||||
action="store_true",
|
action="store_true",
|
||||||
help="Port 80 nicht auf HTTPS umleiten (Sonderfall)",
|
help="Port 80 nicht auf HTTPS umleiten (Sonderfall)",
|
||||||
)
|
)
|
||||||
parser.add_argument("--max-body-mb", type=int, help="Obergrenze fuer Uploads")
|
parser.add_argument("--max-body-mb", type=int, help="Obergrenze für Uploads")
|
||||||
parser.add_argument("--http-port", type=int, help="Abweichender HTTP-Port (Standard 80)")
|
parser.add_argument("--http-port", type=int, help="Abweichender HTTP-Port (Standard 80)")
|
||||||
parser.add_argument("--https-port", type=int, help="Abweichender HTTPS-Port (Standard 443)")
|
parser.add_argument("--https-port", type=int, help="Abweichender HTTPS-Port (Standard 443)")
|
||||||
parser.add_argument("--apply", action="store_true", help="nginx schreiben, pruefen, neu laden")
|
parser.add_argument("--apply", action="store_true", help="nginx schreiben, prüfen, neu laden")
|
||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
"--print-config", action="store_true", help="Nur die erzeugte Datei ausgeben"
|
"--print-config", action="store_true", help="Nur die erzeugte Datei ausgeben"
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -175,11 +175,11 @@ def load_core_config(
|
|||||||
|
|
||||||
bind = (_env(env_prefix, "BIND") or default_bind).strip()
|
bind = (_env(env_prefix, "BIND") or default_bind).strip()
|
||||||
if not re.fullmatch(r"[0-9A-Za-z_.\[\]:-]{1,80}:\d{1,5}", bind):
|
if not re.fullmatch(r"[0-9A-Za-z_.\[\]:-]{1,80}:\d{1,5}", bind):
|
||||||
raise ValueError(f"Ungueltige Bind-Adresse: {bind!r}")
|
raise ValueError(f"Ungültige Bind-Adresse: {bind!r}")
|
||||||
|
|
||||||
site_key = (_env(env_prefix, "SITE_KEY") or app_key).strip().lower()
|
site_key = (_env(env_prefix, "SITE_KEY") or app_key).strip().lower()
|
||||||
if not re.fullmatch(r"[a-z0-9][a-z0-9-]{0,39}", site_key):
|
if not re.fullmatch(r"[a-z0-9][a-z0-9-]{0,39}", site_key):
|
||||||
raise ValueError(f"Ungueltiger SITE_KEY: {site_key!r}")
|
raise ValueError(f"Ungültiger SITE_KEY: {site_key!r}")
|
||||||
|
|
||||||
return CoreConfig(
|
return CoreConfig(
|
||||||
app_key=app_key,
|
app_key=app_key,
|
||||||
|
|||||||
@@ -80,8 +80,8 @@ class Database:
|
|||||||
"""Oeffnet eine neue Verbindung. Nicht aufrufen, solange eine Transaktion laeuft."""
|
"""Oeffnet eine neue Verbindung. Nicht aufrufen, solange eine Transaktion laeuft."""
|
||||||
if self._depth() > 0:
|
if self._depth() > 0:
|
||||||
raise NestedConnectionError(
|
raise NestedConnectionError(
|
||||||
"Es laeuft bereits eine Schreibtransaktion auf dieser Datenbank. "
|
"Es läuft bereits eine Schreibtransaktion auf dieser Datenbank. "
|
||||||
"Reiche die bestehende Verbindung durch, statt eine neue zu oeffnen "
|
"Reiche die bestehende Verbindung durch, statt eine neue zu öffnen "
|
||||||
"(sonst blockiert SQLite sich selbst)."
|
"(sonst blockiert SQLite sich selbst)."
|
||||||
)
|
)
|
||||||
return self._raw_connect()
|
return self._raw_connect()
|
||||||
@@ -119,6 +119,29 @@ class Database:
|
|||||||
finally:
|
finally:
|
||||||
conn.close()
|
conn.close()
|
||||||
|
|
||||||
|
@contextmanager
|
||||||
|
def dauerhaft(self, conn: sqlite3.Connection) -> Iterator[sqlite3.Connection]:
|
||||||
|
"""Fuer Schreibvorgaenge, die einen Stromausfall ueberstehen muessen.
|
||||||
|
|
||||||
|
Im Alltag laeuft die Datenbank mit ``synchronous=NORMAL``: schnell, und
|
||||||
|
bei einem Stromausfall gehen hoechstens die letzten Sekunden verloren --
|
||||||
|
die Datei bleibt heil. Fuer einen Messwert ist das genau richtig.
|
||||||
|
|
||||||
|
Fuer eine Unterschrift nicht. Dort liegt die Datei anschliessend auf der
|
||||||
|
Freigabe, und fehlte der Eintrag dazu, waere sie "unbekannt" statt
|
||||||
|
nachweisbar. Ein fsync kostet ein paar Millisekunden -- bei ein paar
|
||||||
|
Unterschriften am Tag ist das keine Diskussion wert.
|
||||||
|
|
||||||
|
Umschliesst die Transaktion, nicht umgekehrt: ``PRAGMA synchronous``
|
||||||
|
wirkt auf den naechsten Commit, und gesetzt werden kann es nur
|
||||||
|
ausserhalb einer laufenden Transaktion.
|
||||||
|
"""
|
||||||
|
conn.execute("PRAGMA synchronous=FULL")
|
||||||
|
try:
|
||||||
|
yield conn
|
||||||
|
finally:
|
||||||
|
conn.execute("PRAGMA synchronous=NORMAL")
|
||||||
|
|
||||||
# ------------------------------------------------------------------ #
|
# ------------------------------------------------------------------ #
|
||||||
# Transaktionen
|
# Transaktionen
|
||||||
# ------------------------------------------------------------------ #
|
# ------------------------------------------------------------------ #
|
||||||
|
|||||||
@@ -79,7 +79,7 @@ def core(app: Flask | None = None) -> TesmCore:
|
|||||||
try:
|
try:
|
||||||
return target.extensions["tesm"] # type: ignore[return-value]
|
return target.extensions["tesm"] # type: ignore[return-value]
|
||||||
except KeyError as exc: # pragma: no cover - Programmierfehler
|
except KeyError as exc: # pragma: no cover - Programmierfehler
|
||||||
raise RuntimeError("tesm_core ist fuer diese Anwendung nicht initialisiert") from exc
|
raise RuntimeError("tesm_core ist für diese Anwendung nicht initialisiert") from exc
|
||||||
|
|
||||||
|
|
||||||
def db() -> sqlite3.Connection:
|
def db() -> sqlite3.Connection:
|
||||||
|
|||||||
@@ -78,7 +78,7 @@ def check_private_file(path: Path) -> list[str]:
|
|||||||
except OSError:
|
except OSError:
|
||||||
return problems
|
return problems
|
||||||
if mode & (stat.S_IRGRP | stat.S_IROTH | stat.S_IWGRP | stat.S_IWOTH):
|
if mode & (stat.S_IRGRP | stat.S_IROTH | stat.S_IWGRP | stat.S_IWOTH):
|
||||||
problems.append(f"{path} ist fuer Gruppe/Andere lesbar oder schreibbar (erwartet 0600)")
|
problems.append(f"{path} ist für Gruppe/Andere lesbar oder schreibbar (erwartet 0600)")
|
||||||
return problems
|
return problems
|
||||||
|
|
||||||
|
|
||||||
@@ -110,11 +110,11 @@ class Keystore:
|
|||||||
created_at=str(entry.get("created_at", "")),
|
created_at=str(entry.get("created_at", "")),
|
||||||
)
|
)
|
||||||
if len(key.material) != _KEY_BYTES:
|
if len(key.material) != _KEY_BYTES:
|
||||||
raise ValueError(f"Schluessel {key.key_id} hat die falsche Laenge")
|
raise ValueError(f"Schlüssel {key.key_id} hat die falsche Länge")
|
||||||
self._keys[key.key_id] = key
|
self._keys[key.key_id] = key
|
||||||
self._active = str(raw.get("active") or "")
|
self._active = str(raw.get("active") or "")
|
||||||
if self._active not in self._keys:
|
if self._active not in self._keys:
|
||||||
raise ValueError("Aktiver Schluessel fehlt im Schluesselbund")
|
raise ValueError("Aktiver Schlüssel fehlt im Schlüsselbund")
|
||||||
return
|
return
|
||||||
self._active = "1"
|
self._active = "1"
|
||||||
self._keys = {
|
self._keys = {
|
||||||
@@ -160,7 +160,7 @@ class Keystore:
|
|||||||
def drop_key(self, key_id: str) -> None:
|
def drop_key(self, key_id: str) -> None:
|
||||||
"""Entfernt einen alten Schluessel -- erst nach vollstaendiger Neuverschluesselung."""
|
"""Entfernt einen alten Schluessel -- erst nach vollstaendiger Neuverschluesselung."""
|
||||||
if key_id == self._active:
|
if key_id == self._active:
|
||||||
raise ValueError("Der aktive Schluessel kann nicht entfernt werden")
|
raise ValueError("Der aktive Schlüssel kann nicht entfernt werden")
|
||||||
self._keys.pop(key_id, None)
|
self._keys.pop(key_id, None)
|
||||||
self._persist()
|
self._persist()
|
||||||
|
|
||||||
@@ -183,14 +183,14 @@ class Keystore:
|
|||||||
raise DecryptionError(f"Unbekannte Chiffrat-Version: {prefix!r}")
|
raise DecryptionError(f"Unbekannte Chiffrat-Version: {prefix!r}")
|
||||||
key = self._keys.get(key_id)
|
key = self._keys.get(key_id)
|
||||||
if key is None:
|
if key is None:
|
||||||
raise DecryptionError(f"Schluessel {key_id} ist nicht im Schluesselbund")
|
raise DecryptionError(f"Schlüssel {key_id} ist nicht im Schlüsselbund")
|
||||||
try:
|
try:
|
||||||
plain = AESGCM(key.material).decrypt(
|
plain = AESGCM(key.material).decrypt(
|
||||||
_b64d(nonce_b64), _b64d(ct_b64), aad.encode("utf-8")
|
_b64d(nonce_b64), _b64d(ct_b64), aad.encode("utf-8")
|
||||||
)
|
)
|
||||||
except (InvalidTag, ValueError) as exc:
|
except (InvalidTag, ValueError) as exc:
|
||||||
raise DecryptionError(
|
raise DecryptionError(
|
||||||
"Chiffrat konnte nicht entschluesselt werden (falscher Schluessel, "
|
"Chiffrat konnte nicht entschlüsselt werden (falscher Schlüssel, "
|
||||||
"manipulierte Daten oder falscher Verwendungszweck)"
|
"manipulierte Daten oder falscher Verwendungszweck)"
|
||||||
) from exc
|
) from exc
|
||||||
return plain.decode("utf-8")
|
return plain.decode("utf-8")
|
||||||
|
|||||||
@@ -0,0 +1,242 @@
|
|||||||
|
"""Alte, deutsche Adressen auf die neuen englischen umlenken.
|
||||||
|
|
||||||
|
Mit 2.5.0 folgen die Adressen der Navigation und sind englisch:
|
||||||
|
``/monitoring/host-keys`` statt ``/hostschluessel``. Das ist eine gute
|
||||||
|
Aenderung und eine unangenehme zugleich -- Lesezeichen, offene Browsertabs und
|
||||||
|
ein Link in einer Mail zeigen weiter auf die alte Adresse. Ein offener Tab ist
|
||||||
|
dabei der unangenehmste Fall: die Statusuebersicht fragt alle 20 Sekunden ihre
|
||||||
|
Kacheln ab und bekam nach dem Update jedes Mal einen 404.
|
||||||
|
|
||||||
|
Deshalb diese Tabelle. Sie greift **nur, wenn sonst ein 404 herauskaeme** --
|
||||||
|
also nie vor einer bestehenden Route. Das ist keine Feinheit: auf dem
|
||||||
|
Lizenzserver ist ``/lizenz/<ticket>`` eine echte, weiterhin gueltige Adresse
|
||||||
|
(sie steht in verschickten Mails), waehrend dieselbe Wurzel in TESM zur
|
||||||
|
Lizenzseite fuehrte. Wer zuerst das Routing fragt, muss sich nicht zwischen
|
||||||
|
beidem entscheiden.
|
||||||
|
|
||||||
|
Die Tabelle ist bewusst vollstaendig und nicht nur auf Wurzeln gekuerzt: eine
|
||||||
|
Umleitung von ``/dateifreigaben/x/vorschau`` auf eine Uebersichtsseite waere
|
||||||
|
zwar kein 404 mehr, aber auch nicht das, was jemand angeklickt hat.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Mapping
|
||||||
|
|
||||||
|
#: Alte Wurzel -> (neue Wurzel, Uebersetzung der Segmente darunter).
|
||||||
|
WURZELN: dict[str, tuple[str, Mapping[str, str]]] = {
|
||||||
|
"/dateifreigaben": (
|
||||||
|
"/fileshares",
|
||||||
|
{
|
||||||
|
"herunterladen": "download",
|
||||||
|
"vorschau": "preview",
|
||||||
|
"hochladen": "upload",
|
||||||
|
"formular": "form",
|
||||||
|
"ordner": "folder",
|
||||||
|
"umbenennen": "rename",
|
||||||
|
"loeschen": "delete",
|
||||||
|
"freigeben": "release",
|
||||||
|
"zuordnungen": "mappings",
|
||||||
|
},
|
||||||
|
),
|
||||||
|
"/hostschluessel": (
|
||||||
|
"/monitoring/host-keys",
|
||||||
|
{"pruefen": "probe", "freigeben": "trust", "verwerfen": "forget", "verwaist": "orphaned"},
|
||||||
|
),
|
||||||
|
"/zugangsdaten": (
|
||||||
|
"/monitoring/credentials",
|
||||||
|
{"neu": "new", "loeschen": "delete", "anzeigen": "reveal"},
|
||||||
|
),
|
||||||
|
"/switche": (
|
||||||
|
"/monitoring/switches",
|
||||||
|
{
|
||||||
|
"neu": "new",
|
||||||
|
"loeschen": "delete",
|
||||||
|
"hostkey": "host-key",
|
||||||
|
"pruefen": "probe",
|
||||||
|
"freigeben": "trust",
|
||||||
|
"verwerfen": "forget",
|
||||||
|
"kommando": "command",
|
||||||
|
"konsole": "console",
|
||||||
|
},
|
||||||
|
),
|
||||||
|
"/clients": (
|
||||||
|
"/monitoring/clients",
|
||||||
|
{
|
||||||
|
"neu": "new",
|
||||||
|
"aktiv": "active",
|
||||||
|
"loeschen": "delete",
|
||||||
|
"neustart": "restart",
|
||||||
|
"hostkey": "host-key",
|
||||||
|
"pruefen": "probe",
|
||||||
|
"freigeben": "trust",
|
||||||
|
"verwerfen": "forget",
|
||||||
|
"konsole": "console",
|
||||||
|
},
|
||||||
|
),
|
||||||
|
"/wartung": (
|
||||||
|
"/maintenance",
|
||||||
|
{
|
||||||
|
"paket-nachinstallieren": "install-package",
|
||||||
|
"auftrag": "job",
|
||||||
|
"ausgabe": "output",
|
||||||
|
"aktualisieren": "update",
|
||||||
|
"neustarten": "restart",
|
||||||
|
},
|
||||||
|
),
|
||||||
|
"/protokolle": (
|
||||||
|
"/logs",
|
||||||
|
{
|
||||||
|
"kea": "dhcp",
|
||||||
|
"neustarts": "restarts",
|
||||||
|
"laufend": "live",
|
||||||
|
"inhalt": "content",
|
||||||
|
"verlauf": "history",
|
||||||
|
"datei": "file",
|
||||||
|
"archiv": "archive",
|
||||||
|
"archiv-export": "archive-export",
|
||||||
|
"archiv-loeschen": "archive-delete",
|
||||||
|
"aenderungen": "audit",
|
||||||
|
},
|
||||||
|
),
|
||||||
|
"/verwaltung": (
|
||||||
|
"/administration",
|
||||||
|
{
|
||||||
|
"benutzer": "users",
|
||||||
|
"neu": "new",
|
||||||
|
"loeschen": "delete",
|
||||||
|
"sitzungen-beenden": "end-sessions",
|
||||||
|
"verzeichnis-suche": "directory-search",
|
||||||
|
"verzeichnis-uebernehmen": "directory-import",
|
||||||
|
"gruppen": "groups",
|
||||||
|
"vorlage-anwenden": "apply-preset",
|
||||||
|
"sitzungen": "sessions",
|
||||||
|
"beenden": "end",
|
||||||
|
},
|
||||||
|
),
|
||||||
|
"/einstellungen": (
|
||||||
|
"/administration",
|
||||||
|
{
|
||||||
|
"zeitzone": "timezone",
|
||||||
|
"pruefung-jetzt": "check-now",
|
||||||
|
"netzwerk": "network",
|
||||||
|
"bestaetigen": "confirm",
|
||||||
|
"webserver": "web",
|
||||||
|
"uebernehmen": "apply",
|
||||||
|
"zertifikat": "certificate",
|
||||||
|
"selbstsigniert": "self-signed",
|
||||||
|
"verzeichnisdienst": "directory",
|
||||||
|
"gruppensuche": "group-search",
|
||||||
|
"zuordnung": "mapping",
|
||||||
|
"loeschen": "delete",
|
||||||
|
"anbieter": "vendor",
|
||||||
|
"logo-entfernen": "remove-logo",
|
||||||
|
},
|
||||||
|
),
|
||||||
|
"/sicherung": (
|
||||||
|
"/administration/backup",
|
||||||
|
{"vorschau": "preview", "uebernehmen": "apply", "verwerfen": "discard"},
|
||||||
|
),
|
||||||
|
"/papierkorb": (
|
||||||
|
"/administration/trash",
|
||||||
|
{"wiederherstellen": "restore", "endgueltig-loeschen": "purge", "leeren": "empty"},
|
||||||
|
),
|
||||||
|
"/diagnose": (
|
||||||
|
"/administration/diagnostics",
|
||||||
|
{"aufraeumen": "cleanup", "schluessel-rotieren": "rotate-keys"},
|
||||||
|
),
|
||||||
|
"/eigene-lizenz": (
|
||||||
|
"/own-license",
|
||||||
|
{
|
||||||
|
"einspielen": "install",
|
||||||
|
"aktivieren": "activate",
|
||||||
|
"deaktivieren": "deactivate",
|
||||||
|
"offline-antwort": "offline-response",
|
||||||
|
"signaturschluessel": "signing-key",
|
||||||
|
},
|
||||||
|
),
|
||||||
|
"/lizenzen": (
|
||||||
|
"/licenses",
|
||||||
|
{
|
||||||
|
"aus-ticket": "from-ticket",
|
||||||
|
"datei": "file",
|
||||||
|
"neu-signieren": "resign",
|
||||||
|
"widerrufen": "revoke",
|
||||||
|
"loeschen": "delete",
|
||||||
|
"versenden": "send",
|
||||||
|
"sub-lizenzserver": "sub-servers",
|
||||||
|
},
|
||||||
|
),
|
||||||
|
"/lizenz": (
|
||||||
|
"/administration/license",
|
||||||
|
{
|
||||||
|
"einspielen": "install",
|
||||||
|
"aktivieren": "activate",
|
||||||
|
"deaktivieren": "deactivate",
|
||||||
|
"offline-antwort": "offline-response",
|
||||||
|
"serverzertifikat": "server-certificate",
|
||||||
|
"aussteller": "issuer",
|
||||||
|
"entfernen": "remove",
|
||||||
|
},
|
||||||
|
),
|
||||||
|
"/kunden": ("/customers", {"neu": "new", "loeschen": "delete"}),
|
||||||
|
"/konto": (
|
||||||
|
"/account",
|
||||||
|
{
|
||||||
|
"stammdaten": "profile",
|
||||||
|
"profilbild": "avatar",
|
||||||
|
"sicherheit": "security",
|
||||||
|
"passwort": "password",
|
||||||
|
"zwei-faktor": "two-factor",
|
||||||
|
"starten": "start",
|
||||||
|
"bestaetigen": "confirm",
|
||||||
|
"deaktivieren": "disable",
|
||||||
|
"codes-erneuern": "recovery-codes",
|
||||||
|
"sitzungen": "sessions",
|
||||||
|
"beenden": "end",
|
||||||
|
"alle-beenden": "end-all",
|
||||||
|
},
|
||||||
|
),
|
||||||
|
"/uebersicht": ("/overview", {"kacheln": "tiles"}),
|
||||||
|
"/gesundheit": ("/health", {}),
|
||||||
|
"/bestaetigen": ("/confirm", {}),
|
||||||
|
}
|
||||||
|
|
||||||
|
#: Abfrageparameter, die mit umbenannt wurden.
|
||||||
|
PARAMETER = {
|
||||||
|
"freigabe": "share",
|
||||||
|
"pfad": "path",
|
||||||
|
"aktion": "action",
|
||||||
|
"benutzer": "actor",
|
||||||
|
"stufe": "severity",
|
||||||
|
"seite": "page",
|
||||||
|
"methode": "method",
|
||||||
|
"kunde": "customer",
|
||||||
|
"freischalten": "unlocked",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
#: Wurzeln, unter denen eine unbekannte Unterseite **nicht** umgelenkt wird.
|
||||||
|
#: ``/lizenz/<ticket>`` ist auf dem Lizenzserver eine echte Adresse; ein
|
||||||
|
#: abgelaufenes oder falsches Ticket gehoert dort auf die Fehlerseite und nicht
|
||||||
|
#: in die Verwaltung.
|
||||||
|
STRENG = {"/lizenz"}
|
||||||
|
|
||||||
|
|
||||||
|
def neue_adresse(pfad: str) -> str | None:
|
||||||
|
"""Uebersetzt eine alte Adresse -- oder ``None``, wenn es keine ist."""
|
||||||
|
for alt, (neu, segmente) in WURZELN.items():
|
||||||
|
if pfad != alt and not pfad.startswith(alt + "/"):
|
||||||
|
continue
|
||||||
|
rest = pfad[len(alt) :]
|
||||||
|
teile = rest.split("/")
|
||||||
|
if alt in STRENG:
|
||||||
|
erstes = next((teil for teil in teile if teil), "")
|
||||||
|
if erstes and erstes not in segmente:
|
||||||
|
return None
|
||||||
|
return neu + "/".join(segmente.get(teil, teil) for teil in teile)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def neue_parameter(args: Mapping[str, str]) -> dict[str, str]:
|
||||||
|
return {PARAMETER.get(name, name): wert for name, wert in args.items()}
|
||||||
@@ -68,10 +68,10 @@ API_PATHS = {
|
|||||||
#: die einzige Wahrheit -- Templates lesen sie, Routen erzwingen sie.
|
#: die einzige Wahrheit -- Templates lesen sie, Routen erzwingen sie.
|
||||||
LICENSED_FEATURES: dict[str, str] = {
|
LICENSED_FEATURES: dict[str, str] = {
|
||||||
"directory": "Verzeichnisanmeldung (LDAP/AD) konfigurieren",
|
"directory": "Verzeichnisanmeldung (LDAP/AD) konfigurieren",
|
||||||
"nginx": "Webserver-Einstellungen aendern",
|
"nginx": "Webserver-Einstellungen ändern",
|
||||||
"export": "Daten exportieren",
|
"export": "Daten exportieren",
|
||||||
"trash_restore": "Papierkorb wiederherstellen und endgueltig loeschen",
|
"trash_restore": "Papierkorb wiederherstellen und endgültig löschen",
|
||||||
"monitor_interval": "Pruefintervall der Ueberwachung aendern",
|
"monitor_interval": "Prüfintervall der Überwachung ändern",
|
||||||
"auto_poe_restart": "Automatischer PoE-Neustart bei Ausfall",
|
"auto_poe_restart": "Automatischer PoE-Neustart bei Ausfall",
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -85,7 +85,7 @@ def describe_transport_error(exc: BaseException, endpoint: str) -> str:
|
|||||||
"""
|
"""
|
||||||
text = str(exc)
|
text = str(exc)
|
||||||
if "CERTIFICATE_VERIFY_FAILED" in text or "certificate verify failed" in text:
|
if "CERTIFICATE_VERIFY_FAILED" in text or "certificate verify failed" in text:
|
||||||
detail = "Das Zertifikat konnte nicht geprueft werden"
|
detail = "Das Zertifikat konnte nicht geprüft werden"
|
||||||
if "self-signed" in text or "self signed" in text:
|
if "self-signed" in text or "self signed" in text:
|
||||||
detail = "Der Lizenzserver benutzt ein selbstsigniertes Zertifikat"
|
detail = "Der Lizenzserver benutzt ein selbstsigniertes Zertifikat"
|
||||||
return (
|
return (
|
||||||
@@ -94,7 +94,7 @@ def describe_transport_error(exc: BaseException, endpoint: str) -> str:
|
|||||||
"einer anerkannten Stelle."
|
"einer anerkannten Stelle."
|
||||||
)
|
)
|
||||||
if "Name or service not known" in text or "nodename nor servname" in text:
|
if "Name or service not known" in text or "nodename nor servname" in text:
|
||||||
return f"Der Name im Endpunkt ({endpoint}) laesst sich nicht aufloesen."
|
return f"Der Name im Endpunkt ({endpoint}) lässt sich nicht auflösen."
|
||||||
if "Connection refused" in text:
|
if "Connection refused" in text:
|
||||||
return f"{endpoint} nimmt keine Verbindung an."
|
return f"{endpoint} nimmt keine Verbindung an."
|
||||||
if "timed out" in text or "timeout" in text.lower():
|
if "timed out" in text or "timeout" in text.lower():
|
||||||
@@ -257,8 +257,8 @@ class LicenseManager:
|
|||||||
def require(self, feature: str) -> None:
|
def require(self, feature: str) -> None:
|
||||||
if not self.feature_allowed(feature):
|
if not self.feature_allowed(feature):
|
||||||
raise LicenseError(
|
raise LicenseError(
|
||||||
f"{LICENSED_FEATURES.get(feature, feature)} ist ohne gueltige, "
|
f"{LICENSED_FEATURES.get(feature, feature)} ist ohne gültige, "
|
||||||
"aktivierte Lizenz nicht moeglich."
|
"aktivierte Lizenz nicht möglich."
|
||||||
)
|
)
|
||||||
|
|
||||||
# ------------------------------------------------------------------ #
|
# ------------------------------------------------------------------ #
|
||||||
@@ -284,7 +284,7 @@ class LicenseManager:
|
|||||||
try:
|
try:
|
||||||
payload = json.loads(text)
|
payload = json.loads(text)
|
||||||
except ValueError as exc:
|
except ValueError as exc:
|
||||||
raise LicenseError("Die Datei ist kein gueltiges JSON.") from exc
|
raise LicenseError("Die Datei ist kein gültiges JSON.") from exc
|
||||||
|
|
||||||
try:
|
try:
|
||||||
document, client_key = lic.split_bundle(payload)
|
document, client_key = lic.split_bundle(payload)
|
||||||
@@ -294,20 +294,20 @@ class LicenseManager:
|
|||||||
try:
|
try:
|
||||||
lic.verify_license(document) # innere Konsistenz
|
lic.verify_license(document) # innere Konsistenz
|
||||||
except lic.LicensingError as exc:
|
except lic.LicensingError as exc:
|
||||||
raise LicenseError(f"Die Lizenzdatei ist ungueltig: {exc}") from exc
|
raise LicenseError(f"Die Lizenzdatei ist ungültig: {exc}") from exc
|
||||||
|
|
||||||
master_pubkey = str(document.get("master_pubkey") or "")
|
master_pubkey = str(document.get("master_pubkey") or "")
|
||||||
known = self.trusted_keys(conn)
|
known = self.trusted_keys(conn)
|
||||||
if known and master_pubkey not in set(known) and not allow_new_issuer:
|
if known and master_pubkey not in set(known) and not allow_new_issuer:
|
||||||
raise LicenseError(
|
raise LicenseError(
|
||||||
"Diese Lizenz wurde von einem anderen Lizenzserver ausgestellt "
|
"Diese Lizenz wurde von einem anderen Lizenzserver ausgestellt "
|
||||||
f"(Schluessel {lic.key_id(master_pubkey)}). Ein Administrator muss den "
|
f"(Schlüssel {lic.key_id(master_pubkey)}). Ein Administrator muss den "
|
||||||
"Wechsel des Ausstellers ausdruecklich bestaetigen."
|
"Wechsel des Ausstellers ausdrücklich bestätigen."
|
||||||
)
|
)
|
||||||
|
|
||||||
if not client_key and not self.key_path.is_file():
|
if not client_key and not self.key_path.is_file():
|
||||||
raise LicenseError(
|
raise LicenseError(
|
||||||
"Der Datei fehlt der Client-Schluessel. Bitte das vollstaendige "
|
"Der Datei fehlt der Client-Schlüssel. Bitte das vollständige "
|
||||||
"Lizenz-Bundle des Lizenzservers verwenden."
|
"Lizenz-Bundle des Lizenzservers verwenden."
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -332,7 +332,7 @@ class LicenseManager:
|
|||||||
actor=actor,
|
actor=actor,
|
||||||
action="license.issuer_pinned",
|
action="license.issuer_pinned",
|
||||||
target=lic.key_id(master_pubkey),
|
target=lic.key_id(master_pubkey),
|
||||||
detail="Trust on first use" if not known else "Aussteller-Wechsel bestaetigt",
|
detail="Trust on first use" if not known else "Aussteller-Wechsel bestätigt",
|
||||||
severity="notice",
|
severity="notice",
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -397,11 +397,11 @@ class LicenseManager:
|
|||||||
self, action: str, *, hostname: str = "", report: dict[str, Any] | None = None
|
self, action: str, *, hostname: str = "", report: dict[str, Any] | None = None
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
if not self._state.valid:
|
if not self._state.valid:
|
||||||
raise LicenseError("Es ist keine gueltige Lizenz installiert.")
|
raise LicenseError("Es ist keine gültige Lizenz installiert.")
|
||||||
key = self.client_key()
|
key = self.client_key()
|
||||||
if not key:
|
if not key:
|
||||||
raise LicenseError(
|
raise LicenseError(
|
||||||
"Der Client-Schluessel dieser Lizenz fehlt. Bitte die Lizenzdatei erneut importieren."
|
"Der Client-Schlüssel dieser Lizenz fehlt. Bitte die Lizenzdatei erneut importieren."
|
||||||
)
|
)
|
||||||
return lic.build_client_request(
|
return lic.build_client_request(
|
||||||
action, self._state.document, key, hostname=hostname, report=report
|
action, self._state.document, key, hostname=hostname, report=report
|
||||||
@@ -442,8 +442,8 @@ class LicenseManager:
|
|||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
reason = (
|
reason = (
|
||||||
"In dieser Lizenz ist kein Lizenzserver hinterlegt -- sie kann sich nur "
|
"In dieser Lizenz ist kein Lizenzserver hinterlegt – sie kann sich nur "
|
||||||
"ueber den Offline-Weg aktivieren."
|
"über den Offline-Weg aktivieren."
|
||||||
)
|
)
|
||||||
|
|
||||||
# Offline-Weg: Anfrage merken -- **mit** dem Code. Zwischen Anfrage und
|
# Offline-Weg: Anfrage merken -- **mit** dem Code. Zwischen Anfrage und
|
||||||
@@ -475,7 +475,7 @@ class LicenseManager:
|
|||||||
"code": code,
|
"code": code,
|
||||||
"message": (
|
"message": (
|
||||||
(reason + " " if reason else "")
|
(reason + " " if reason else "")
|
||||||
+ "Der Offline-Weg steht offen: uebermitteln Sie den angezeigten Code an "
|
+ "Der Offline-Weg steht offen: übermitteln Sie den angezeigten Code an "
|
||||||
"Ihren Ansprechpartner und tragen Sie die Antwort hier ein."
|
"Ihren Ansprechpartner und tragen Sie die Antwort hier ein."
|
||||||
),
|
),
|
||||||
}
|
}
|
||||||
@@ -527,7 +527,7 @@ class LicenseManager:
|
|||||||
current_seq=current_seq,
|
current_seq=current_seq,
|
||||||
)
|
)
|
||||||
except lic.LicensingError as exc:
|
except lic.LicensingError as exc:
|
||||||
raise LicenseError(f"Die Antwort des Lizenzservers ist nicht gueltig: {exc}") from exc
|
raise LicenseError(f"Die Antwort des Lizenzservers ist nicht gültig: {exc}") from exc
|
||||||
|
|
||||||
status = str(response.get("status") or "")
|
status = str(response.get("status") or "")
|
||||||
detail = str(response.get("detail") or "")
|
detail = str(response.get("detail") or "")
|
||||||
@@ -597,7 +597,7 @@ class LicenseManager:
|
|||||||
actor=actor,
|
actor=actor,
|
||||||
)
|
)
|
||||||
self.settings.set(conn, SETTING_HEARTBEAT_ERROR, "", actor=actor)
|
self.settings.set(conn, SETTING_HEARTBEAT_ERROR, "", actor=actor)
|
||||||
message = "Verbindung zum Lizenzserver bestaetigt."
|
message = "Verbindung zum Lizenzserver bestätigt."
|
||||||
|
|
||||||
self.settings.set(conn, SETTING_PENDING, "", actor=actor)
|
self.settings.set(conn, SETTING_PENDING, "", actor=actor)
|
||||||
# Ein erfolgreicher Heartbeat laeuft alle paar Minuten und ist kein
|
# Ein erfolgreicher Heartbeat laeuft alle paar Minuten und ist kein
|
||||||
@@ -648,7 +648,7 @@ class LicenseManager:
|
|||||||
url: str, payload: dict[str, Any], *, timeout: int, ca_pem: str = ""
|
url: str, payload: dict[str, Any], *, timeout: int, ca_pem: str = ""
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
if not url.startswith(("https://", "http://")):
|
if not url.startswith(("https://", "http://")):
|
||||||
raise ValueError("Ungueltiger Endpunkt")
|
raise ValueError("Ungültiger Endpunkt")
|
||||||
data = json.dumps(payload).encode("utf-8")
|
data = json.dumps(payload).encode("utf-8")
|
||||||
request = urllib.request.Request( # noqa: S310 - Schema oben geprueft
|
request = urllib.request.Request( # noqa: S310 - Schema oben geprueft
|
||||||
url,
|
url,
|
||||||
@@ -748,7 +748,7 @@ class LicenseManager:
|
|||||||
}
|
}
|
||||||
if not state.valid:
|
if not state.valid:
|
||||||
return {
|
return {
|
||||||
"label": "Lizenz ungueltig",
|
"label": "Lizenz ungültig",
|
||||||
"css_class": "status-pill--danger",
|
"css_class": "status-pill--danger",
|
||||||
"dot": "1",
|
"dot": "1",
|
||||||
"dot_class": "status-pill__dot--pulse",
|
"dot_class": "status-pill__dot--pulse",
|
||||||
@@ -773,29 +773,29 @@ class LicenseManager:
|
|||||||
"dot": "1",
|
"dot": "1",
|
||||||
"dot_class": "status-pill__dot--pulse",
|
"dot_class": "status-pill__dot--pulse",
|
||||||
"title": (
|
"title": (
|
||||||
"Die Kulanzfrist laeuft noch, Funktionen bleiben vorerst verfuegbar."
|
"Die Kulanzfrist läuft noch, Funktionen bleiben vorerst verfügbar."
|
||||||
if status.grace_active
|
if status.grace_active
|
||||||
else "Lizenzpflichtige Funktionen sind abgeschaltet."
|
else "Lizenzpflichtige Funktionen sind abgeschaltet."
|
||||||
),
|
),
|
||||||
}
|
}
|
||||||
if status.expiring_soon:
|
if status.expiring_soon:
|
||||||
return {
|
return {
|
||||||
"label": f"Lizenz laeuft in {status.days_left} Tagen ab",
|
"label": f"Lizenz läuft in {status.days_left} Tagen ab",
|
||||||
"css_class": "status-pill--warning",
|
"css_class": "status-pill--warning",
|
||||||
"dot": "1",
|
"dot": "1",
|
||||||
"dot_class": "status-pill__dot--pulse",
|
"dot_class": "status-pill__dot--pulse",
|
||||||
"title": "Bitte rechtzeitig verlaengern.",
|
"title": "Bitte rechtzeitig verlängern.",
|
||||||
}
|
}
|
||||||
# Zwischen der Warnschwelle und 90 Tagen: gruen, rein als Hinweis. Wer
|
# Zwischen der Warnschwelle und 90 Tagen: gruen, rein als Hinweis. Wer
|
||||||
# eine Verlaengerung im Voraus plant, will das Datum sehen, ohne dass es
|
# eine Verlaengerung im Voraus plant, will das Datum sehen, ohne dass es
|
||||||
# nach einem Problem aussieht.
|
# nach einem Problem aussieht.
|
||||||
if status.days_left is not None and 0 < status.days_left <= EXPIRY_HINT_DAYS:
|
if status.days_left is not None and 0 < status.days_left <= EXPIRY_HINT_DAYS:
|
||||||
return {
|
return {
|
||||||
"label": f"Lizenz laeuft in {status.days_left} Tagen ab",
|
"label": f"Lizenz läuft in {status.days_left} Tagen ab",
|
||||||
"css_class": "",
|
"css_class": "",
|
||||||
"dot": "1",
|
"dot": "1",
|
||||||
"dot_class": "",
|
"dot_class": "",
|
||||||
"title": "Nur ein Hinweis -- es ist nichts eingeschraenkt.",
|
"title": "Nur ein Hinweis – es ist nichts eingeschränkt.",
|
||||||
}
|
}
|
||||||
if state.last_heartbeat_at:
|
if state.last_heartbeat_at:
|
||||||
try:
|
try:
|
||||||
@@ -809,6 +809,6 @@ class LicenseManager:
|
|||||||
"css_class": "status-pill--warning",
|
"css_class": "status-pill--warning",
|
||||||
"dot": "1",
|
"dot": "1",
|
||||||
"dot_class": "",
|
"dot_class": "",
|
||||||
"title": "Rein informativ -- es wird nichts abgeschaltet.",
|
"title": "Rein informativ – es wird nichts abgeschaltet.",
|
||||||
}
|
}
|
||||||
return None
|
return None
|
||||||
@@ -94,7 +94,7 @@ def init_app(app: Flask, *, log_dir: Path, debug: bool, json_logs: bool = False)
|
|||||||
except OSError:
|
except OSError:
|
||||||
pass
|
pass
|
||||||
except OSError as exc: # pragma: no cover - z. B. fehlende Rechte
|
except OSError as exc: # pragma: no cover - z. B. fehlende Rechte
|
||||||
root.warning("Konnte app.log nicht oeffnen: %s", exc)
|
root.warning("Konnte app.log nicht öffnen: %s", exc)
|
||||||
|
|
||||||
# Zugriffsprotokoll separat: eine Zeile pro Anfrage, ohne Stacktraces.
|
# Zugriffsprotokoll separat: eine Zeile pro Anfrage, ohne Stacktraces.
|
||||||
access = logging.getLogger(ACCESS_LOGGER)
|
access = logging.getLogger(ACCESS_LOGGER)
|
||||||
|
|||||||
@@ -176,9 +176,9 @@ class MigrationRunner:
|
|||||||
if record is not None:
|
if record is not None:
|
||||||
if record["checksum"] != migration.checksum:
|
if record["checksum"] != migration.checksum:
|
||||||
logger.warning(
|
logger.warning(
|
||||||
"Migration %s/%s (%s) wurde nachtraeglich geaendert "
|
"Migration %s/%s (%s) wurde nachträglich geändert "
|
||||||
"(Pruefsumme %s != %s). Bereits angewendete Migrationen werden "
|
"(Prüfsumme %s != %s). Bereits angewendete Migrationen werden "
|
||||||
"nicht erneut ausgefuehrt -- neue Aenderungen brauchen eine neue "
|
"nicht erneut ausgeführt – neue Änderungen brauchen eine neue "
|
||||||
"Versionsnummer.",
|
"Versionsnummer.",
|
||||||
component,
|
component,
|
||||||
migration.version,
|
migration.version,
|
||||||
@@ -210,7 +210,7 @@ class MigrationRunner:
|
|||||||
raise
|
raise
|
||||||
logger.error(
|
logger.error(
|
||||||
"Migration %s/%s (%s) konnte wegen bestehender Altdaten nicht "
|
"Migration %s/%s (%s) konnte wegen bestehender Altdaten nicht "
|
||||||
"angewendet werden. Der Start laeuft weiter; die Daten muessen "
|
"angewendet werden. Der Start läuft weiter; die Daten müssen "
|
||||||
"manuell bereinigt werden.",
|
"manuell bereinigt werden.",
|
||||||
component,
|
component,
|
||||||
migration.version,
|
migration.version,
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ USERS = Resource(
|
|||||||
actions=(A.VIEW, A.CREATE, A.EDIT, A.DELETE),
|
actions=(A.VIEW, A.CREATE, A.EDIT, A.DELETE),
|
||||||
hint="Konten anlegen, sperren, Gruppen zuordnen",
|
hint="Konten anlegen, sperren, Gruppen zuordnen",
|
||||||
action_hints={
|
action_hints={
|
||||||
A.EDIT: "Stammdaten, Sperre und Gruppenzuordnung aendern.",
|
A.EDIT: "Stammdaten, Sperre und Gruppenzuordnung ändern.",
|
||||||
A.DELETE: "Konto in den Papierkorb legen (Verzeichniskonten sofort entfernen).",
|
A.DELETE: "Konto in den Papierkorb legen (Verzeichniskonten sofort entfernen).",
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
@@ -30,7 +30,7 @@ GROUPS = Resource(
|
|||||||
actions=(A.VIEW, A.CREATE, A.EDIT, A.DELETE),
|
actions=(A.VIEW, A.CREATE, A.EDIT, A.DELETE),
|
||||||
hint="Rechtematrix pflegen",
|
hint="Rechtematrix pflegen",
|
||||||
action_hints={
|
action_hints={
|
||||||
A.EDIT: "Rechte vergeben -- nur Rechte, die man selbst besitzt.",
|
A.EDIT: "Rechte vergeben – nur Rechte, die man selbst besitzt.",
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -39,7 +39,7 @@ SETTINGS_SYSTEM = Resource(
|
|||||||
label="Systemeinstellungen",
|
label="Systemeinstellungen",
|
||||||
actions=(A.VIEW, A.EDIT, A.EXECUTE),
|
actions=(A.VIEW, A.EDIT, A.EXECUTE),
|
||||||
hint="Hostname, Zeitzone, Protokollrotation, Navigation",
|
hint="Hostname, Zeitzone, Protokollrotation, Navigation",
|
||||||
action_hints={A.EXECUTE: "Systemaktionen ausloesen (Dienste, Netzwerk uebernehmen)."},
|
action_hints={A.EXECUTE: "Systemaktionen auslösen (Dienste, Netzwerk übernehmen)."},
|
||||||
)
|
)
|
||||||
|
|
||||||
SETTINGS_LICENSE = Resource(
|
SETTINGS_LICENSE = Resource(
|
||||||
@@ -55,7 +55,7 @@ SETTINGS_DIRECTORY = Resource(
|
|||||||
actions=(A.VIEW, A.EDIT, A.EXECUTE, A.SECRETS),
|
actions=(A.VIEW, A.EDIT, A.EXECUTE, A.SECRETS),
|
||||||
hint="Anbindung und Gruppen-Zuordnung",
|
hint="Anbindung und Gruppen-Zuordnung",
|
||||||
action_hints={
|
action_hints={
|
||||||
A.EXECUTE: "Verbindungstest ausfuehren.",
|
A.EXECUTE: "Verbindungstest ausführen.",
|
||||||
A.SECRETS: "Bind-Passwort des Dienstkontos setzen.",
|
A.SECRETS: "Bind-Passwort des Dienstkontos setzen.",
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
@@ -65,7 +65,7 @@ SETTINGS_WEB = Resource(
|
|||||||
label="Webserver und TLS",
|
label="Webserver und TLS",
|
||||||
actions=(A.VIEW, A.EDIT, A.EXECUTE),
|
actions=(A.VIEW, A.EDIT, A.EXECUTE),
|
||||||
hint="Domain, Zertifikat, HSTS",
|
hint="Domain, Zertifikat, HSTS",
|
||||||
action_hints={A.EXECUTE: "Konfiguration uebernehmen, Zertifikat anfordern."},
|
action_hints={A.EXECUTE: "Konfiguration übernehmen, Zertifikat anfordern."},
|
||||||
)
|
)
|
||||||
|
|
||||||
SETTINGS_NETWORK = Resource(
|
SETTINGS_NETWORK = Resource(
|
||||||
@@ -77,13 +77,13 @@ SETTINGS_NETWORK = Resource(
|
|||||||
|
|
||||||
BACKUP = Resource(
|
BACKUP = Resource(
|
||||||
key="backup",
|
key="backup",
|
||||||
label="Sicherung und Uebertragung",
|
label="Sicherung und Übertragung",
|
||||||
actions=(A.VIEW, A.EXECUTE, A.EXPORT, A.SECRETS),
|
actions=(A.VIEW, A.EXECUTE, A.EXPORT, A.SECRETS),
|
||||||
hint="Verschluesselter Export und Import",
|
hint="Verschlüsselter Export und Import",
|
||||||
action_hints={
|
action_hints={
|
||||||
A.EXECUTE: "Eine Sicherung einspielen.",
|
A.EXECUTE: "Eine Sicherung einspielen.",
|
||||||
A.EXPORT: "Sicherung erzeugen und herunterladen.",
|
A.EXPORT: "Sicherung erzeugen und herunterladen.",
|
||||||
A.SECRETS: "Zugangsdaten in die Sicherung einschliessen.",
|
A.SECRETS: "Zugangsdaten in die Sicherung einschließen.",
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -91,10 +91,10 @@ TRASH = Resource(
|
|||||||
key="trash",
|
key="trash",
|
||||||
label="Papierkorb",
|
label="Papierkorb",
|
||||||
actions=(A.VIEW, A.EDIT, A.DELETE),
|
actions=(A.VIEW, A.EDIT, A.DELETE),
|
||||||
hint="Geloeschtes wiederherstellen oder endgueltig entfernen",
|
hint="Gelöschtes wiederherstellen oder endgültig entfernen",
|
||||||
action_hints={
|
action_hints={
|
||||||
A.EDIT: "Eintraege wiederherstellen.",
|
A.EDIT: "Einträge wiederherstellen.",
|
||||||
A.DELETE: "Eintraege endgueltig entfernen.",
|
A.DELETE: "Einträge endgültig entfernen.",
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -102,7 +102,7 @@ DIAGNOSTICS = Resource(
|
|||||||
key="diagnostics",
|
key="diagnostics",
|
||||||
label="Diagnose",
|
label="Diagnose",
|
||||||
actions=(A.VIEW,),
|
actions=(A.VIEW,),
|
||||||
hint="Schemastand, Protokollintegritaet, Schluesselrechte",
|
hint="Schemastand, Protokollintegrität, Schlüsselrechte",
|
||||||
)
|
)
|
||||||
|
|
||||||
LOGS_LIVE = Resource(
|
LOGS_LIVE = Resource(
|
||||||
@@ -121,9 +121,9 @@ LOGS_HISTORY = Resource(
|
|||||||
|
|
||||||
LOGS_AUDIT = Resource(
|
LOGS_AUDIT = Resource(
|
||||||
key="logs_audit",
|
key="logs_audit",
|
||||||
label="Aenderungsprotokoll",
|
label="Änderungsprotokoll",
|
||||||
actions=(A.VIEW, A.EXPORT),
|
actions=(A.VIEW, A.EXPORT),
|
||||||
hint="Wer hat wann was geaendert",
|
hint="Wer hat wann was geändert",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -42,16 +42,16 @@ class Action(str, Enum):
|
|||||||
|
|
||||||
#: Kuerzel und Erklaerung je Aktion -- Kopfzeile der Rechtematrix.
|
#: Kuerzel und Erklaerung je Aktion -- Kopfzeile der Rechtematrix.
|
||||||
ACTION_META: dict[Action, tuple[str, str, str]] = {
|
ACTION_META: dict[Action, tuple[str, str, str]] = {
|
||||||
Action.VIEW: ("R", "Ansehen", "Datensaetze und Seiten dieses Bereichs lesen."),
|
Action.VIEW: ("R", "Ansehen", "Datensätze und Seiten dieses Bereichs lesen."),
|
||||||
Action.CREATE: ("C", "Anlegen", "Neue Datensaetze erstellen."),
|
Action.CREATE: ("C", "Anlegen", "Neue Datensätze erstellen."),
|
||||||
Action.EDIT: ("U", "Bearbeiten", "Bestehende Datensaetze aendern -- ohne Loeschen."),
|
Action.EDIT: ("U", "Bearbeiten", "Bestehende Datensätze ändern – ohne Löschen."),
|
||||||
Action.DELETE: ("D", "Loeschen", "Datensaetze loeschen bzw. endgueltig entfernen."),
|
Action.DELETE: ("D", "Loeschen", "Datensätze löschen bzw. endgültig entfernen."),
|
||||||
Action.EXECUTE: ("X", "Ausfuehren", "Aktionen ausloesen (Neustart, Dienste, Jobs, Import)."),
|
Action.EXECUTE: ("X", "Ausfuehren", "Aktionen auslösen (Neustart, Dienste, Jobs, Import)."),
|
||||||
Action.EXPORT: ("E", "Exportieren", "Daten aus dem System heraustragen (Datei-Download)."),
|
Action.EXPORT: ("E", "Exportieren", "Daten aus dem System heraustragen (Datei-Download)."),
|
||||||
Action.SECRETS: (
|
Action.SECRETS: (
|
||||||
"S",
|
"S",
|
||||||
"Geheimnisse",
|
"Geheimnisse",
|
||||||
"Passwoerter und Schluessel im Klartext sehen oder mitnehmen.",
|
"Passwörter und Schlüssel im Klartext sehen oder mitnehmen.",
|
||||||
),
|
),
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -74,6 +74,11 @@ class Resource:
|
|||||||
hint: str = ""
|
hint: str = ""
|
||||||
#: Abweichende Erklaerung einzelner Aktionen (z. B. was "Ausfuehren" hier meint).
|
#: Abweichende Erklaerung einzelner Aktionen (z. B. was "Ausfuehren" hier meint).
|
||||||
action_hints: Mapping[Action, str] = field(default_factory=dict)
|
action_hints: Mapping[Action, str] = field(default_factory=dict)
|
||||||
|
#: Aktionen, die hier **keine** frische Passwortbestaetigung verlangen,
|
||||||
|
#: obwohl sie in SENSITIVE_ACTIONS stehen. Bewusst hier und nicht als
|
||||||
|
#: stilles Weglassen in der Route: eine Ausnahme von einer
|
||||||
|
#: Sicherheitsregel gehoert dorthin, wo man sie sieht und begruenden muss.
|
||||||
|
ohne_wiederanmeldung: tuple[Action, ...] = ()
|
||||||
|
|
||||||
def permission(self, action: Action) -> str:
|
def permission(self, action: Action) -> str:
|
||||||
return f"{self.key}.{action.value}"
|
return f"{self.key}.{action.value}"
|
||||||
@@ -127,18 +132,18 @@ class PermissionTree:
|
|||||||
seen_keys: set[str] = set()
|
seen_keys: set[str] = set()
|
||||||
for area in self.areas:
|
for area in self.areas:
|
||||||
if area.key in seen_keys:
|
if area.key in seen_keys:
|
||||||
raise ValueError(f"Doppelter Bereichsschluessel: {area.key}")
|
raise ValueError(f"Doppelter Bereichsschlüssel: {area.key}")
|
||||||
seen_keys.add(area.key)
|
seen_keys.add(area.key)
|
||||||
self._by_key[area.key] = area
|
self._by_key[area.key] = area
|
||||||
self._all.append(area.view_key)
|
self._all.append(area.view_key)
|
||||||
for resource in area.resources:
|
for resource in area.resources:
|
||||||
if resource.key in seen_keys:
|
if resource.key in seen_keys:
|
||||||
raise ValueError(f"Doppelter Ressourcenschluessel: {resource.key}")
|
raise ValueError(f"Doppelter Ressourcenschlüssel: {resource.key}")
|
||||||
seen_keys.add(resource.key)
|
seen_keys.add(resource.key)
|
||||||
if Action.VIEW not in resource.actions:
|
if Action.VIEW not in resource.actions:
|
||||||
raise ValueError(
|
raise ValueError(
|
||||||
f"Ressource {resource.key} muss die Aktion 'view' anbieten -- "
|
f"Ressource {resource.key} muss die Aktion 'view' anbieten – "
|
||||||
"sie ist Voraussetzung fuer jede andere Aktion."
|
"sie ist Voraussetzung für jede andere Aktion."
|
||||||
)
|
)
|
||||||
self._resources[resource.key] = (area, resource)
|
self._resources[resource.key] = (area, resource)
|
||||||
for permission in resource.permissions():
|
for permission in resource.permissions():
|
||||||
@@ -159,6 +164,25 @@ class PermissionTree:
|
|||||||
def resource(self, key: str) -> tuple[Area, Resource] | None:
|
def resource(self, key: str) -> tuple[Area, Resource] | None:
|
||||||
return self._resources.get(key)
|
return self._resources.get(key)
|
||||||
|
|
||||||
|
def verlangt_wiederanmeldung(self, permission: str) -> bool:
|
||||||
|
"""Braucht dieses Recht eine frische Passwortbestaetigung?
|
||||||
|
|
||||||
|
Die Antwort steht im Modell (``SENSITIVE_ACTIONS``) und nicht in der
|
||||||
|
Route -- sonst vergisst sie die naechste, die dazukommt.
|
||||||
|
"""
|
||||||
|
resource_key, _, action_value = permission.rpartition(".")
|
||||||
|
entry = self._resources.get(resource_key)
|
||||||
|
if entry is None:
|
||||||
|
return False
|
||||||
|
_, resource = entry
|
||||||
|
try:
|
||||||
|
action = Action(action_value)
|
||||||
|
except ValueError:
|
||||||
|
return False
|
||||||
|
if action in resource.ohne_wiederanmeldung:
|
||||||
|
return False
|
||||||
|
return action in SENSITIVE_ACTIONS
|
||||||
|
|
||||||
def label_for(self, permission: str) -> str:
|
def label_for(self, permission: str) -> str:
|
||||||
"""Menschenlesbare Beschreibung eines Rechteschluessels -- fuer Audit-Logs."""
|
"""Menschenlesbare Beschreibung eines Rechteschluessels -- fuer Audit-Logs."""
|
||||||
if permission.endswith(".view") and permission[:-5] in self._by_key:
|
if permission.endswith(".view") and permission[:-5] in self._by_key:
|
||||||
|
|||||||
@@ -132,7 +132,7 @@ def guard_no_amplification(
|
|||||||
labels = ", ".join(tree.label_for(p) for p in missing[:6])
|
labels = ", ".join(tree.label_for(p) for p in missing[:6])
|
||||||
more = "" if len(missing) <= 6 else f" (und {len(missing) - 6} weitere)"
|
more = "" if len(missing) <= 6 else f" (und {len(missing) - 6} weitere)"
|
||||||
raise PermissionDenied(
|
raise PermissionDenied(
|
||||||
f"Sie koennen kein {what} vergeben, das Sie selbst nicht besitzen: {labels}{more}."
|
f"Sie können kein {what} vergeben, das Sie selbst nicht besitzen: {labels}{more}."
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -160,7 +160,7 @@ def create_group(
|
|||||||
if existing["deleted_at"]:
|
if existing["deleted_at"]:
|
||||||
raise ValidationError(
|
raise ValidationError(
|
||||||
f"Eine Gruppe namens {clean_name!r} liegt im Papierkorb. "
|
f"Eine Gruppe namens {clean_name!r} liegt im Papierkorb. "
|
||||||
"Stellen Sie sie wieder her oder loeschen Sie sie endgueltig."
|
"Stellen Sie sie wieder her oder löschen Sie sie endgültig."
|
||||||
)
|
)
|
||||||
raise ValidationError(f"Eine Gruppe namens {clean_name!r} existiert bereits.")
|
raise ValidationError(f"Eine Gruppe namens {clean_name!r} existiert bereits.")
|
||||||
|
|
||||||
@@ -205,7 +205,7 @@ def update_group(
|
|||||||
|
|
||||||
if name is not None and name.strip() != group.name:
|
if name is not None and name.strip() != group.name:
|
||||||
if group.is_system:
|
if group.is_system:
|
||||||
raise PermissionDenied("Systemgruppen koennen nicht umbenannt werden.")
|
raise PermissionDenied("Systemgruppen können nicht umbenannt werden.")
|
||||||
clean = name.strip()
|
clean = name.strip()
|
||||||
if not clean:
|
if not clean:
|
||||||
raise ValidationError("Der Gruppenname darf nicht leer sein.")
|
raise ValidationError("Der Gruppenname darf nicht leer sein.")
|
||||||
@@ -224,13 +224,13 @@ def update_group(
|
|||||||
"UPDATE groups SET description=?, updated_at=? WHERE id=?",
|
"UPDATE groups SET description=?, updated_at=? WHERE id=?",
|
||||||
(description.strip(), _now(), group_id),
|
(description.strip(), _now(), group_id),
|
||||||
)
|
)
|
||||||
changes.append("Beschreibung geaendert")
|
changes.append("Beschreibung geändert")
|
||||||
|
|
||||||
if permissions is not None:
|
if permissions is not None:
|
||||||
if group.is_system and not unlock_system:
|
if group.is_system and not unlock_system:
|
||||||
raise PermissionDenied(
|
raise PermissionDenied(
|
||||||
"Die Rechte dieser Systemgruppe sind gesperrt. "
|
"Die Rechte dieser Systemgruppe sind gesperrt. "
|
||||||
"Sie muessen sie zuerst ausdruecklich freischalten."
|
"Sie müssen sie zuerst ausdrücklich freischalten."
|
||||||
)
|
)
|
||||||
wanted = tree.sanitize(permissions)
|
wanted = tree.sanitize(permissions)
|
||||||
added = sorted(set(wanted) - set(group.permissions))
|
added = sorted(set(wanted) - set(group.permissions))
|
||||||
@@ -240,7 +240,7 @@ def update_group(
|
|||||||
_write_permissions(conn, group_id, wanted)
|
_write_permissions(conn, group_id, wanted)
|
||||||
conn.execute("UPDATE groups SET updated_at=? WHERE id=?", (_now(), group_id))
|
conn.execute("UPDATE groups SET updated_at=? WHERE id=?", (_now(), group_id))
|
||||||
if added:
|
if added:
|
||||||
changes.append("Rechte hinzugefuegt: " + ", ".join(tree.label_for(p) for p in added))
|
changes.append("Rechte hinzugefügt: " + ", ".join(tree.label_for(p) for p in added))
|
||||||
if removed:
|
if removed:
|
||||||
changes.append("Rechte entfernt: " + ", ".join(tree.label_for(p) for p in removed))
|
changes.append("Rechte entfernt: " + ", ".join(tree.label_for(p) for p in removed))
|
||||||
|
|
||||||
@@ -252,7 +252,7 @@ def soft_delete_group(conn: sqlite3.Connection, group_id: int) -> None:
|
|||||||
if group is None:
|
if group is None:
|
||||||
raise ValidationError("Diese Gruppe existiert nicht.")
|
raise ValidationError("Diese Gruppe existiert nicht.")
|
||||||
if group.is_system:
|
if group.is_system:
|
||||||
raise PermissionDenied("Systemgruppen koennen nicht geloescht werden.")
|
raise PermissionDenied("Systemgruppen können nicht gelöscht werden.")
|
||||||
conn.execute("UPDATE groups SET deleted_at=? WHERE id=?", (_now(), group_id))
|
conn.execute("UPDATE groups SET deleted_at=? WHERE id=?", (_now(), group_id))
|
||||||
|
|
||||||
|
|
||||||
@@ -410,7 +410,7 @@ def guard_target_account(actor: Any, target: dict[str, Any]) -> None:
|
|||||||
"""Ein Administratorkonto darf nur ein echter Administrator anfassen."""
|
"""Ein Administratorkonto darf nur ein echter Administrator anfassen."""
|
||||||
if bool(target.get("is_admin")) and not getattr(actor, "is_admin", False):
|
if bool(target.get("is_admin")) and not getattr(actor, "is_admin", False):
|
||||||
raise PermissionDenied(
|
raise PermissionDenied(
|
||||||
"Administratorkonten koennen nur von Administratoren bearbeitet werden."
|
"Administratorkonten können nur von Administratoren bearbeitet werden."
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -420,8 +420,8 @@ def guard_last_admin(conn: sqlite3.Connection, user_id: int, *, action: str) ->
|
|||||||
return
|
return
|
||||||
if count_active_admins(conn, exclude_id=user_id) == 0:
|
if count_active_admins(conn, exclude_id=user_id) == 0:
|
||||||
raise PermissionDenied(
|
raise PermissionDenied(
|
||||||
f"{action} nicht moeglich: es muss mindestens ein entsperrtes "
|
f"{action} nicht möglich: es muss mindestens ein entsperrtes "
|
||||||
"Administratorkonto uebrig bleiben."
|
"Administratorkonto übrig bleiben."
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -482,12 +482,12 @@ def create_user(
|
|||||||
if existing["deleted_at"]:
|
if existing["deleted_at"]:
|
||||||
raise ValidationError(
|
raise ValidationError(
|
||||||
f"Der Benutzername {clean!r} liegt im Papierkorb. Stellen Sie das Konto "
|
f"Der Benutzername {clean!r} liegt im Papierkorb. Stellen Sie das Konto "
|
||||||
"wieder her oder loeschen Sie es endgueltig."
|
"wieder her oder löschen Sie es endgültig."
|
||||||
)
|
)
|
||||||
raise ValidationError(f"Der Benutzername {clean!r} ist bereits vergeben.")
|
raise ValidationError(f"Der Benutzername {clean!r} ist bereits vergeben.")
|
||||||
|
|
||||||
if is_admin and not getattr(actor, "is_admin", False):
|
if is_admin and not getattr(actor, "is_admin", False):
|
||||||
raise PermissionDenied("Nur Administratoren koennen weitere Administratoren anlegen.")
|
raise PermissionDenied("Nur Administratoren können weitere Administratoren anlegen.")
|
||||||
|
|
||||||
if auth_source == "local":
|
if auth_source == "local":
|
||||||
problems = policy.check(password, username=clean)
|
problems = policy.check(password, username=clean)
|
||||||
@@ -552,24 +552,30 @@ def update_user(
|
|||||||
# ueberschrieben -- die Bearbeitung waere eine Illusion.
|
# ueberschrieben -- die Bearbeitung waere eine Illusion.
|
||||||
raise ValidationError(
|
raise ValidationError(
|
||||||
"Name und E-Mail eines Verzeichniskontos werden bei jeder Anmeldung "
|
"Name und E-Mail eines Verzeichniskontos werden bei jeder Anmeldung "
|
||||||
"aus dem Verzeichnis uebernommen und koennen hier nicht geaendert werden."
|
"aus dem Verzeichnis übernommen und können hier nicht geändert werden."
|
||||||
)
|
)
|
||||||
|
|
||||||
fields: dict[str, Any] = {}
|
fields: dict[str, Any] = {}
|
||||||
|
# Mit Werten, nicht nur "geaendert": aus dem Protokoll soll man den
|
||||||
|
# vorherigen Stand ablesen koennen, ohne eine Sicherung auszupacken.
|
||||||
|
beschriftungen = {"first_name": "Vorname", "last_name": "Nachname", "email": "E-Mail"}
|
||||||
for column, value in (
|
for column, value in (
|
||||||
("first_name", first_name),
|
("first_name", first_name),
|
||||||
("last_name", last_name),
|
("last_name", last_name),
|
||||||
("email", email),
|
("email", email),
|
||||||
):
|
):
|
||||||
if value is not None and value.strip() != str(target.get(column) or ""):
|
alt = str(target.get(column) or "")
|
||||||
|
if value is not None and value.strip() != alt:
|
||||||
fields[column] = value.strip()
|
fields[column] = value.strip()
|
||||||
changes.append(f"{column} geaendert")
|
changes.append(
|
||||||
|
f"{beschriftungen[column]}: {alt or '(leer)'} -> {value.strip() or '(leer)'}"
|
||||||
|
)
|
||||||
|
|
||||||
if is_admin is not None and bool(is_admin) != bool(target["is_admin"]):
|
if is_admin is not None and bool(is_admin) != bool(target["is_admin"]):
|
||||||
if not getattr(actor, "is_admin", False):
|
if not getattr(actor, "is_admin", False):
|
||||||
raise PermissionDenied("Das Admin-Kennzeichen kann nur ein Administrator aendern.")
|
raise PermissionDenied("Das Admin-Kennzeichen kann nur ein Administrator ändern.")
|
||||||
if int(target["id"]) == int(getattr(actor, "id", 0)):
|
if int(target["id"]) == int(getattr(actor, "id", 0)):
|
||||||
raise PermissionDenied("Sie koennen Ihr eigenes Admin-Kennzeichen nicht aendern.")
|
raise PermissionDenied("Sie können Ihr eigenes Admin-Kennzeichen nicht ändern.")
|
||||||
if not is_admin:
|
if not is_admin:
|
||||||
guard_last_admin(conn, user_id, action="Herabstufen")
|
guard_last_admin(conn, user_id, action="Herabstufen")
|
||||||
fields["is_admin"] = int(bool(is_admin))
|
fields["is_admin"] = int(bool(is_admin))
|
||||||
@@ -577,7 +583,7 @@ def update_user(
|
|||||||
|
|
||||||
if is_locked is not None and bool(is_locked) != bool(target["is_locked"]):
|
if is_locked is not None and bool(is_locked) != bool(target["is_locked"]):
|
||||||
if int(target["id"]) == int(getattr(actor, "id", 0)):
|
if int(target["id"]) == int(getattr(actor, "id", 0)):
|
||||||
raise PermissionDenied("Sie koennen Ihr eigenes Konto nicht sperren.")
|
raise PermissionDenied("Sie können Ihr eigenes Konto nicht sperren.")
|
||||||
if is_locked:
|
if is_locked:
|
||||||
guard_last_admin(conn, user_id, action="Sperren")
|
guard_last_admin(conn, user_id, action="Sperren")
|
||||||
fields["is_locked"] = int(bool(is_locked))
|
fields["is_locked"] = int(bool(is_locked))
|
||||||
@@ -595,7 +601,7 @@ def update_user(
|
|||||||
after = {int(gid) for gid in group_ids}
|
after = {int(gid) for gid in group_ids}
|
||||||
if before != after:
|
if before != after:
|
||||||
set_user_groups(conn, user_id, sorted(after), actor=actor, tree=tree)
|
set_user_groups(conn, user_id, sorted(after), actor=actor, tree=tree)
|
||||||
changes.append("Gruppenzuordnung geaendert")
|
changes.append("Gruppenzuordnung geändert")
|
||||||
|
|
||||||
return changes
|
return changes
|
||||||
|
|
||||||
@@ -613,7 +619,7 @@ def set_password(
|
|||||||
raise ValidationError("Dieses Konto existiert nicht.")
|
raise ValidationError("Dieses Konto existiert nicht.")
|
||||||
if str(row.get("auth_source")) == "ldap":
|
if str(row.get("auth_source")) == "ldap":
|
||||||
raise ValidationError(
|
raise ValidationError(
|
||||||
"Verzeichniskonten haben hier kein Passwort -- es wird im Verzeichnisdienst gepflegt."
|
"Verzeichniskonten haben hier kein Passwort – es wird im Verzeichnisdienst gepflegt."
|
||||||
)
|
)
|
||||||
problems = policy.check(new_password, username=str(row["username"]))
|
problems = policy.check(new_password, username=str(row["username"]))
|
||||||
if problems:
|
if problems:
|
||||||
@@ -631,7 +637,7 @@ def soft_delete_user(conn: sqlite3.Connection, user_id: int, *, actor: Any) -> s
|
|||||||
raise ValidationError("Dieses Konto existiert nicht (mehr).")
|
raise ValidationError("Dieses Konto existiert nicht (mehr).")
|
||||||
guard_target_account(actor, target)
|
guard_target_account(actor, target)
|
||||||
if int(target["id"]) == int(getattr(actor, "id", 0)):
|
if int(target["id"]) == int(getattr(actor, "id", 0)):
|
||||||
raise PermissionDenied("Sie koennen Ihr eigenes Konto nicht loeschen.")
|
raise PermissionDenied("Sie können Ihr eigenes Konto nicht löschen.")
|
||||||
guard_last_admin(conn, user_id, action="Loeschen")
|
guard_last_admin(conn, user_id, action="Loeschen")
|
||||||
|
|
||||||
if str(target.get("auth_source")) == "ldap":
|
if str(target.get("auth_source")) == "ldap":
|
||||||
|
|||||||
@@ -112,7 +112,7 @@ def protect() -> None:
|
|||||||
if not expected or not provided:
|
if not expected or not provided:
|
||||||
abort(403, description="Das Sicherheitstoken des Formulars fehlt. Bitte Seite neu laden.")
|
abort(403, description="Das Sicherheitstoken des Formulars fehlt. Bitte Seite neu laden.")
|
||||||
if not hmac.compare_digest(str(expected), str(provided)):
|
if not hmac.compare_digest(str(expected), str(provided)):
|
||||||
abort(403, description="Das Sicherheitstoken des Formulars ist ungueltig. Bitte Seite neu laden.")
|
abort(403, description="Das Sicherheitstoken des Formulars ist ungültig. Bitte Seite neu laden.")
|
||||||
|
|
||||||
|
|
||||||
def exempt(view):
|
def exempt(view):
|
||||||
|
|||||||
@@ -65,10 +65,10 @@ class PasswordPolicy:
|
|||||||
if classes < self.require_classes:
|
if classes < self.require_classes:
|
||||||
problems.append(
|
problems.append(
|
||||||
f"Mindestens {self.require_classes} verschiedene Zeichenarten "
|
f"Mindestens {self.require_classes} verschiedene Zeichenarten "
|
||||||
"(Klein-/Grossbuchstaben, Ziffern, Sonderzeichen)."
|
"(Klein-/Großbuchstaben, Ziffern, Sonderzeichen)."
|
||||||
)
|
)
|
||||||
if normalized.lower() in _FORBIDDEN:
|
if normalized.lower() in _FORBIDDEN:
|
||||||
problems.append("Dieses Passwort ist zu haeufig und damit unsicher.")
|
problems.append("Dieses Passwort ist zu häufig und damit unsicher.")
|
||||||
if username and len(username) >= 3 and username.lower() in normalized.lower():
|
if username and len(username) >= 3 and username.lower() in normalized.lower():
|
||||||
problems.append("Das Passwort darf den Benutzernamen nicht enthalten.")
|
problems.append("Das Passwort darf den Benutzernamen nicht enthalten.")
|
||||||
if len(set(normalized)) <= 3 and normalized:
|
if len(set(normalized)) <= 3 and normalized:
|
||||||
|
|||||||
@@ -163,6 +163,7 @@ class SqliteSessionInterface(SessionInterface):
|
|||||||
idle_minutes: int,
|
idle_minutes: int,
|
||||||
absolute_hours: int,
|
absolute_hours: int,
|
||||||
idle_lookup: Any = None,
|
idle_lookup: Any = None,
|
||||||
|
absolute_lookup: Any = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
self.database = database
|
self.database = database
|
||||||
self.cookie_name = cookie_name
|
self.cookie_name = cookie_name
|
||||||
@@ -172,6 +173,16 @@ class SqliteSessionInterface(SessionInterface):
|
|||||||
#: Zeit zur Laufzeit einstellbar, ohne den Dienst neu zu starten -- und
|
#: Zeit zur Laufzeit einstellbar, ohne den Dienst neu zu starten -- und
|
||||||
#: ohne eine zweite Datenbankverbindung, denn sie bekommt die offene.
|
#: ohne eine zweite Datenbankverbindung, denn sie bekommt die offene.
|
||||||
self._idle_lookup = idle_lookup
|
self._idle_lookup = idle_lookup
|
||||||
|
self._absolute_lookup = absolute_lookup
|
||||||
|
|
||||||
|
def _absolute(self, conn: Any) -> int:
|
||||||
|
"""Gesamtdauer in Stunden -- einstellbar wie die Leerlaufgrenze."""
|
||||||
|
if self._absolute_lookup is None:
|
||||||
|
return self.absolute_hours
|
||||||
|
try:
|
||||||
|
return int(self._absolute_lookup(conn)) or self.absolute_hours
|
||||||
|
except Exception: # pragma: no cover - Einstellung unlesbar
|
||||||
|
return self.absolute_hours
|
||||||
|
|
||||||
def _idle(self, conn: Any) -> int:
|
def _idle(self, conn: Any) -> int:
|
||||||
if self._idle_lookup is None:
|
if self._idle_lookup is None:
|
||||||
@@ -280,7 +291,10 @@ class SqliteSessionInterface(SessionInterface):
|
|||||||
payload = json.dumps(dict(session), separators=(",", ":"), sort_keys=True)
|
payload = json.dumps(dict(session), separators=(",", ":"), sort_keys=True)
|
||||||
with self.database.session() as conn, self.database.transaction(conn):
|
with self.database.session() as conn, self.database.transaction(conn):
|
||||||
if session.new or not session.sid:
|
if session.new or not session.sid:
|
||||||
session.sid = secrets.token_urlsafe(SESSION_ID_BYTES)
|
# Eine vorhandene ID nicht wegwerfen: nach einem Wechsel beim
|
||||||
|
# Anmelden steht sie schon, und andere haben sie sich gemerkt.
|
||||||
|
if not session.sid:
|
||||||
|
session.sid = secrets.token_urlsafe(SESSION_ID_BYTES)
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"INSERT INTO user_sessions "
|
"INSERT INTO user_sessions "
|
||||||
"(id, user_id, created_at, last_seen_at, idle_expires_at, "
|
"(id, user_id, created_at, last_seen_at, idle_expires_at, "
|
||||||
@@ -292,7 +306,7 @@ class SqliteSessionInterface(SessionInterface):
|
|||||||
_iso(now),
|
_iso(now),
|
||||||
_iso(now),
|
_iso(now),
|
||||||
_iso(now + timedelta(minutes=self._idle(conn))),
|
_iso(now + timedelta(minutes=self._idle(conn))),
|
||||||
_iso(now + timedelta(hours=self.absolute_hours)),
|
_iso(now + timedelta(hours=self._absolute(conn))),
|
||||||
session.get("_ip", ""),
|
session.get("_ip", ""),
|
||||||
session.get("_ua", ""),
|
session.get("_ua", ""),
|
||||||
payload,
|
payload,
|
||||||
@@ -371,7 +385,12 @@ def rotate(conn: sqlite3.Connection, session: ServerSession, *, reason: str = "p
|
|||||||
"UPDATE user_sessions SET revoked_at=?, revoked_reason=? WHERE id=?",
|
"UPDATE user_sessions SET revoked_at=?, revoked_reason=? WHERE id=?",
|
||||||
(_iso(_now()), reason, old_sid),
|
(_iso(_now()), reason, old_sid),
|
||||||
)
|
)
|
||||||
session.sid = ""
|
# Die neue ID entsteht **jetzt** und nicht erst beim Speichern der Antwort.
|
||||||
|
# Zwischen beidem laufen die Anmeldehaken, und einer davon bindet die
|
||||||
|
# Dateifreigaben ein und schreibt sich die Sitzung dazu. Mit einer leeren
|
||||||
|
# Sitzung hielt der Aufraeumer diese Einbindungen fuer verwaist und loeste
|
||||||
|
# sie binnen Minuten wieder.
|
||||||
|
session.sid = secrets.token_urlsafe(SESSION_ID_BYTES)
|
||||||
session.new = True
|
session.new = True
|
||||||
session.modified = True
|
session.modified = True
|
||||||
return old_sid
|
return old_sid
|
||||||
|
|||||||
@@ -67,6 +67,45 @@ class SettingSpec:
|
|||||||
return "" if value is None else str(value)
|
return "" if value is None else str(value)
|
||||||
|
|
||||||
|
|
||||||
|
#: So lang darf ein Wert im Protokoll werden -- danach wird gekuerzt. Ein
|
||||||
|
#: JSON-Feld mit der halben Navigationsreihenfolge macht die Zeile unlesbar.
|
||||||
|
WERT_GRENZE = 80
|
||||||
|
|
||||||
|
|
||||||
|
class Aenderung(str):
|
||||||
|
"""Eine geaenderte Einstellung, lesbar als ``schluessel: alt -> neu``.
|
||||||
|
|
||||||
|
Eine Zeichenkette und kein eigener Typ, weil die Aufrufer die Liste
|
||||||
|
weiterhin mit ``", ".join(...)`` in einen Protokolleintrag schreiben --
|
||||||
|
so gibt es keine Stelle, die man beim Umbau vergessen kann.
|
||||||
|
"""
|
||||||
|
|
||||||
|
key: str
|
||||||
|
alt: str
|
||||||
|
neu: str
|
||||||
|
|
||||||
|
def __new__(cls, key: str, alt: Any, neu: Any, *, geheim: bool = False) -> "Aenderung":
|
||||||
|
alt_text = "(gesetzt)" if geheim and alt else _kurz(alt)
|
||||||
|
neu_text = "(geaendert)" if geheim else _kurz(neu)
|
||||||
|
selbst = super().__new__(cls, f"{key}: {alt_text} -> {neu_text}")
|
||||||
|
selbst.key = key
|
||||||
|
selbst.alt = alt_text
|
||||||
|
selbst.neu = neu_text
|
||||||
|
return selbst
|
||||||
|
|
||||||
|
|
||||||
|
def _kurz(wert: Any) -> str:
|
||||||
|
"""Ein Wert als kurzer Text -- leer wird sichtbar, lang wird gekuerzt."""
|
||||||
|
if wert is None or wert == "":
|
||||||
|
return "(leer)"
|
||||||
|
if isinstance(wert, bool):
|
||||||
|
return "ein" if wert else "aus"
|
||||||
|
text = " ".join(str(wert).split())
|
||||||
|
if len(text) > WERT_GRENZE:
|
||||||
|
return text[: WERT_GRENZE - 1] + "…"
|
||||||
|
return text
|
||||||
|
|
||||||
|
|
||||||
class SettingsStore:
|
class SettingsStore:
|
||||||
"""Zugriff auf ``settings``. Alle Methoden nehmen die bestehende Verbindung."""
|
"""Zugriff auf ``settings``. Alle Methoden nehmen die bestehende Verbindung."""
|
||||||
|
|
||||||
@@ -140,16 +179,24 @@ class SettingsStore:
|
|||||||
|
|
||||||
def set_many(
|
def set_many(
|
||||||
self, conn: sqlite3.Connection, values: dict[str, Any], *, actor: str = ""
|
self, conn: sqlite3.Connection, values: dict[str, Any], *, actor: str = ""
|
||||||
) -> list[str]:
|
) -> list["Aenderung"]:
|
||||||
"""Setzt mehrere Werte, gibt die tatsaechlich geaenderten Schluessel zurueck."""
|
"""Setzt mehrere Werte und gibt die Aenderungen zurueck.
|
||||||
changed: list[str] = []
|
|
||||||
|
Jede Aenderung ist eine Zeichenkette der Form ``schluessel: alt -> neu``
|
||||||
|
-- und traegt die Einzelteile als Attribute. Damit steht im
|
||||||
|
Aenderungsprotokoll, was sich geaendert hat, ohne dass jede
|
||||||
|
Einstellungsseite es einzeln zusammenbauen muss. Vorher stand dort nur
|
||||||
|
der Schluessel, und die Frage "was war vorher eingestellt?" liess sich
|
||||||
|
aus dem Protokoll nicht beantworten.
|
||||||
|
"""
|
||||||
|
changed: list[Aenderung] = []
|
||||||
current = self.get_many(conn, list(values))
|
current = self.get_many(conn, list(values))
|
||||||
for key, value in values.items():
|
for key, value in values.items():
|
||||||
spec = self.spec(key)
|
spec = self.spec(key)
|
||||||
if spec.serialize(current.get(key)) == spec.serialize(value):
|
if spec.serialize(current.get(key)) == spec.serialize(value):
|
||||||
continue
|
continue
|
||||||
self.set(conn, key, value, actor=actor)
|
self.set(conn, key, value, actor=actor)
|
||||||
changed.append(key)
|
changed.append(Aenderung(key, current.get(key), value, geheim=spec.kind == "secret"))
|
||||||
return changed
|
return changed
|
||||||
|
|
||||||
def delete(self, conn: sqlite3.Connection, key: str) -> None:
|
def delete(self, conn: sqlite3.Connection, key: str) -> None:
|
||||||
|
|||||||
@@ -847,6 +847,25 @@
|
|||||||
flex: 1 1 12rem;
|
flex: 1 1 12rem;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
}
|
}
|
||||||
|
/* Die Entscheidung steht rechts und der Haken darueber: er gehoert zum
|
||||||
|
Speichern, nicht zu den Feldern davor. */
|
||||||
|
.viewer__saveaktionen {
|
||||||
|
margin-left: auto;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-end;
|
||||||
|
gap: var(--sp-2);
|
||||||
|
}
|
||||||
|
.viewer__saveknoepfe {
|
||||||
|
display: flex;
|
||||||
|
gap: var(--sp-2);
|
||||||
|
}
|
||||||
|
/* Nach "gibt es schon": der Haken ist die Antwort -- also faellt er auf. */
|
||||||
|
.viewer__saveaktionen .check.is-gefragt {
|
||||||
|
outline: 2px solid var(--accent);
|
||||||
|
outline-offset: 3px;
|
||||||
|
border-radius: var(--r-xs);
|
||||||
|
}
|
||||||
/* Eigener Name: ".field--inline" gibt es schon und steht dort fuer
|
/* Eigener Name: ".field--inline" gibt es schon und steht dort fuer
|
||||||
"waagerecht" -- diese Regel haette sie app-weit umgestellt. */
|
"waagerecht" -- diese Regel haette sie app-weit umgestellt. */
|
||||||
.viewer__feld {
|
.viewer__feld {
|
||||||
@@ -1315,3 +1334,327 @@
|
|||||||
.hide-sm { display: none !important; }
|
.hide-sm { display: none !important; }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* -- Sortierbare Liste (Navigationsreihenfolge) --------------------------- */
|
||||||
|
/* Symbol und Beschriftung stehen in einer Zeile -- so wie im Menue selbst.
|
||||||
|
Vorher brach die Beschriftung unter das Symbol, und die Liste sah aus wie
|
||||||
|
etwas anderes als das, was sie sortiert. */
|
||||||
|
|
||||||
|
.reorder {
|
||||||
|
list-style: none;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reorder__row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 6px 10px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--r-sm);
|
||||||
|
background: var(--surface-2);
|
||||||
|
cursor: grab;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reorder__row > .flex-1 {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reorder__row.is-dragging {
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reorder__grip {
|
||||||
|
color: var(--text-muted);
|
||||||
|
display: inline-flex;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reorder__pos {
|
||||||
|
min-width: 1.6em;
|
||||||
|
text-align: right;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: var(--fs-xs);
|
||||||
|
}
|
||||||
|
|
||||||
|
.reorder__moves {
|
||||||
|
display: inline-flex;
|
||||||
|
gap: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reorder__group {
|
||||||
|
list-style: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reorder--child {
|
||||||
|
margin: 4px 0 4px 28px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reorder--child .reorder__row {
|
||||||
|
background: transparent;
|
||||||
|
border-style: dashed;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* -- Unterschrift --------------------------------------------------------- */
|
||||||
|
/* Die Zeichenflaeche braucht "touch-action: none": sonst scrollt ein iPad die
|
||||||
|
Seite, statt einen Strich zu zeichnen -- und es sieht aus, als reagierte das
|
||||||
|
Feld nicht. */
|
||||||
|
|
||||||
|
.viewer__sign {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
background: var(--surface-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.viewer__signfelder {
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.viewer__signpad {
|
||||||
|
width: 100%;
|
||||||
|
height: 160px;
|
||||||
|
border: 1px dashed var(--border-strong, var(--border));
|
||||||
|
border-radius: var(--r-sm);
|
||||||
|
background: #fff;
|
||||||
|
touch-action: none;
|
||||||
|
cursor: crosshair;
|
||||||
|
}
|
||||||
|
|
||||||
|
.viewer__signaktionen {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
justify-content: flex-end;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.viewer__pdfscroll--setzen {
|
||||||
|
cursor: crosshair;
|
||||||
|
}
|
||||||
|
|
||||||
|
.viewer__pdfscroll--setzen .page {
|
||||||
|
outline: 2px dashed var(--accent, #c8791f);
|
||||||
|
outline-offset: -2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Die gesetzte Unterschrift liegt auf der Seite -- in Prozent, damit sie beim
|
||||||
|
Zoomen an ihrer Stelle bleibt. Sie nimmt Zeiger an: verschieben mit dem
|
||||||
|
Block, Groesse mit dem Griff unten rechts, entfernen mit dem Kreuz.
|
||||||
|
"touch-action: none" ist auf dem iPad die Bedingung dafuer, dass ein Finger
|
||||||
|
zieht statt die Seite zu scrollen. */
|
||||||
|
.viewer__unterschrift {
|
||||||
|
position: absolute;
|
||||||
|
z-index: 3;
|
||||||
|
cursor: grab;
|
||||||
|
touch-action: none;
|
||||||
|
container-type: inline-size;
|
||||||
|
border: 1px dashed transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.viewer__unterschrift:hover,
|
||||||
|
.viewer__unterschrift.is-fassend {
|
||||||
|
border-color: var(--accent);
|
||||||
|
cursor: grabbing;
|
||||||
|
}
|
||||||
|
|
||||||
|
.viewer__unterschrift img {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Dieselbe Rechnung wie im PDF (Breite durch 18): auf dem Schirm steht, was
|
||||||
|
spaeter in der Datei steht. */
|
||||||
|
/* "width: max-content": der Strich ist so lang wie der Text darunter -- so
|
||||||
|
wie im PDF. Waere er so breit wie der Block, zeigte die Vorschau etwas
|
||||||
|
anderes als die Datei. */
|
||||||
|
.viewer__unterschrifttext {
|
||||||
|
/* Helvetica: dieselbe Schrift, die das PDF benutzt (StandardFonts.Helvetica).
|
||||||
|
Mit der Hausschrift sah die Vorschau anders aus als das Ergebnis. Die
|
||||||
|
Groesse setzt das Skript je Unterschrift -- hier steht nur ein Rueckfall. */
|
||||||
|
font-family: Helvetica, Arial, sans-serif;
|
||||||
|
font-size: 5.6cqw;
|
||||||
|
line-height: 1.15;
|
||||||
|
color: #111;
|
||||||
|
border-top: 1px solid rgba(0, 0, 0, 0.85);
|
||||||
|
padding-top: 1px;
|
||||||
|
pointer-events: none;
|
||||||
|
white-space: nowrap;
|
||||||
|
width: max-content;
|
||||||
|
}
|
||||||
|
|
||||||
|
.viewer__unterschriftgriff,
|
||||||
|
.viewer__unterschriftweg {
|
||||||
|
position: absolute;
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
padding: 0;
|
||||||
|
border: 1px solid var(--accent);
|
||||||
|
border-radius: 3px;
|
||||||
|
background: var(--surface-1);
|
||||||
|
color: var(--accent);
|
||||||
|
font-size: 11px;
|
||||||
|
line-height: 1;
|
||||||
|
opacity: 0;
|
||||||
|
cursor: nwse-resize;
|
||||||
|
}
|
||||||
|
|
||||||
|
.viewer__unterschriftweg {
|
||||||
|
cursor: pointer;
|
||||||
|
top: -8px;
|
||||||
|
right: -8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.viewer__unterschriftgriff {
|
||||||
|
right: -8px;
|
||||||
|
bottom: -8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.viewer__unterschrift:hover .viewer__unterschriftgriff,
|
||||||
|
.viewer__unterschrift:hover .viewer__unterschriftweg,
|
||||||
|
.viewer__unterschrift.is-fassend .viewer__unterschriftgriff {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Auf einem Tastbildschirm gibt es kein "hover" -- dort stehen die Knoepfe
|
||||||
|
immer, sonst kaeme man nie an sie heran. */
|
||||||
|
@media (hover: none) {
|
||||||
|
.viewer__unterschriftgriff,
|
||||||
|
.viewer__unterschriftweg {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* -- Ergebnis der Signaturpruefung ---------------------------------------- */
|
||||||
|
/* Der Schleier liegt ueber der Seite und nimmt keine Klicks an: ein
|
||||||
|
unterschriebenes Dokument ist flach, aber bei einem Irrtum soll die
|
||||||
|
Kennzeichnung nicht auch noch die Bedienung blockieren. */
|
||||||
|
|
||||||
|
.viewer__pdf--unveraendert {
|
||||||
|
box-shadow: inset 0 0 0 3px var(--success);
|
||||||
|
}
|
||||||
|
|
||||||
|
.viewer__pdf--unveraendert::after,
|
||||||
|
.viewer__pdf--veraendert::after {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
z-index: 4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.viewer__pdf--unveraendert::after {
|
||||||
|
background: rgba(47, 158, 68, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.viewer__pdf--veraendert {
|
||||||
|
box-shadow: inset 0 0 0 3px var(--danger);
|
||||||
|
}
|
||||||
|
|
||||||
|
.viewer__pdf--veraendert::after {
|
||||||
|
background: rgba(201, 42, 42, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.viewer__pruefung {
|
||||||
|
margin: 0;
|
||||||
|
padding: 6px 12px;
|
||||||
|
font-size: var(--fs-xs);
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.viewer__pruefung--gut {
|
||||||
|
color: var(--success);
|
||||||
|
background: rgba(47, 158, 68, 0.12);
|
||||||
|
}
|
||||||
|
|
||||||
|
.viewer__pruefung--schlecht {
|
||||||
|
color: var(--danger);
|
||||||
|
background: rgba(201, 42, 42, 0.12);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* -- Tabelle, die schmal zu Bloecken wird -------------------------------- */
|
||||||
|
/* Eine Protokollzeile besteht aus sieben Angaben, von denen eine lang ist:
|
||||||
|
das Detail. Auf einem breiten Schirm passt das nebeneinander. Wird es eng,
|
||||||
|
ist eine Spalte von zwei Zentimetern fuer eine Pruefsumme keine Loesung --
|
||||||
|
dann steht jede Angabe auf ihrer eigenen Zeile, beschriftet. */
|
||||||
|
|
||||||
|
.table--stapelbar .td--detail {
|
||||||
|
/* Zeilenumbrueche aus dem Eintrag bleiben stehen: die Pruefsumme steht
|
||||||
|
unter dem lesbaren Teil, nicht mitten darin. Und ein Hash ohne Trennstelle
|
||||||
|
darf notfalls hart umbrechen. */
|
||||||
|
white-space: pre-line;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
max-width: 52ch;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 1100px) {
|
||||||
|
.table--stapelbar thead {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table--stapelbar,
|
||||||
|
.table--stapelbar tbody,
|
||||||
|
.table--stapelbar tr,
|
||||||
|
.table--stapelbar td {
|
||||||
|
display: block;
|
||||||
|
width: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table--stapelbar tr {
|
||||||
|
padding: var(--sp-3) 0;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.table--stapelbar td {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 8.5rem 1fr;
|
||||||
|
gap: var(--sp-2);
|
||||||
|
align-items: baseline;
|
||||||
|
border: 0;
|
||||||
|
padding: 2px var(--sp-3);
|
||||||
|
white-space: normal;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table--stapelbar td::before {
|
||||||
|
content: attr(data-spalte);
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: var(--fs-xs);
|
||||||
|
}
|
||||||
|
|
||||||
|
.table--stapelbar .td--detail {
|
||||||
|
max-width: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Leere Zellen brauchen keine Zeile. */
|
||||||
|
.table--stapelbar td:empty {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* -- Dateiliste: Symbol und Name in einer Zeile -------------------------- */
|
||||||
|
/* Das Symbol ist ein Block-SVG; ohne diese Regel stand der Name darunter und
|
||||||
|
eine Ordnerzeile sah doppelt so hoch aus wie eine Dateizeile. */
|
||||||
|
|
||||||
|
.file-cell {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-cell > svg {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
}
|
||||||
@@ -109,7 +109,7 @@ export function showToast(message, type = "info", { timeout = 6000 } = {}) {
|
|||||||
const close = document.createElement("button");
|
const close = document.createElement("button");
|
||||||
close.type = "button";
|
close.type = "button";
|
||||||
close.className = "toast__close";
|
close.className = "toast__close";
|
||||||
close.setAttribute("aria-label", "Meldung schliessen");
|
close.setAttribute("aria-label", "Meldung schließen");
|
||||||
close.textContent = "×";
|
close.textContent = "×";
|
||||||
toast.append(body, close);
|
toast.append(body, close);
|
||||||
toastStack.appendChild(toast);
|
toastStack.appendChild(toast);
|
||||||
@@ -293,7 +293,7 @@ function initConfirmations() {
|
|||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
event.stopPropagation();
|
event.stopPropagation();
|
||||||
pending = trigger;
|
pending = trigger;
|
||||||
titleNode.textContent = trigger.dataset.confirmTitle || "Bitte bestaetigen";
|
titleNode.textContent = trigger.dataset.confirmTitle || "Bitte bestätigen";
|
||||||
textNode.textContent = trigger.dataset.confirm;
|
textNode.textContent = trigger.dataset.confirm;
|
||||||
okButton.textContent = trigger.dataset.confirmOk || "Fortfahren";
|
okButton.textContent = trigger.dataset.confirmOk || "Fortfahren";
|
||||||
okButton.className = `btn btn--${trigger.dataset.confirmStyle || "danger"}`;
|
okButton.className = `btn btn--${trigger.dataset.confirmStyle || "danger"}`;
|
||||||
@@ -497,14 +497,14 @@ function initAutoRefresh() {
|
|||||||
nameField.value = entry.name || "";
|
nameField.value = entry.name || "";
|
||||||
dnField.value = entry.dn || "";
|
dnField.value = entry.dn || "";
|
||||||
results.hidden = true;
|
results.hidden = true;
|
||||||
show(`Uebernommen: ${entry.name || entry.dn}`);
|
show(`Übernommen: ${entry.name || entry.dn}`);
|
||||||
};
|
};
|
||||||
|
|
||||||
const search = async () => {
|
const search = async () => {
|
||||||
const term = query.value.trim();
|
const term = query.value.trim();
|
||||||
if (term.length < 2) { show("Bitte mindestens zwei Zeichen eingeben."); return; }
|
if (term.length < 2) { show("Bitte mindestens zwei Zeichen eingeben."); return; }
|
||||||
button.disabled = true;
|
button.disabled = true;
|
||||||
show("Suche laeuft ...");
|
show("Suche läuft ...");
|
||||||
results.innerHTML = "";
|
results.innerHTML = "";
|
||||||
try {
|
try {
|
||||||
const url = `${picker.dataset.searchUrl}?q=${encodeURIComponent(term)}`;
|
const url = `${picker.dataset.searchUrl}?q=${encodeURIComponent(term)}`;
|
||||||
@@ -532,10 +532,10 @@ function initAutoRefresh() {
|
|||||||
results.appendChild(row);
|
results.appendChild(row);
|
||||||
});
|
});
|
||||||
results.hidden = false;
|
results.hidden = false;
|
||||||
show(`${entries.length} Treffer -- zum Uebernehmen anklicken.`);
|
show(`${entries.length} Treffer – zum Übernehmen anklicken.`);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
results.hidden = true;
|
results.hidden = true;
|
||||||
show("Die Suche ist fehlgeschlagen. Laeuft die Verbindung zum Verzeichnis?");
|
show("Die Suche ist fehlgeschlagen. Läuft die Verbindung zum Verzeichnis?");
|
||||||
} finally {
|
} finally {
|
||||||
button.disabled = false;
|
button.disabled = false;
|
||||||
}
|
}
|
||||||
@@ -720,7 +720,7 @@ function initClipboard() {
|
|||||||
timeout: 2500,
|
timeout: 2500,
|
||||||
});
|
});
|
||||||
} catch {
|
} catch {
|
||||||
showToast("Kopieren nicht moeglich -- bitte manuell markieren.", "warning");
|
showToast("Kopieren nicht möglich – bitte manuell markieren.", "warning");
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -856,6 +856,81 @@ function initTileFilters() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* -- Reihenfolge aendern -------------------------------------------------- */
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Eine Liste, deren Reihenfolge man aendern kann -- ohne Bibliothek.
|
||||||
|
*
|
||||||
|
* Die Reihenfolge steckt in der Reihenfolge der versteckten Felder: das
|
||||||
|
* Formular schickt sie als wiederholtes Feld, und der Server liest sie als
|
||||||
|
* Liste. Verschiebt man einen Eintrag, verschiebt sich das Feld mit ihm.
|
||||||
|
*
|
||||||
|
* Zwei Wege, weil es zwei Arten von Geraeten gibt: Ziehen mit der Maus und
|
||||||
|
* zwei Knoepfe je Zeile. Die Knoepfe sind nicht die Notloesung -- auf einem
|
||||||
|
* Tablet sind sie der einzige Weg, der zuverlaessig funktioniert, und mit der
|
||||||
|
* Tastatur ebenso.
|
||||||
|
*/
|
||||||
|
function initReorder() {
|
||||||
|
$$('[data-behavior="reorder"]').forEach((liste) => {
|
||||||
|
// Nur die eigenen Zeilen: Gruppen enthalten wiederum eine sortierbare
|
||||||
|
// Liste, und deren Zeilen gehoeren der inneren Liste, nicht dieser.
|
||||||
|
const zeilen = () => Array.from(liste.querySelectorAll(":scope > [data-reorder-item]"));
|
||||||
|
|
||||||
|
const nummerieren = () => {
|
||||||
|
zeilen().forEach((zeile, index) => {
|
||||||
|
const nummer = zeile.querySelector("[data-reorder-position-text]");
|
||||||
|
if (nummer) nummer.textContent = String(index + 1);
|
||||||
|
const hoch = zeile.querySelector('[data-reorder-move="up"]');
|
||||||
|
const runter = zeile.querySelector('[data-reorder-move="down"]');
|
||||||
|
if (hoch) hoch.disabled = index === 0;
|
||||||
|
if (runter) runter.disabled = index === zeilen().length - 1;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
liste.addEventListener("click", (event) => {
|
||||||
|
const knopf = event.target.closest("[data-reorder-move]");
|
||||||
|
if (!knopf) return;
|
||||||
|
const zeile = knopf.closest("[data-reorder-item]");
|
||||||
|
if (!zeile || zeile.parentElement !== liste) return;
|
||||||
|
event.preventDefault();
|
||||||
|
if (knopf.dataset.reorderMove === "up") {
|
||||||
|
const davor = zeile.previousElementSibling;
|
||||||
|
if (davor) liste.insertBefore(zeile, davor);
|
||||||
|
} else {
|
||||||
|
const danach = zeile.nextElementSibling;
|
||||||
|
if (danach) liste.insertBefore(danach, zeile);
|
||||||
|
}
|
||||||
|
nummerieren();
|
||||||
|
zeile.querySelector(`[data-reorder-move="${knopf.dataset.reorderMove}"]`)?.focus();
|
||||||
|
});
|
||||||
|
|
||||||
|
let gezogen = null;
|
||||||
|
liste.addEventListener("dragstart", (event) => {
|
||||||
|
const zeile = event.target.closest("[data-reorder-item]");
|
||||||
|
if (!zeile || zeile.parentElement !== liste) return;
|
||||||
|
gezogen = zeile;
|
||||||
|
gezogen.classList.add("is-dragging");
|
||||||
|
if (event.dataTransfer) event.dataTransfer.effectAllowed = "move";
|
||||||
|
});
|
||||||
|
liste.addEventListener("dragend", () => {
|
||||||
|
if (gezogen) gezogen.classList.remove("is-dragging");
|
||||||
|
gezogen = null;
|
||||||
|
nummerieren();
|
||||||
|
});
|
||||||
|
liste.addEventListener("dragover", (event) => {
|
||||||
|
if (!gezogen) return;
|
||||||
|
event.preventDefault();
|
||||||
|
const ziel = event.target.closest("[data-reorder-item]");
|
||||||
|
if (!ziel || ziel === gezogen || ziel.parentElement !== liste) return;
|
||||||
|
const kasten = ziel.getBoundingClientRect();
|
||||||
|
const obereHaelfte = event.clientY < kasten.top + kasten.height / 2;
|
||||||
|
liste.insertBefore(gezogen, obereHaelfte ? ziel : ziel.nextElementSibling);
|
||||||
|
});
|
||||||
|
|
||||||
|
nummerieren();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/* -- Start ---------------------------------------------------------------- */
|
/* -- Start ---------------------------------------------------------------- */
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -893,6 +968,7 @@ function boot() {
|
|||||||
initPalette();
|
initPalette();
|
||||||
initSubmitGuards();
|
initSubmitGuards();
|
||||||
initTileFilters();
|
initTileFilters();
|
||||||
|
initReorder();
|
||||||
initLogTails();
|
initLogTails();
|
||||||
unsavedGuard.init();
|
unsavedGuard.init();
|
||||||
initViewer();
|
initViewer();
|
||||||
|
|||||||
@@ -169,7 +169,7 @@ async function fotoAlsJpeg(datei) {
|
|||||||
const daten = await new Promise((fertig, schiefgegangen) => {
|
const daten = await new Promise((fertig, schiefgegangen) => {
|
||||||
const leser = new FileReader();
|
const leser = new FileReader();
|
||||||
leser.onload = () => fertig(String(leser.result));
|
leser.onload = () => fertig(String(leser.result));
|
||||||
leser.onerror = () => schiefgegangen(new Error("Die Datei liess sich nicht lesen."));
|
leser.onerror = () => schiefgegangen(new Error("Die Datei ließ sich nicht lesen."));
|
||||||
leser.readAsDataURL(datei);
|
leser.readAsDataURL(datei);
|
||||||
});
|
});
|
||||||
bild = await new Promise((fertig, schiefgegangen) => {
|
bild = await new Promise((fertig, schiefgegangen) => {
|
||||||
@@ -207,7 +207,7 @@ async function pdflibLaden(adresse) {
|
|||||||
skript.onload = () => fertig(window.PDFLib);
|
skript.onload = () => fertig(window.PDFLib);
|
||||||
skript.onerror = () => {
|
skript.onerror = () => {
|
||||||
pdflibGeladen = null;
|
pdflibGeladen = null;
|
||||||
schiefgegangen(new Error("pdf-lib liess sich nicht laden."));
|
schiefgegangen(new Error("pdf-lib ließ sich nicht laden."));
|
||||||
};
|
};
|
||||||
document.head.append(skript);
|
document.head.append(skript);
|
||||||
});
|
});
|
||||||
@@ -228,7 +228,12 @@ async function pdflibLaden(adresse) {
|
|||||||
* ``abschliessen`` rechnet danach alle Felder flach in die Seite: heraus kommt
|
* ``abschliessen`` rechnet danach alle Felder flach in die Seite: heraus kommt
|
||||||
* ein PDF ohne Formular -- nichts mehr zu verstellen.
|
* ein PDF ohne Formular -- nichts mehr zu verstellen.
|
||||||
*/
|
*/
|
||||||
async function nachbearbeiten(bytes, bilder, adresse, { abschliessen = false } = {}) {
|
async function nachbearbeiten(
|
||||||
|
bytes,
|
||||||
|
bilder,
|
||||||
|
adresse,
|
||||||
|
{ abschliessen = false, unterschriften = [], zeuge = "" } = {}
|
||||||
|
) {
|
||||||
const PDFLib = await pdflibLaden(adresse);
|
const PDFLib = await pdflibLaden(adresse);
|
||||||
const dokument = await PDFLib.PDFDocument.load(bytes);
|
const dokument = await PDFLib.PDFDocument.load(bytes);
|
||||||
const formular = dokument.getForm();
|
const formular = dokument.getForm();
|
||||||
@@ -258,11 +263,77 @@ async function nachbearbeiten(bytes, bilder, adresse, { abschliessen = false } =
|
|||||||
try {
|
try {
|
||||||
formular.removeField(knopf);
|
formular.removeField(knopf);
|
||||||
} catch (fehler) {
|
} catch (fehler) {
|
||||||
console.warn("Bildknopf liess sich nicht entfernen", fehler);
|
console.warn("Bildknopf ließ sich nicht entfernen", fehler);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Unterschriften: Bild und darunter der angegebene Name mit Zeitpunkt. Das
|
||||||
|
// Bild allein waere eine Zeichnung ohne Aussage -- auf Papier steht neben
|
||||||
|
// der Unterschrift auch der Name in Druckbuchstaben.
|
||||||
|
if (unterschriften.length) {
|
||||||
|
const schrift = await dokument.embedFont(PDFLib.StandardFonts.Helvetica);
|
||||||
|
for (const eintrag of unterschriften) {
|
||||||
|
const seite = dokument.getPage(eintrag.seite - 1);
|
||||||
|
const png = await dokument.embedPng(eintrag.bytes);
|
||||||
|
const breite = eintrag.breite;
|
||||||
|
const hoehe = (png.height / png.width) * breite;
|
||||||
|
seite.drawImage(png, { x: eintrag.x, y: eintrag.y, width: breite, height: hoehe });
|
||||||
|
const zeilen = [`${eintrag.name} | ${eintrag.zeit}`];
|
||||||
|
// Der Zeugenvermerk gehoert zum Dokument, nicht zu jeder Unterschrift:
|
||||||
|
// unter drei Unterschriften stand dreimal derselbe Satz. Sichtbar
|
||||||
|
// erscheint er deshalb nur unter der ersten -- fest steht er ohnehin in
|
||||||
|
// den Dokumenteigenschaften und im Aenderungsprotokoll.
|
||||||
|
if (zeuge && eintrag === unterschriften[0]) {
|
||||||
|
zeilen.push(`In TESM abgelegt durch ${zeuge}`);
|
||||||
|
}
|
||||||
|
// Die Schrift waechst mit der Unterschrift: wer sie kleiner zieht, will
|
||||||
|
// den ganzen Block kleiner haben, nicht eine Zeichnung mit einer
|
||||||
|
// Bildunterschrift in Originalgroesse daneben.
|
||||||
|
const groesse = schriftgroesse(breite);
|
||||||
|
// Der Strich ist so lang wie die laengste Zeile darunter -- nicht so
|
||||||
|
// breit wie die Zeichnung. Eine kurze Unterschrift ueber einem langen
|
||||||
|
// Namen sah sonst aus wie zwei Dinge, die nicht zusammengehoeren.
|
||||||
|
const textbreite = Math.max(
|
||||||
|
...zeilen.map((zeile) => schrift.widthOfTextAtSize(zeile, groesse))
|
||||||
|
);
|
||||||
|
let unten = eintrag.y - 2;
|
||||||
|
for (const zeile of zeilen) {
|
||||||
|
unten -= groesse * 1.15;
|
||||||
|
seite.drawText(zeile, {
|
||||||
|
x: eintrag.x,
|
||||||
|
y: unten,
|
||||||
|
size: groesse,
|
||||||
|
font: schrift,
|
||||||
|
color: PDFLib.rgb(0.1, 0.1, 0.1),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
// Kraeftig genug, um neben den Linien eines Formulars als eigener Strich
|
||||||
|
// erkennbar zu bleiben: ein halber Punkt in hellem Grau ging zwischen
|
||||||
|
// den Tabellenlinien schlicht unter.
|
||||||
|
seite.drawLine({
|
||||||
|
start: { x: eintrag.x, y: eintrag.y - 1.5 },
|
||||||
|
end: { x: eintrag.x + textbreite, y: eintrag.y - 1.5 },
|
||||||
|
thickness: 0.9,
|
||||||
|
color: PDFLib.rgb(0.15, 0.15, 0.15),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (unterschriften.length) {
|
||||||
|
// In den Dokumenteigenschaften -- dort sucht man so etwas, und dort steht
|
||||||
|
// es einmal statt unter jeder Unterschrift. Jeder Betrachter zeigt das
|
||||||
|
// an, auch ausserhalb von TESM.
|
||||||
|
const namen = unterschriften.map((eintrag) => eintrag.name);
|
||||||
|
const wann = unterschriften[unterschriften.length - 1].zeit;
|
||||||
|
dokument.setAuthor(namen.join(", "));
|
||||||
|
dokument.setSubject(
|
||||||
|
`Unterschrieben am ${wann}` + (zeuge ? ` | in TESM abgelegt durch ${zeuge}` : "")
|
||||||
|
);
|
||||||
|
dokument.setProducer("TESM");
|
||||||
|
dokument.setModificationDate(new Date());
|
||||||
|
}
|
||||||
|
|
||||||
if (abschliessen) {
|
if (abschliessen) {
|
||||||
// Alle Felder werden Teil der Seite. Danach gibt es kein Formular mehr --
|
// Alle Felder werden Teil der Seite. Danach gibt es kein Formular mehr --
|
||||||
// gewollt: so kann niemand mehr etwas verstellen.
|
// gewollt: so kann niemand mehr etwas verstellen.
|
||||||
@@ -271,6 +342,112 @@ async function nachbearbeiten(bytes, bilder, adresse, { abschliessen = false } =
|
|||||||
return dokument.save();
|
return dokument.save();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Schriftgroesse des Namenszugs unter einer Unterschrift dieser Breite.
|
||||||
|
*
|
||||||
|
* Ein Achtzehntel: bei den 150 Punkt der vollen Breite sind das gut acht
|
||||||
|
* Punkt -- die uebliche Groesse einer Unterschriftszeile auf Papier. Dieselbe
|
||||||
|
* Rechnung benutzt die Vorschau (als ``cqw``), damit auf dem Schirm steht,
|
||||||
|
* was spaeter im PDF steht.
|
||||||
|
*/
|
||||||
|
function schriftgroesse(breite) {
|
||||||
|
return Math.max(5, Math.min(10, breite / 18));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Die gezeichnete Unterschrift als PNG -- auf das Gezeichnete beschnitten.
|
||||||
|
*
|
||||||
|
* Ungeschnitten waere jede Unterschrift so gross wie die Zeichenflaeche, mit
|
||||||
|
* viel Luft ringsum. Im Dokument sieht das aus wie ein verrutschter Stempel.
|
||||||
|
*/
|
||||||
|
function unterschriftAlsPng(leinwand, kasten) {
|
||||||
|
const rand = 6;
|
||||||
|
const links = Math.max(0, Math.floor(kasten.x1 - rand));
|
||||||
|
const oben = Math.max(0, Math.floor(kasten.y1 - rand));
|
||||||
|
const breite = Math.min(leinwand.width - links, Math.ceil(kasten.x2 - kasten.x1 + rand * 2));
|
||||||
|
const hoehe = Math.min(leinwand.height - oben, Math.ceil(kasten.y2 - kasten.y1 + rand * 2));
|
||||||
|
const ziel = document.createElement("canvas");
|
||||||
|
ziel.width = Math.max(1, breite);
|
||||||
|
ziel.height = Math.max(1, hoehe);
|
||||||
|
ziel
|
||||||
|
.getContext("2d")
|
||||||
|
.drawImage(leinwand, links, oben, ziel.width, ziel.height, 0, 0, ziel.width, ziel.height);
|
||||||
|
// Ueber eine data:-Adresse und nicht ueber blob: -- die Sicherheitsregeln
|
||||||
|
// der Anwendung erlauben blob: nicht, und ein stilles "img-src" waere ein
|
||||||
|
// Loch, das man fuer eine Unterschrift nicht aufmacht.
|
||||||
|
const adresse = ziel.toDataURL("image/png");
|
||||||
|
const roh = atob(adresse.slice(adresse.indexOf(",") + 1));
|
||||||
|
const bytes = new Uint8Array(roh.length);
|
||||||
|
for (let i = 0; i < roh.length; i++) bytes[i] = roh.charCodeAt(i);
|
||||||
|
return { bytes, vorschau: adresse, breite: ziel.width, hoehe: ziel.height };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Zeichenflaeche fuer die Unterschrift -- Finger, Stift oder Maus.
|
||||||
|
*
|
||||||
|
* ``touch-action: none`` steht im Stylesheet: ohne das scrollt ein iPad die
|
||||||
|
* Seite, statt einen Strich zu zeichnen.
|
||||||
|
*/
|
||||||
|
function unterschriftPad(leinwand) {
|
||||||
|
const stift = leinwand.getContext("2d");
|
||||||
|
let kasten = null;
|
||||||
|
let zeichnet = false;
|
||||||
|
|
||||||
|
const punkt = (event) => {
|
||||||
|
const rechteck = leinwand.getBoundingClientRect();
|
||||||
|
return {
|
||||||
|
x: ((event.clientX - rechteck.left) / rechteck.width) * leinwand.width,
|
||||||
|
y: ((event.clientY - rechteck.top) / rechteck.height) * leinwand.height,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const merken = ({ x, y }) => {
|
||||||
|
kasten = kasten
|
||||||
|
? {
|
||||||
|
x1: Math.min(kasten.x1, x),
|
||||||
|
y1: Math.min(kasten.y1, y),
|
||||||
|
x2: Math.max(kasten.x2, x),
|
||||||
|
y2: Math.max(kasten.y2, y),
|
||||||
|
}
|
||||||
|
: { x1: x, y1: y, x2: x, y2: y };
|
||||||
|
};
|
||||||
|
|
||||||
|
stift.lineWidth = 2.5;
|
||||||
|
stift.lineCap = "round";
|
||||||
|
stift.lineJoin = "round";
|
||||||
|
stift.strokeStyle = "#111";
|
||||||
|
|
||||||
|
leinwand.addEventListener("pointerdown", (event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
leinwand.setPointerCapture(event.pointerId);
|
||||||
|
zeichnet = true;
|
||||||
|
const p = punkt(event);
|
||||||
|
merken(p);
|
||||||
|
stift.beginPath();
|
||||||
|
stift.moveTo(p.x, p.y);
|
||||||
|
});
|
||||||
|
leinwand.addEventListener("pointermove", (event) => {
|
||||||
|
if (!zeichnet) return;
|
||||||
|
event.preventDefault();
|
||||||
|
const p = punkt(event);
|
||||||
|
merken(p);
|
||||||
|
stift.lineTo(p.x, p.y);
|
||||||
|
stift.stroke();
|
||||||
|
});
|
||||||
|
const beenden = () => {
|
||||||
|
zeichnet = false;
|
||||||
|
};
|
||||||
|
leinwand.addEventListener("pointerup", beenden);
|
||||||
|
leinwand.addEventListener("pointercancel", beenden);
|
||||||
|
leinwand.addEventListener("pointerleave", beenden);
|
||||||
|
|
||||||
|
return {
|
||||||
|
leeren: () => {
|
||||||
|
stift.clearRect(0, 0, leinwand.width, leinwand.height);
|
||||||
|
kasten = null;
|
||||||
|
},
|
||||||
|
leer: () => kasten === null,
|
||||||
|
alsPng: () => (kasten ? unterschriftAlsPng(leinwand, kasten) : null),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
/* -- PDF ------------------------------------------------------------------- */
|
/* -- PDF ------------------------------------------------------------------- */
|
||||||
|
|
||||||
let pdfjsGeladen = null;
|
let pdfjsGeladen = null;
|
||||||
@@ -319,8 +496,13 @@ async function pdfjsLaden(basis) {
|
|||||||
return pdfjsGeladen;
|
return pdfjsGeladen;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Zeigt ein PDF mit ausfuellbaren Formularfeldern. */
|
/** Zeigt ein PDF -- mit ausfuellbaren Feldern, sofern das erlaubt ist.
|
||||||
async function pdfBetrachter(behaelter, seiten, url, basis) {
|
*
|
||||||
|
* Ohne ``formulare`` bleibt es bei der Darstellung aus dem Dokument: pdf.js
|
||||||
|
* malt dann die Marken, die im PDF stehen (etwa rotes Kreuz und gruener
|
||||||
|
* Haken), statt Bedienelemente des Browsers einzusetzen.
|
||||||
|
*/
|
||||||
|
async function pdfBetrachter(behaelter, seiten, url, basis, { formulare = true } = {}) {
|
||||||
const { lib, komponenten } = await pdfjsLaden(basis);
|
const { lib, komponenten } = await pdfjsLaden(basis);
|
||||||
const adressen = pdfjsAdressen(basis);
|
const adressen = pdfjsAdressen(basis);
|
||||||
const eventBus = new komponenten.EventBus();
|
const eventBus = new komponenten.EventBus();
|
||||||
@@ -330,8 +512,11 @@ async function pdfBetrachter(behaelter, seiten, url, basis) {
|
|||||||
viewer: seiten,
|
viewer: seiten,
|
||||||
eventBus,
|
eventBus,
|
||||||
linkService,
|
linkService,
|
||||||
// Der Kern der Sache: Formularfelder werden zu echten Eingabefeldern.
|
// Der Kern der Sache: Formularfelder werden zu echten Eingabefeldern --
|
||||||
annotationMode: lib.AnnotationMode.ENABLE_FORMS,
|
// wer nicht ausfuellen darf, bekommt die Darstellung aus dem Dokument.
|
||||||
|
annotationMode: formulare
|
||||||
|
? lib.AnnotationMode.ENABLE_FORMS
|
||||||
|
: lib.AnnotationMode.ENABLE,
|
||||||
textLayerMode: 1,
|
textLayerMode: 1,
|
||||||
});
|
});
|
||||||
linkService.setViewer(viewer);
|
linkService.setViewer(viewer);
|
||||||
@@ -379,6 +564,7 @@ async function pdfBetrachter(behaelter, seiten, url, basis) {
|
|||||||
const bilder = new Map();
|
const bilder = new Map();
|
||||||
|
|
||||||
const bildknoepfeAufsetzen = async (nurSeite) => {
|
const bildknoepfeAufsetzen = async (nurSeite) => {
|
||||||
|
if (!formulare) return;
|
||||||
for (let nummer = 1; nummer <= dokument.numPages; nummer++) {
|
for (let nummer = 1; nummer <= dokument.numPages; nummer++) {
|
||||||
if (nurSeite && nummer !== nurSeite) continue;
|
if (nurSeite && nummer !== nurSeite) continue;
|
||||||
const seite = await dokument.getPage(nummer);
|
const seite = await dokument.getPage(nummer);
|
||||||
@@ -439,6 +625,159 @@ async function pdfBetrachter(behaelter, seiten, url, basis) {
|
|||||||
});
|
});
|
||||||
await bildknoepfeAufsetzen();
|
await bildknoepfeAufsetzen();
|
||||||
|
|
||||||
|
/* Unterschriften: gezeichnet wird ausserhalb, gesetzt wird hier. Die Stelle
|
||||||
|
bestimmt ein Tipp ins Dokument -- ein festes Feld gibt es in den wenigsten
|
||||||
|
Vorlagen, und "unten rechts" ist bei einem Formular mit drei
|
||||||
|
Unterschriftszeilen die falsche Annahme. */
|
||||||
|
// Ein menschliches Mass: rund fuenf Zentimeter breit, hoechstens knapp zwei
|
||||||
|
// hoch. Ohne die Hoehengrenze wird aus einem hohen Gekritzel ein Stempel,
|
||||||
|
// der die halbe Seite einnimmt -- das Seitenverhaeltnis kommt aus dem
|
||||||
|
// Zuschnitt der Zeichnung, nicht aus einer Vorlage. Nach dem Setzen laesst
|
||||||
|
// sich beides mit dem Griff nachstellen, nach oben bis zu dieser Grenze.
|
||||||
|
const MAX_BREITE = 150;
|
||||||
|
const MAX_HOEHE = 50;
|
||||||
|
const unterschriften = [];
|
||||||
|
let wartendeUnterschrift = null;
|
||||||
|
let zeugenzeile = () => "";
|
||||||
|
|
||||||
|
const platzieren = async (event) => {
|
||||||
|
if (!wartendeUnterschrift) return;
|
||||||
|
const seitenEl = event.target.closest(".page");
|
||||||
|
if (!seitenEl) return;
|
||||||
|
event.preventDefault();
|
||||||
|
const nummer = Number(seitenEl.dataset.pageNumber || 0);
|
||||||
|
if (!nummer) return;
|
||||||
|
const rechteck = seitenEl.getBoundingClientRect();
|
||||||
|
const anteilX = (event.clientX - rechteck.left) / rechteck.width;
|
||||||
|
const anteilY = (event.clientY - rechteck.top) / rechteck.height;
|
||||||
|
const seite = await dokument.getPage(nummer);
|
||||||
|
const blick = seite.getViewport({ scale: 1 });
|
||||||
|
let breite = Math.min(MAX_BREITE, blick.width * 0.3);
|
||||||
|
let hoehe = (wartendeUnterschrift.hoehe / wartendeUnterschrift.breite) * breite;
|
||||||
|
if (hoehe > MAX_HOEHE) {
|
||||||
|
breite *= MAX_HOEHE / hoehe;
|
||||||
|
hoehe = MAX_HOEHE;
|
||||||
|
}
|
||||||
|
const eintrag = {
|
||||||
|
...wartendeUnterschrift,
|
||||||
|
seite: nummer,
|
||||||
|
breite,
|
||||||
|
// Der Tipp meint die Mitte der Unterschrift, nicht ihre Ecke.
|
||||||
|
x: Math.max(4, anteilX * blick.width - breite / 2),
|
||||||
|
y: Math.max(24, (1 - anteilY) * blick.height - hoehe / 2),
|
||||||
|
};
|
||||||
|
unterschriften.push(eintrag);
|
||||||
|
behaelter.classList.remove("viewer__pdfscroll--setzen");
|
||||||
|
wartendeUnterschrift = null;
|
||||||
|
|
||||||
|
// Sichtbar machen, wo sie gelandet ist -- und beweglich: der erste Tipp
|
||||||
|
// trifft selten genau die Linie, und der Textblock gehoert mit auf die
|
||||||
|
// richtige Stelle.
|
||||||
|
marke_setzen(seitenEl, eintrag, blick, zeugenzeile());
|
||||||
|
behaelter.dispatchEvent(new CustomEvent("unterschrieben", { detail: eintrag }));
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Baut die bewegliche Marke einer gesetzten Unterschrift. */
|
||||||
|
const marke_setzen = (seitenEl, eintrag, blick, zeuge) => {
|
||||||
|
const marke = document.createElement("div");
|
||||||
|
marke.className = "viewer__unterschrift";
|
||||||
|
const bild = document.createElement("img");
|
||||||
|
bild.src = eintrag.vorschau;
|
||||||
|
bild.alt = "";
|
||||||
|
// Genau der Aufbau der spaeteren Datei: eine Zeile mit Namen und
|
||||||
|
// Zeitpunkt, darunter -- als eigene Zeile -- der Zeugenvermerk. In der
|
||||||
|
// Vorschau standen beide hintereinander, getrennt durch einen
|
||||||
|
// Schraegstrich; beim Positionieren sah der Block dadurch breiter und
|
||||||
|
// flacher aus als das Ergebnis.
|
||||||
|
const text = document.createElement("div");
|
||||||
|
text.className = "viewer__unterschrifttext";
|
||||||
|
const namenszeile = document.createElement("div");
|
||||||
|
namenszeile.textContent = `${eintrag.name} | ${eintrag.zeit}`;
|
||||||
|
text.append(namenszeile);
|
||||||
|
if (zeuge && eintrag === unterschriften[0]) {
|
||||||
|
const zeugenzeile = document.createElement("div");
|
||||||
|
zeugenzeile.textContent = `In TESM abgelegt durch ${zeuge}`;
|
||||||
|
text.append(zeugenzeile);
|
||||||
|
}
|
||||||
|
const griff = document.createElement("button");
|
||||||
|
griff.type = "button";
|
||||||
|
griff.className = "viewer__unterschriftgriff";
|
||||||
|
griff.title = "Größe ändern";
|
||||||
|
griff.setAttribute("aria-label", "Unterschrift vergroessern oder verkleinern");
|
||||||
|
const weg = document.createElement("button");
|
||||||
|
weg.type = "button";
|
||||||
|
weg.className = "viewer__unterschriftweg";
|
||||||
|
weg.textContent = "x";
|
||||||
|
weg.title = "Unterschrift entfernen";
|
||||||
|
weg.setAttribute("aria-label", "Unterschrift entfernen");
|
||||||
|
marke.append(bild, text, griff, weg);
|
||||||
|
seitenEl.append(marke);
|
||||||
|
|
||||||
|
const zeichnen = () => {
|
||||||
|
marke.style.left = `${(eintrag.x / blick.width) * 100}%`;
|
||||||
|
marke.style.bottom = `${(eintrag.y / blick.height) * 100}%`;
|
||||||
|
marke.style.width = `${(eintrag.breite / blick.width) * 100}%`;
|
||||||
|
// Dieselbe Rechnung wie beim Stempeln, samt ihrer Ober- und Untergrenze
|
||||||
|
// -- in "cqw" (Prozent der Blockbreite), damit sie beim Zoomen mitgeht.
|
||||||
|
text.style.fontSize = `${(schriftgroesse(eintrag.breite) / eintrag.breite) * 100}cqw`;
|
||||||
|
};
|
||||||
|
zeichnen();
|
||||||
|
|
||||||
|
weg.addEventListener("click", (event) => {
|
||||||
|
event.stopPropagation();
|
||||||
|
const stelle = unterschriften.indexOf(eintrag);
|
||||||
|
if (stelle >= 0) unterschriften.splice(stelle, 1);
|
||||||
|
marke.remove();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Verschieben und Skalieren teilen sich einen Zeiger: beim Griff aendert
|
||||||
|
// sich die Breite, sonst die Lage.
|
||||||
|
const fassen = (event) => {
|
||||||
|
if (event.target === weg) return;
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
const skalieren = event.target === griff;
|
||||||
|
const rechteck = seitenEl.getBoundingClientRect();
|
||||||
|
const proPunkt = rechteck.width / blick.width;
|
||||||
|
const startX = event.clientX;
|
||||||
|
const startY = event.clientY;
|
||||||
|
const startBreite = eintrag.breite;
|
||||||
|
const start = { x: eintrag.x, y: eintrag.y };
|
||||||
|
const obergrenze = Math.min(MAX_BREITE, blick.width * 0.3);
|
||||||
|
marke.setPointerCapture(event.pointerId);
|
||||||
|
marke.classList.add("is-fassend");
|
||||||
|
|
||||||
|
const bewegen = (zug) => {
|
||||||
|
if (skalieren) {
|
||||||
|
const breite = startBreite + (zug.clientX - startX) / proPunkt;
|
||||||
|
eintrag.breite = Math.max(40, Math.min(obergrenze, breite));
|
||||||
|
} else {
|
||||||
|
eintrag.x = Math.max(2, start.x + (zug.clientX - startX) / proPunkt);
|
||||||
|
eintrag.y = Math.max(
|
||||||
|
// Platz fuer die Textzeilen unter der Zeichnung.
|
||||||
|
schriftgroesse(eintrag.breite) * 2.5,
|
||||||
|
start.y - (zug.clientY - startY) / proPunkt
|
||||||
|
);
|
||||||
|
}
|
||||||
|
zeichnen();
|
||||||
|
};
|
||||||
|
const loslassen = () => {
|
||||||
|
marke.classList.remove("is-fassend");
|
||||||
|
marke.removeEventListener("pointermove", bewegen);
|
||||||
|
marke.removeEventListener("pointerup", loslassen);
|
||||||
|
marke.removeEventListener("pointercancel", loslassen);
|
||||||
|
};
|
||||||
|
marke.addEventListener("pointermove", bewegen);
|
||||||
|
marke.addEventListener("pointerup", loslassen);
|
||||||
|
marke.addEventListener("pointercancel", loslassen);
|
||||||
|
};
|
||||||
|
marke.addEventListener("pointerdown", fassen);
|
||||||
|
};
|
||||||
|
|
||||||
|
behaelter.addEventListener("click", (event) => {
|
||||||
|
platzieren(event).catch((fehler) => console.error("Unterschrift", fehler));
|
||||||
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
hinein: () => {
|
hinein: () => {
|
||||||
viewer.currentScale = Math.min(MAX_ZOOM, viewer.currentScale * 1.2);
|
viewer.currentScale = Math.min(MAX_ZOOM, viewer.currentScale * 1.2);
|
||||||
@@ -454,12 +793,23 @@ async function pdfBetrachter(behaelter, seiten, url, basis) {
|
|||||||
* pdf.js schreibt die Feldwerte; gewaehlte Fotos kommen danach mit
|
* pdf.js schreibt die Feldwerte; gewaehlte Fotos kommen danach mit
|
||||||
* pdf-lib hinein -- pdf.js kann nichts in ein PDF zeichnen.
|
* pdf-lib hinein -- pdf.js kann nichts in ein PDF zeichnen.
|
||||||
*/
|
*/
|
||||||
speichern: async (pdflibAdresse, { abschliessen = false } = {}) => {
|
speichern: async (pdflibAdresse, { abschliessen = false, zeuge = "" } = {}) => {
|
||||||
const bytes = await dokument.saveDocument();
|
const bytes = await dokument.saveDocument();
|
||||||
// Ohne Bild und ohne Abschluss braucht es pdf-lib gar nicht.
|
// Ohne Bild, ohne Unterschrift und ohne Abschluss braucht es pdf-lib nicht.
|
||||||
if (!bilder.size && !abschliessen) return bytes;
|
if (!bilder.size && !abschliessen && !unterschriften.length) return bytes;
|
||||||
return nachbearbeiten(bytes, [...bilder.values()], pdflibAdresse, { abschliessen });
|
return nachbearbeiten(bytes, [...bilder.values()], pdflibAdresse, {
|
||||||
|
abschliessen,
|
||||||
|
unterschriften,
|
||||||
|
zeuge,
|
||||||
|
});
|
||||||
},
|
},
|
||||||
|
/** Wartet der Betrachter auf den Tipp, wohin die Unterschrift soll? */
|
||||||
|
unterschriftAnnehmen: (daten, zeuge = "") => {
|
||||||
|
wartendeUnterschrift = daten;
|
||||||
|
zeugenzeile = () => zeuge;
|
||||||
|
behaelter.classList.add("viewer__pdfscroll--setzen");
|
||||||
|
},
|
||||||
|
unterschriften: () => unterschriften.map(({ name, zeit }) => ({ name, zeit })),
|
||||||
/** Wurde ueberhaupt etwas eingetragen? */
|
/** Wurde ueberhaupt etwas eingetragen? */
|
||||||
veraendert: () => {
|
veraendert: () => {
|
||||||
const s = dokument.annotationStorage;
|
const s = dokument.annotationStorage;
|
||||||
@@ -496,10 +846,49 @@ export function initFilePreview(wurzel = document) {
|
|||||||
const ordnerFeld = stueck("ordner");
|
const ordnerFeld = stueck("ordner");
|
||||||
const nameFeld = stueck("name");
|
const nameFeld = stueck("name");
|
||||||
const ersetzenFeld = stueck("ersetzen");
|
const ersetzenFeld = stueck("ersetzen");
|
||||||
|
const ersetzenKasten = stueck("ersetzenfeld");
|
||||||
|
const pruefzeile = stueck("pruefung");
|
||||||
|
// Die Ordner der geoeffneten Freigabe kommen mit der Seite; die einer
|
||||||
|
// anderen holt das Auswahlfeld bei Bedarf nach.
|
||||||
|
const eigeneOrdner = () => {
|
||||||
|
try {
|
||||||
|
return JSON.parse(rumpf.dataset.viewerOrdnerliste || "[]");
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
};
|
||||||
const jetzt = stueck("jetzt");
|
const jetzt = stueck("jetzt");
|
||||||
const abbrechen = stueck("abbrechen");
|
const abbrechen = stueck("abbrechen");
|
||||||
const modusFeld = stueck("modus");
|
const modusFeld = stueck("modus");
|
||||||
|
const zielFeld = stueck("ziel");
|
||||||
|
const unterschreibenKnopf = stueck("unterschreiben");
|
||||||
|
const unterschriftPanel = stueck("unterschriftpanel");
|
||||||
|
const vornameFeld = stueck("vorname");
|
||||||
|
const nachnameFeld = stueck("nachname");
|
||||||
|
const padLeinwand = stueck("signpad");
|
||||||
|
const padLeeren = stueck("signleeren");
|
||||||
|
const padSetzen = stueck("signsetzen");
|
||||||
|
const padAbbrechen = stueck("signabbrechen");
|
||||||
|
const padHinweis = stueck("signhinweis");
|
||||||
const basis = rumpf.dataset.viewerPdfjs || "";
|
const basis = rumpf.dataset.viewerPdfjs || "";
|
||||||
|
// Der Anmeldename, nicht der volle Name: er ist eindeutig, und genau das
|
||||||
|
// soll die Zeile belegen.
|
||||||
|
const konto = rumpf.dataset.viewerKonto || "";
|
||||||
|
const bezeugen = rumpf.dataset.viewerZeuge === "1";
|
||||||
|
// Die Uhr des Servers, nicht die des Tablets: eine verstellte Uhr am Geraet
|
||||||
|
// soll nicht das Datum unter einer Unterschrift bestimmen.
|
||||||
|
const serverStart = Date.parse(rumpf.dataset.viewerZeit || "") || Date.now();
|
||||||
|
const geladenSeit = Date.now();
|
||||||
|
const jetztText = () => {
|
||||||
|
const zeitpunkt = new Date(serverStart + (Date.now() - geladenSeit));
|
||||||
|
return zeitpunkt.toLocaleString("de-DE", {
|
||||||
|
day: "2-digit",
|
||||||
|
month: "2-digit",
|
||||||
|
year: "numeric",
|
||||||
|
hour: "2-digit",
|
||||||
|
minute: "2-digit",
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
// Ein Format, das dieser Browser nicht kann (HEIC ausserhalb von Safari),
|
// Ein Format, das dieser Browser nicht kann (HEIC ausserhalb von Safari),
|
||||||
// soll nicht als leere Flaeche enden.
|
// soll nicht als leere Flaeche enden.
|
||||||
@@ -507,7 +896,7 @@ export function initFilePreview(wurzel = document) {
|
|||||||
if (bildBuehne.hidden || !bild.getAttribute("src")) return;
|
if (bildBuehne.hidden || !bild.getAttribute("src")) return;
|
||||||
if (hinweis) {
|
if (hinweis) {
|
||||||
hinweis.textContent =
|
hinweis.textContent =
|
||||||
"Dieses Bildformat kann Ihr Browser nicht anzeigen -- bitte herunterladen.";
|
"Dieses Bildformat kann Ihr Browser nicht anzeigen – bitte herunterladen.";
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -526,9 +915,13 @@ export function initFilePreview(wurzel = document) {
|
|||||||
rahmen.hidden = art !== "text";
|
rahmen.hidden = art !== "text";
|
||||||
leiste.hidden = art === "text";
|
leiste.hidden = art === "text";
|
||||||
if (speichern) speichern.hidden = art !== "pdf";
|
if (speichern) speichern.hidden = art !== "pdf";
|
||||||
|
if (unterschreibenKnopf) unterschreibenKnopf.hidden = art !== "pdf";
|
||||||
|
if (speichern) speichern.hidden = art !== "pdf";
|
||||||
|
if (unterschriftPanel) unterschriftPanel.hidden = true;
|
||||||
};
|
};
|
||||||
|
|
||||||
const aufraeumen = () => {
|
const aufraeumen = () => {
|
||||||
|
pruefungZuruecksetzen();
|
||||||
bild.removeAttribute("src");
|
bild.removeAttribute("src");
|
||||||
bilder.zuruecksetzen();
|
bilder.zuruecksetzen();
|
||||||
rahmen.src = "about:blank";
|
rahmen.src = "about:blank";
|
||||||
@@ -546,6 +939,61 @@ export function initFilePreview(wurzel = document) {
|
|||||||
aktuell = null;
|
aktuell = null;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/* Unterschrieben und unveraendert? Die Antwort kommt aus dem
|
||||||
|
Aenderungsprotokoll: dort steht die Pruefsumme der abgelegten Fassung.
|
||||||
|
Stimmt sie, bekommt die Seite einen gruenen Schleier -- man soll es sehen,
|
||||||
|
bevor man liest, nicht danach. */
|
||||||
|
|
||||||
|
const pruefungZuruecksetzen = () => {
|
||||||
|
pdfBehaelter.classList.remove("viewer__pdf--unveraendert", "viewer__pdf--veraendert");
|
||||||
|
if (pruefzeile) {
|
||||||
|
pruefzeile.hidden = true;
|
||||||
|
pruefzeile.textContent = "";
|
||||||
|
pruefzeile.className = "viewer__pruefung";
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const pruefen = async (adresse) => {
|
||||||
|
pruefungZuruecksetzen();
|
||||||
|
if (!adresse) return;
|
||||||
|
try {
|
||||||
|
const stand = await window.tesm.api(adresse);
|
||||||
|
if (stand.status === "unveraendert" || stand.status === "veraendert") {
|
||||||
|
// Ein unterschriebenes Dokument ist abgeschlossen. Wer daraus eine
|
||||||
|
// neue Fassung machen koennte, koennte auch eine Unterschrift unter
|
||||||
|
// einen veraenderten Text setzen -- also gibt es hier nichts mehr zu
|
||||||
|
// bearbeiten. Der Server weist es zusaetzlich ab.
|
||||||
|
if (unterschreibenKnopf) unterschreibenKnopf.hidden = true;
|
||||||
|
if (speichern) speichern.hidden = true;
|
||||||
|
if (zeile) zeile.hidden = true;
|
||||||
|
}
|
||||||
|
if (stand.status === "unveraendert") {
|
||||||
|
pdfBehaelter.classList.add("viewer__pdf--unveraendert");
|
||||||
|
if (pruefzeile) {
|
||||||
|
pruefzeile.hidden = false;
|
||||||
|
pruefzeile.className = "viewer__pruefung viewer__pruefung--gut";
|
||||||
|
const namen = (stand.namen || []).join(", ");
|
||||||
|
pruefzeile.textContent =
|
||||||
|
`Unverändert seit der Unterschrift${namen ? ` von ${namen}` : ""}` +
|
||||||
|
` | Prüfsumme stimmt mit dem Änderungsprotokoll überein.`;
|
||||||
|
}
|
||||||
|
} else if (stand.status === "veraendert") {
|
||||||
|
pdfBehaelter.classList.add("viewer__pdf--veraendert");
|
||||||
|
if (pruefzeile) {
|
||||||
|
pruefzeile.hidden = false;
|
||||||
|
pruefzeile.className = "viewer__pruefung viewer__pruefung--schlecht";
|
||||||
|
pruefzeile.textContent =
|
||||||
|
"Diese Datei wurde nach der Unterschrift verändert – die Prüfsumme " +
|
||||||
|
"weicht vom Änderungsprotokoll ab.";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (fehler) {
|
||||||
|
// Keine Aussage ist besser als eine falsche: ohne Antwort bleibt die
|
||||||
|
// Seite schlicht ungekennzeichnet.
|
||||||
|
console.error("Signaturpruefung", fehler);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const oeffnen = async (ausloeser) => {
|
const oeffnen = async (ausloeser) => {
|
||||||
aufraeumen();
|
aufraeumen();
|
||||||
const art = ausloeser.dataset.fillKind || "text";
|
const art = ausloeser.dataset.fillKind || "text";
|
||||||
@@ -561,8 +1009,11 @@ export function initFilePreview(wurzel = document) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (hinweis) hinweis.textContent = "PDF wird geladen ...";
|
if (hinweis) hinweis.textContent = "PDF wird geladen ...";
|
||||||
|
pruefen(ausloeser.dataset.fillPruefung || "");
|
||||||
try {
|
try {
|
||||||
pdf = await pdfBetrachter(pdfScroll, pdfSeiten, quelle, basis);
|
pdf = await pdfBetrachter(pdfScroll, pdfSeiten, quelle, basis, {
|
||||||
|
formulare: rumpf.dataset.viewerFormulare === "1",
|
||||||
|
});
|
||||||
if (hinweis) hinweis.textContent = "";
|
if (hinweis) hinweis.textContent = "";
|
||||||
} catch (fehler) {
|
} catch (fehler) {
|
||||||
// Kein weisses Feld: sagen, was los ist, und auf die einfache Anzeige
|
// Kein weisses Feld: sagen, was los ist, und auf die einfache Anzeige
|
||||||
@@ -575,7 +1026,7 @@ export function initFilePreview(wurzel = document) {
|
|||||||
// Browsers die einzige Spur, die man hat.
|
// Browsers die einzige Spur, die man hat.
|
||||||
const grund = String(fehler?.message || fehler || "").slice(0, 120);
|
const grund = String(fehler?.message || fehler || "").slice(0, 120);
|
||||||
hinweis.textContent =
|
hinweis.textContent =
|
||||||
"Die Formularansicht liess sich nicht laden -- es bleibt bei der einfachen Anzeige." +
|
"Die Formularansicht ließ sich nicht laden – es bleibt bei der einfachen Anzeige." +
|
||||||
(grund ? ` (${grund})` : "");
|
(grund ? ` (${grund})` : "");
|
||||||
// Die Leiste traegt den Hinweis. "zeigen('text')" hat sie gerade
|
// Die Leiste traegt den Hinweis. "zeigen('text')" hat sie gerade
|
||||||
// verborgen -- ohne diese Zeile blieb der Fehlschlag unsichtbar, und
|
// verborgen -- ohne diese Zeile blieb der Fehlschlag unsichtbar, und
|
||||||
@@ -609,18 +1060,92 @@ export function initFilePreview(wurzel = document) {
|
|||||||
if (!aktuell) return;
|
if (!aktuell) return;
|
||||||
const teile = aktuell.pfad.split("/");
|
const teile = aktuell.pfad.split("/");
|
||||||
const datei = teile.pop() || "formular.pdf";
|
const datei = teile.pop() || "formular.pdf";
|
||||||
ordnerFeld.value = teile.join("/");
|
ordnerFuellen(eigeneOrdner(), teile.join("/"));
|
||||||
// Ohne Endung: die haengt der Server an. Eine Datei, die auf ".pdf.txt"
|
// Ohne Endung: die haengt der Server an. Eine Datei, die auf ".pdf.txt"
|
||||||
// oder auf gar nichts endet, oeffnet auf einer Freigabe nichts mehr.
|
// oder auf gar nichts endet, oeffnet auf einer Freigabe nichts mehr.
|
||||||
// Der Zusatz haelt die Vorlage heil, auch wenn jemand blind speichert.
|
// Der Zusatz haelt die Vorlage heil, auch wenn jemand blind speichert.
|
||||||
nameFeld.value = datei.replace(/\.pdf$/i, "") + "-ausgefuellt";
|
// Der Zusatz sagt, was in der Datei steht: unterschrieben schlaegt
|
||||||
|
// ausgefuellt. Und einmal reicht -- wer eine Fassung noch einmal oeffnet
|
||||||
|
// und speichert, bekam sonst "...-ausgefuellt-ausgefuellt".
|
||||||
|
const rumpfname = datei.replace(/\.pdf$/i, "");
|
||||||
|
const zusatz = pdf?.unterschriften().length ? "-unterschrieben" : "-ausgefuellt";
|
||||||
|
const bereits = new RegExp(`${zusatz}$`, "i").test(rumpfname);
|
||||||
|
// Eine unterschriebene Fassung aus einer ausgefuellten: der alte Zusatz
|
||||||
|
// haette sonst zweimal dran gestanden ("-ausgefuellt-unterschrieben").
|
||||||
|
const blank = rumpfname.replace(/-(ausgefuellt|unterschrieben)$/i, "");
|
||||||
|
nameFeld.value = bereits ? rumpfname : `${blank}${zusatz}`;
|
||||||
ersetzenFeld.checked = false;
|
ersetzenFeld.checked = false;
|
||||||
|
ersetzenKasten?.classList.remove("is-gefragt");
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/* Unterschreiben: Name tippen, zeichnen, ins Dokument setzen. */
|
||||||
|
|
||||||
|
const pad = padLeinwand ? unterschriftPad(padLeinwand) : null;
|
||||||
|
|
||||||
|
const panelZeigen = (offen) => {
|
||||||
|
if (!unterschriftPanel) return;
|
||||||
|
unterschriftPanel.hidden = !offen;
|
||||||
|
if (offen) {
|
||||||
|
pad?.leeren();
|
||||||
|
if (padHinweis) padHinweis.textContent = "";
|
||||||
|
vornameFeld?.focus();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Gesetzt heisst fertig zum Ablegen: die Zeile geht von selbst auf, statt
|
||||||
|
// dass man den Knopf daneben sucht.
|
||||||
|
pdfScroll?.addEventListener("unterschrieben", () => {
|
||||||
|
if (!zeile || !zeile.hidden) return;
|
||||||
|
speichern?.click();
|
||||||
|
});
|
||||||
|
|
||||||
|
unterschreibenKnopf?.addEventListener("click", () => {
|
||||||
|
if (!pdf) return;
|
||||||
|
panelZeigen(unterschriftPanel?.hidden !== false);
|
||||||
|
});
|
||||||
|
|
||||||
|
padLeeren?.addEventListener("click", () => pad?.leeren());
|
||||||
|
padAbbrechen?.addEventListener("click", () => panelZeigen(false));
|
||||||
|
|
||||||
|
padSetzen?.addEventListener("click", () => {
|
||||||
|
if (!pdf || !pad) return;
|
||||||
|
const name = [vornameFeld?.value, nachnameFeld?.value]
|
||||||
|
.map((wert) => (wert || "").trim())
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(" ");
|
||||||
|
// Beides ist noetig, und beides wird hier gesagt statt stumm abgelehnt.
|
||||||
|
if (!name) {
|
||||||
|
if (padHinweis) padHinweis.textContent = "Bitte Vor- und Nachnamen eintragen.";
|
||||||
|
vornameFeld?.focus();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const zeichnung = pad.alsPng();
|
||||||
|
if (!zeichnung) {
|
||||||
|
if (padHinweis) padHinweis.textContent = "Bitte im Feld unterschreiben.";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
pdf.unterschriftAnnehmen(
|
||||||
|
{ ...zeichnung, name, zeit: jetztText() },
|
||||||
|
bezeugen ? konto : ""
|
||||||
|
);
|
||||||
|
panelZeigen(false);
|
||||||
|
window.tesm.showToast(
|
||||||
|
"Jetzt auf die Stelle im Dokument tippen – danach lässt sie sich noch "
|
||||||
|
+ "verschieben und in der Größe anpassen.",
|
||||||
|
"info"
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
speichern?.addEventListener("click", () => {
|
speichern?.addEventListener("click", () => {
|
||||||
if (!pdf || !aktuell) return;
|
if (!pdf || !aktuell) return;
|
||||||
vorschlagen();
|
vorschlagen();
|
||||||
zeile.hidden = false;
|
zeile.hidden = false;
|
||||||
|
if (modusFeld && pdf.unterschriften().length) {
|
||||||
|
// Mit Unterschrift gibt es nur einen Weg -- und das Feld soll nicht
|
||||||
|
// etwas anderes behaupten.
|
||||||
|
modusFeld.value = "fertig";
|
||||||
|
modusFeld.disabled = true;
|
||||||
|
}
|
||||||
nameFeld.focus();
|
nameFeld.focus();
|
||||||
nameFeld.select();
|
nameFeld.select();
|
||||||
});
|
});
|
||||||
@@ -629,6 +1154,46 @@ export function initFilePreview(wurzel = document) {
|
|||||||
zeile.hidden = true;
|
zeile.hidden = true;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/* Zielfreigabe: die Ordnerliste gehoert zur gewaehlten Freigabe, nicht zur
|
||||||
|
geoeffneten. Geholt wird sie erst bei Bedarf -- alle Listen im
|
||||||
|
Seitenaufbau mitzuschicken waere Ballast fuer einen seltenen Fall. */
|
||||||
|
/** Fuellt die Ordnerauswahl -- erster Eintrag ist immer die Wurzel. */
|
||||||
|
const ordnerFuellen = (ordner, ausgewaehlt = "") => {
|
||||||
|
if (!ordnerFeld) return;
|
||||||
|
const wurzel = document.createElement("option");
|
||||||
|
wurzel.value = "";
|
||||||
|
wurzel.textContent = "Wurzelverzeichnis der Freigabe";
|
||||||
|
const eintraege = [wurzel];
|
||||||
|
for (const name of ordner) {
|
||||||
|
const option = document.createElement("option");
|
||||||
|
option.value = name;
|
||||||
|
option.textContent = name;
|
||||||
|
eintraege.push(option);
|
||||||
|
}
|
||||||
|
// Der Ordner der Vorlage kann tiefer liegen, als die Liste reicht.
|
||||||
|
if (ausgewaehlt && !ordner.includes(ausgewaehlt)) {
|
||||||
|
const option = document.createElement("option");
|
||||||
|
option.value = ausgewaehlt;
|
||||||
|
option.textContent = ausgewaehlt;
|
||||||
|
eintraege.splice(1, 0, option);
|
||||||
|
}
|
||||||
|
ordnerFeld.replaceChildren(...eintraege);
|
||||||
|
ordnerFeld.value = ausgewaehlt;
|
||||||
|
};
|
||||||
|
|
||||||
|
zielFeld?.addEventListener("change", async () => {
|
||||||
|
const vorlage = zielFeld.dataset.urlVorlage || "";
|
||||||
|
if (!vorlage) return;
|
||||||
|
try {
|
||||||
|
const antwort = await window.tesm.api(vorlage.replace("__token__", zielFeld.value));
|
||||||
|
ordnerFuellen(antwort.folders || []);
|
||||||
|
} catch (fehler) {
|
||||||
|
// Kein Beinbruch: dann bleibt es bei der Wurzel.
|
||||||
|
console.error("Ordnerliste", fehler);
|
||||||
|
ordnerFuellen([]);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
jetzt?.addEventListener("click", async () => {
|
jetzt?.addEventListener("click", async () => {
|
||||||
if (!pdf || !aktuell) return;
|
if (!pdf || !aktuell) return;
|
||||||
const ziel = speichern.dataset.url;
|
const ziel = speichern.dataset.url;
|
||||||
@@ -636,13 +1201,20 @@ export function initFilePreview(wurzel = document) {
|
|||||||
const vorher = jetzt.textContent;
|
const vorher = jetzt.textContent;
|
||||||
jetzt.textContent = "Speichern ...";
|
jetzt.textContent = "Speichern ...";
|
||||||
try {
|
try {
|
||||||
|
const gesetzt = pdf.unterschriften();
|
||||||
const bytes = await pdf.speichern(rumpf.dataset.viewerPdflib || "", {
|
const bytes = await pdf.speichern(rumpf.dataset.viewerPdflib || "", {
|
||||||
abschliessen: modusFeld?.value === "fertig",
|
// Unterschrieben heisst abgeschlossen: ein Dokument, in dem nach der
|
||||||
|
// Unterschrift noch Felder aenderbar waeren, ist keine Unterschrift
|
||||||
|
// wert.
|
||||||
|
abschliessen: modusFeld?.value === "fertig" || gesetzt.length > 0,
|
||||||
|
zeuge: bezeugen ? konto : "",
|
||||||
});
|
});
|
||||||
const daten = new FormData();
|
const daten = new FormData();
|
||||||
daten.append("pfad", aktuell.pfad);
|
daten.append("path", aktuell.pfad);
|
||||||
daten.append("ordner", ordnerFeld.value.trim());
|
daten.append("ordner", ordnerFeld.value.trim());
|
||||||
daten.append("name", nameFeld.value.trim());
|
daten.append("name", nameFeld.value.trim());
|
||||||
|
if (zielFeld?.value) daten.append("ziel", zielFeld.value);
|
||||||
|
if (gesetzt.length) daten.append("unterschriften", JSON.stringify(gesetzt));
|
||||||
if (ersetzenFeld.checked) daten.append("ersetzen", "1");
|
if (ersetzenFeld.checked) daten.append("ersetzen", "1");
|
||||||
daten.append(
|
daten.append(
|
||||||
"datei",
|
"datei",
|
||||||
@@ -658,6 +1230,12 @@ export function initFilePreview(wurzel = document) {
|
|||||||
// Zeile bleibt offen: meist ist es ein Name, der schon vergeben ist.
|
// Zeile bleibt offen: meist ist es ein Name, der schon vergeben ist.
|
||||||
console.error("Formular speichern", fehler);
|
console.error("Formular speichern", fehler);
|
||||||
window.tesm.showToast(fehler.message || "Speichern fehlgeschlagen.", "danger");
|
window.tesm.showToast(fehler.message || "Speichern fehlgeschlagen.", "danger");
|
||||||
|
// Genau dieser Fall hat eine Antwort auf der Zeile -- dann zeigen wir
|
||||||
|
// auch darauf, statt es bei einer Meldung zu belassen.
|
||||||
|
if (fehler.status === 409) {
|
||||||
|
ersetzenKasten?.classList.add("is-gefragt");
|
||||||
|
ersetzenFeld?.focus();
|
||||||
|
}
|
||||||
} finally {
|
} finally {
|
||||||
jetzt.disabled = false;
|
jetzt.disabled = false;
|
||||||
jetzt.textContent = vorher;
|
jetzt.textContent = vorher;
|
||||||
|
|||||||
@@ -78,7 +78,7 @@ class Result:
|
|||||||
UNAVAILABLE = Result(
|
UNAVAILABLE = Result(
|
||||||
ok=False,
|
ok=False,
|
||||||
stderr=(
|
stderr=(
|
||||||
"Systemaktionen sind in dieser Umgebung nicht verfuegbar "
|
"Systemaktionen sind in dieser Umgebung nicht verfügbar "
|
||||||
"(Helfer nicht installiert oder kein Linux-Host)."
|
"(Helfer nicht installiert oder kein Linux-Host)."
|
||||||
),
|
),
|
||||||
code=127,
|
code=127,
|
||||||
@@ -132,16 +132,16 @@ class SysOps:
|
|||||||
False,
|
False,
|
||||||
"",
|
"",
|
||||||
"sudo wird durch 'NoNewPrivileges' der systemd-Unit blockiert. Ohne sudo "
|
"sudo wird durch 'NoNewPrivileges' der systemd-Unit blockiert. Ohne sudo "
|
||||||
"kann der Dienst den Helfer nicht aufrufen -- keine Systemaktion "
|
"kann der Dienst den Helfer nicht aufrufen – keine Systemaktion "
|
||||||
"funktioniert. In der Unit 'NoNewPrivileges=yes' entfernen und "
|
"funktioniert. In der Unit 'NoNewPrivileges=yes' entfernen und "
|
||||||
"'systemctl daemon-reload && systemctl restart' ausfuehren.",
|
"'systemctl daemon-reload && systemctl restart' ausführen.",
|
||||||
result.code,
|
result.code,
|
||||||
)
|
)
|
||||||
if not result.ok and "a password is required" in result.output.lower():
|
if not result.ok and "a password is required" in result.output.lower():
|
||||||
return Result(
|
return Result(
|
||||||
False,
|
False,
|
||||||
"",
|
"",
|
||||||
"sudo verlangt ein Passwort. Die sudoers-Regel fuer den Helfer fehlt oder "
|
"sudo verlangt ein Passwort. Die sudoers-Regel für den Helfer fehlt oder "
|
||||||
"greift nicht.",
|
"greift nicht.",
|
||||||
result.code,
|
result.code,
|
||||||
)
|
)
|
||||||
@@ -177,7 +177,7 @@ class SysOps:
|
|||||||
check=False,
|
check=False,
|
||||||
)
|
)
|
||||||
except subprocess.TimeoutExpired:
|
except subprocess.TimeoutExpired:
|
||||||
return Result(False, stderr=f"Zeitlimit ueberschritten ({verb}).", code=124)
|
return Result(False, stderr=f"Zeitlimit überschritten ({verb}).", code=124)
|
||||||
except OSError as exc:
|
except OSError as exc:
|
||||||
return Result(False, stderr=str(exc), code=126)
|
return Result(False, stderr=str(exc), code=126)
|
||||||
return Result(
|
return Result(
|
||||||
@@ -210,7 +210,7 @@ class SysOps:
|
|||||||
check=False,
|
check=False,
|
||||||
)
|
)
|
||||||
except subprocess.TimeoutExpired:
|
except subprocess.TimeoutExpired:
|
||||||
return Result(False, stderr="Zeitlimit ueberschritten.", code=124)
|
return Result(False, stderr="Zeitlimit überschritten.", code=124)
|
||||||
except OSError as exc:
|
except OSError as exc:
|
||||||
return Result(False, stderr=str(exc), code=126)
|
return Result(False, stderr=str(exc), code=126)
|
||||||
return Result(
|
return Result(
|
||||||
|
|||||||
@@ -136,24 +136,24 @@ def validate_static(
|
|||||||
try:
|
try:
|
||||||
prefix_length = int(prefix)
|
prefix_length = int(prefix)
|
||||||
except (TypeError, ValueError) as exc:
|
except (TypeError, ValueError) as exc:
|
||||||
raise ValueError("Die Praefixlaenge muss eine Zahl sein (z. B. 24).") from exc
|
raise ValueError("Die Präfixlänge muss eine Zahl sein (z. B. 24).") from exc
|
||||||
if not 1 <= prefix_length <= 32:
|
if not 1 <= prefix_length <= 32:
|
||||||
raise ValueError("Die Praefixlaenge muss zwischen 1 und 32 liegen.")
|
raise ValueError("Die Präfixlänge muss zwischen 1 und 32 liegen.")
|
||||||
try:
|
try:
|
||||||
interface_ip = ipaddress.ip_interface(f"{address.strip()}/{prefix_length}")
|
interface_ip = ipaddress.ip_interface(f"{address.strip()}/{prefix_length}")
|
||||||
except ValueError as exc:
|
except ValueError as exc:
|
||||||
raise ValueError(f"{address!r} ist keine gueltige IPv4-Adresse.") from exc
|
raise ValueError(f"{address!r} ist keine gültige IPv4-Adresse.") from exc
|
||||||
|
|
||||||
gateway_clean = (gateway or "").strip()
|
gateway_clean = (gateway or "").strip()
|
||||||
if gateway_clean:
|
if gateway_clean:
|
||||||
try:
|
try:
|
||||||
gateway_ip = ipaddress.ip_address(gateway_clean)
|
gateway_ip = ipaddress.ip_address(gateway_clean)
|
||||||
except ValueError as exc:
|
except ValueError as exc:
|
||||||
raise ValueError(f"{gateway_clean!r} ist keine gueltige Gateway-Adresse.") from exc
|
raise ValueError(f"{gateway_clean!r} ist keine gültige Gateway-Adresse.") from exc
|
||||||
if gateway_ip not in interface_ip.network:
|
if gateway_ip not in interface_ip.network:
|
||||||
raise ValueError(
|
raise ValueError(
|
||||||
f"Das Gateway {gateway_clean} liegt nicht im Netz {interface_ip.network}. "
|
f"Das Gateway {gateway_clean} liegt nicht im Netz {interface_ip.network}. "
|
||||||
"So waere der Host nach der Aenderung nicht mehr erreichbar."
|
"So wäre der Host nach der Änderung nicht mehr erreichbar."
|
||||||
)
|
)
|
||||||
|
|
||||||
servers: list[str] = []
|
servers: list[str] = []
|
||||||
@@ -163,7 +163,7 @@ def validate_static(
|
|||||||
try:
|
try:
|
||||||
ipaddress.ip_address(token)
|
ipaddress.ip_address(token)
|
||||||
except ValueError as exc:
|
except ValueError as exc:
|
||||||
raise ValueError(f"{token!r} ist keine gueltige DNS-Adresse.") from exc
|
raise ValueError(f"{token!r} ist keine gültige DNS-Adresse.") from exc
|
||||||
servers.append(token)
|
servers.append(token)
|
||||||
|
|
||||||
return str(interface_ip.ip), prefix_length, gateway_clean, servers
|
return str(interface_ip.ip), prefix_length, gateway_clean, servers
|
||||||
@@ -185,7 +185,7 @@ def render_netplan(
|
|||||||
zuruecksetzen -- ein realer Fehler des Vorgaengerprojekts.
|
zuruecksetzen -- ein realer Fehler des Vorgaengerprojekts.
|
||||||
"""
|
"""
|
||||||
lines = [
|
lines = [
|
||||||
"# Von TESM verwaltet. Diese Datei enthaelt ausschliesslich das unten",
|
"# Von TESM verwaltet. Diese Datei enthält ausschließlich das unten",
|
||||||
"# genannte Interface; alle anderen bleiben bei ihrer",
|
"# genannte Interface; alle anderen bleiben bei ihrer",
|
||||||
"# Distributionskonfiguration.",
|
"# Distributionskonfiguration.",
|
||||||
"network:",
|
"network:",
|
||||||
|
|||||||
@@ -94,7 +94,7 @@ def validate_domain(value: str) -> str:
|
|||||||
if not text:
|
if not text:
|
||||||
return ""
|
return ""
|
||||||
if not DOMAIN_RE.match(text):
|
if not DOMAIN_RE.match(text):
|
||||||
raise ValueError(f"{text!r} ist kein gueltiger Domainname.")
|
raise ValueError(f"{text!r} ist kein gültiger Domainname.")
|
||||||
return text
|
return text
|
||||||
|
|
||||||
|
|
||||||
@@ -122,8 +122,8 @@ def render(config: WebConfig) -> str:
|
|||||||
]
|
]
|
||||||
|
|
||||||
lines: list[str] = [
|
lines: list[str] = [
|
||||||
f"# Von TESM erzeugt ({config.app_key}). Aenderungen bitte in der Anwendung",
|
f"# Von TESM erzeugt ({config.app_key}). Änderungen bitte in der Anwendung",
|
||||||
"# vornehmen -- diese Datei wird bei jeder Uebernahme neu geschrieben.",
|
"# vornehmen – diese Datei wird bei jeder Übernahme neu geschrieben.",
|
||||||
"",
|
"",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -200,7 +200,7 @@ def _site_body(config: WebConfig, common_proxy: list[str], body: str, *, tls: bo
|
|||||||
f" client_max_body_size {body};",
|
f" client_max_body_size {body};",
|
||||||
f" # {uploads.NGINX_ZUSCHLAG_MB} MB mehr als die Anwendung erlaubt, damit deren"
|
f" # {uploads.NGINX_ZUSCHLAG_MB} MB mehr als die Anwendung erlaubt, damit deren"
|
||||||
" eigene,",
|
" eigene,",
|
||||||
" # erklaerende Fehlerseite gewinnt statt der generischen 413 von nginx.",
|
" # erklärende Fehlerseite gewinnt statt der generischen 413 von nginx.",
|
||||||
"",
|
"",
|
||||||
" location /assets/ {",
|
" location /assets/ {",
|
||||||
f" alias {config.static_root.rstrip('/')}/;",
|
f" alias {config.static_root.rstrip('/')}/;",
|
||||||
@@ -256,10 +256,10 @@ def apply_config(sysops: SysOps, content: str) -> dict[str, Any]:
|
|||||||
if not result.ok:
|
if not result.ok:
|
||||||
return {
|
return {
|
||||||
"ok": False,
|
"ok": False,
|
||||||
"message": "nginx hat die Konfiguration abgelehnt -- der alte Stand gilt weiter.",
|
"message": "nginx hat die Konfiguration abgelehnt – der alte Stand gilt weiter.",
|
||||||
"detail": result.output,
|
"detail": result.output,
|
||||||
}
|
}
|
||||||
return {"ok": True, "message": "Konfiguration uebernommen.", "detail": result.output}
|
return {"ok": True, "message": "Konfiguration übernommen.", "detail": result.output}
|
||||||
|
|
||||||
|
|
||||||
def request_certificate(
|
def request_certificate(
|
||||||
@@ -274,13 +274,13 @@ def request_certificate(
|
|||||||
if not domain:
|
if not domain:
|
||||||
return {"ok": False, "message": "Es ist keine Domain konfiguriert."}
|
return {"ok": False, "message": "Es ist keine Domain konfiguriert."}
|
||||||
if not email:
|
if not email:
|
||||||
return {"ok": False, "message": "Fuer Let's Encrypt ist eine E-Mail-Adresse noetig."}
|
return {"ok": False, "message": "Für Let's Encrypt ist eine E-Mail-Adresse nötig."}
|
||||||
result = sysops.certbot_issue(domain, email, webroot)
|
result = sysops.certbot_issue(domain, email, webroot)
|
||||||
if not result.ok:
|
if not result.ok:
|
||||||
return {"ok": False, "message": "certbot ist fehlgeschlagen.", "detail": result.output}
|
return {"ok": False, "message": "certbot ist fehlgeschlagen.", "detail": result.output}
|
||||||
return {
|
return {
|
||||||
"ok": True,
|
"ok": True,
|
||||||
"message": f"Zertifikat fuer {domain} ausgestellt.",
|
"message": f"Zertifikat für {domain} ausgestellt.",
|
||||||
"detail": result.output,
|
"detail": result.output,
|
||||||
"cert_path": f"/etc/letsencrypt/live/{domain}/fullchain.pem",
|
"cert_path": f"/etc/letsencrypt/live/{domain}/fullchain.pem",
|
||||||
"key_path": f"/etc/letsencrypt/live/{domain}/privkey.pem",
|
"key_path": f"/etc/letsencrypt/live/{domain}/privkey.pem",
|
||||||
@@ -303,9 +303,9 @@ def create_self_signed(
|
|||||||
"""
|
"""
|
||||||
name = (common_name or "").strip().lower().rstrip(".")
|
name = (common_name or "").strip().lower().rstrip(".")
|
||||||
if not name:
|
if not name:
|
||||||
return {"ok": False, "message": "Es fehlt ein Name fuer das Zertifikat."}
|
return {"ok": False, "message": "Es fehlt ein Name für das Zertifikat."}
|
||||||
if not re.fullmatch(r"[A-Za-z0-9]([A-Za-z0-9.-]{0,251}[A-Za-z0-9])?", name):
|
if not re.fullmatch(r"[A-Za-z0-9]([A-Za-z0-9.-]{0,251}[A-Za-z0-9])?", name):
|
||||||
return {"ok": False, "message": f"{name!r} ist als Zertifikatsname nicht zulaessig."}
|
return {"ok": False, "message": f"{name!r} ist als Zertifikatsname nicht zulässig."}
|
||||||
|
|
||||||
cleaned: list[str] = []
|
cleaned: list[str] = []
|
||||||
for entry in alt_names:
|
for entry in alt_names:
|
||||||
@@ -328,8 +328,8 @@ def create_self_signed(
|
|||||||
return {
|
return {
|
||||||
"ok": True,
|
"ok": True,
|
||||||
"message": (
|
"message": (
|
||||||
f"Selbstsigniertes Zertifikat fuer {name} erzeugt. Browser zeigen dafuer eine "
|
f"Selbstsigniertes Zertifikat für {name} erzeugt. Browser zeigen dafür eine "
|
||||||
"Warnung -- fuer ein oeffentlich erreichbares System bitte Let's Encrypt nutzen."
|
"Warnung – für ein öffentlich erreichbares System bitte Let's Encrypt nutzen."
|
||||||
),
|
),
|
||||||
"cert_path": paths[0],
|
"cert_path": paths[0],
|
||||||
"key_path": paths[1],
|
"key_path": paths[1],
|
||||||
|
|||||||
@@ -181,7 +181,7 @@
|
|||||||
<div class="palette__hint">
|
<div class="palette__hint">
|
||||||
<span><kbd>↑</kbd><kbd>↓</kbd> wählen</span>
|
<span><kbd>↑</kbd><kbd>↓</kbd> wählen</span>
|
||||||
<span><kbd>Enter</kbd> öffnen</span>
|
<span><kbd>Enter</kbd> öffnen</span>
|
||||||
<span><kbd>Esc</kbd> schliessen</span>
|
<span><kbd>Esc</kbd> schließen</span>
|
||||||
</div>
|
</div>
|
||||||
</dialog>
|
</dialog>
|
||||||
<script type="application/json" id="palette-data">{{ palette_entries | tojson }}</script>
|
<script type="application/json" id="palette-data">{{ palette_entries | tojson }}</script>
|
||||||
|
|||||||
@@ -24,7 +24,7 @@
|
|||||||
<strong>nicht</strong> bei jedem Neustart auf die Vorlage zurückgesetzt –
|
<strong>nicht</strong> bei jedem Neustart auf die Vorlage zurückgesetzt –
|
||||||
Anpassungen bleiben erhalten.
|
Anpassungen bleiben erhalten.
|
||||||
{% if current_user.has('groups.edit') %}
|
{% if current_user.has('groups.edit') %}
|
||||||
<a class="btn btn--sm mt-2" href="{{ url_for('admin.group_detail', group_id=group.id, freischalten=1) }}">
|
<a class="btn btn--sm mt-2" href="{{ url_for('admin.group_detail', group_id=group.id, unlocked=1) }}">
|
||||||
{{ icon('unlock', size=14) }} Rechte freischalten
|
{{ icon('unlock', size=14) }} Rechte freischalten
|
||||||
</a>
|
</a>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|||||||
@@ -26,26 +26,29 @@
|
|||||||
{{ icon('search', size=15) }}
|
{{ icon('search', size=15) }}
|
||||||
<input class="input" type="search" name="q" value="{{ filters.q }}" placeholder="Ziel, Detail, Aktion ...">
|
<input class="input" type="search" name="q" value="{{ filters.q }}" placeholder="Ziel, Detail, Aktion ...">
|
||||||
</span>
|
</span>
|
||||||
<select class="select" name="aktion" style="width:auto;max-width:240px">
|
<select class="select" name="action" style="width:auto;max-width:240px">
|
||||||
<option value="">alle Aktionen</option>
|
<option value="">alle Aktionen</option>
|
||||||
{% for action in actions %}
|
{% for action in actions %}
|
||||||
<option value="{{ action }}" {% if action == filters.aktion %}selected{% endif %}>{{ action }}</option>
|
<option value="{{ action }}" {% if action == filters.action %}selected{% endif %}>{{ action }}</option>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</select>
|
</select>
|
||||||
<select class="select" name="stufe" style="width:auto">
|
<select class="select" name="severity" style="width:auto">
|
||||||
<option value="">alle Stufen</option>
|
<option value="">alle Stufen</option>
|
||||||
{% for level in ['info', 'notice', 'warning', 'critical'] %}
|
{% for level in ['info', 'notice', 'warning', 'critical'] %}
|
||||||
<option value="{{ level }}" {% if level == filters.stufe %}selected{% endif %}>{{ level }}</option>
|
<option value="{{ level }}" {% if level == filters.severity %}selected{% endif %}>{{ level }}</option>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</select>
|
</select>
|
||||||
<input class="input" type="text" name="benutzer" value="{{ filters.benutzer }}"
|
<input class="input" type="text" name="actor" value="{{ filters.actor }}"
|
||||||
placeholder="Benutzer" style="width:150px">
|
placeholder="Benutzer" style="width:150px">
|
||||||
<button class="btn btn--sm" type="submit">Filtern</button>
|
<button class="btn btn--sm" type="submit">Filtern</button>
|
||||||
<a class="btn btn--sm btn--ghost" href="{{ url_for('logs.audit_view') }}">Zurücksetzen</a>
|
<a class="btn btn--sm btn--ghost" href="{{ url_for('logs.audit_view') }}">Zurücksetzen</a>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
|
{# "table--stapelbar": schmal wird aus jeder Zeile ein kleiner Block. Vorher
|
||||||
|
quetschte sich das Detail mit Pruefsumme in eine Spalte von zwei
|
||||||
|
Zentimetern und lief ueber zehn Zeilen -- lesbar war das nicht. #}
|
||||||
<div class="table-wrap">
|
<div class="table-wrap">
|
||||||
<table class="table table--compact" data-behavior="sortable">
|
<table class="table table--compact table--stapelbar" data-behavior="sortable">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th class="sortable">Zeit</th>
|
<th class="sortable">Zeit</th>
|
||||||
@@ -60,12 +63,18 @@
|
|||||||
<tbody>
|
<tbody>
|
||||||
{% for row in rows %}
|
{% for row in rows %}
|
||||||
<tr>
|
<tr>
|
||||||
<td class="fs-sm nowrap" data-sort-value="{{ row.ts }}">{{ row.ts | dt }}</td>
|
<td class="fs-sm nowrap" data-spalte="Zeit" data-sort-value="{{ row.ts }}">
|
||||||
<td class="fs-sm fw-600">{{ row.actor }}</td>
|
{{ row.ts | dt }}
|
||||||
<td class="mono fs-xs">{{ row.action }}</td>
|
</td>
|
||||||
<td class="fs-sm">{{ row.target }}</td>
|
<td class="fs-sm fw-600" data-spalte="Benutzer">{{ row.actor }}</td>
|
||||||
<td class="fs-sm muted break-all">{{ row.detail | audit_detail }}</td>
|
<td class="mono fs-xs" data-spalte="Aktion">{{ row.action }}</td>
|
||||||
<td>
|
<td class="fs-sm" data-spalte="Ziel">{{ row.target }}</td>
|
||||||
|
{# Ohne die Minuszeichen stuende bei "white-space: pre-line" die
|
||||||
|
Einrueckung der Vorlage als leere erste Zeile in der Zelle. #}
|
||||||
|
<td class="fs-sm muted break-all td--detail" data-spalte="Detail">
|
||||||
|
{{- row.detail | audit_detail -}}
|
||||||
|
</td>
|
||||||
|
<td data-spalte="Stufe">
|
||||||
{# Ampel: Info grün, Hinweis gelb, Warnung rot. Vorher war Info
|
{# Ampel: Info grün, Hinweis gelb, Warnung rot. Vorher war Info
|
||||||
grau und Hinweis blau -- dabei war nicht zu sehen, was harmlos
|
grau und Hinweis blau -- dabei war nicht zu sehen, was harmlos
|
||||||
ist und was Aufmerksamkeit braucht. "kritisch" bekommt eine
|
ist und was Aufmerksamkeit braucht. "kritisch" bekommt eine
|
||||||
@@ -76,7 +85,7 @@
|
|||||||
{% elif row.severity == 'notice' %}{{ pill('Hinweis', 'warning') }}
|
{% elif row.severity == 'notice' %}{{ pill('Hinweis', 'warning') }}
|
||||||
{% else %}{{ pill('Info', 'online') }}{% endif %}
|
{% else %}{{ pill('Info', 'online') }}{% endif %}
|
||||||
</td>
|
</td>
|
||||||
<td class="mono fs-xs faint">{{ row.ip or '–' | safe }}</td>
|
<td class="mono fs-xs faint" data-spalte="Herkunft">{{ row.ip or '–' | safe }}</td>
|
||||||
</tr>
|
</tr>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</tbody>
|
</tbody>
|
||||||
@@ -88,10 +97,10 @@
|
|||||||
<span class="fs-sm muted">Seite {{ page }}</span>
|
<span class="fs-sm muted">Seite {{ page }}</span>
|
||||||
<span class="row row--tight">
|
<span class="row row--tight">
|
||||||
{% if page > 1 %}
|
{% if page > 1 %}
|
||||||
<a class="btn btn--sm" href="{{ url_for('logs.audit_view', seite=page-1, **filters) }}">Zurück</a>
|
<a class="btn btn--sm" href="{{ url_for('logs.audit_view', page=page-1, **filters) }}">Zurück</a>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% if rows | length == per_page %}
|
{% if rows | length == per_page %}
|
||||||
<a class="btn btn--sm" href="{{ url_for('logs.audit_view', seite=page+1, **filters) }}">Weiter</a>
|
<a class="btn btn--sm" href="{{ url_for('logs.audit_view', page=page+1, **filters) }}">Weiter</a>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -235,3 +235,69 @@
|
|||||||
die Spalte gehört zu einer anderen Zeile desselben Bereichs.</span>
|
die Spalte gehört zu einer anderen Zeile desselben Bereichs.</span>
|
||||||
</div>
|
</div>
|
||||||
{%- endmacro %}
|
{%- endmacro %}
|
||||||
|
|
||||||
|
{# --------------------------------------------------------------------------
|
||||||
|
Navigationsreihenfolge. Die Reihenfolge der versteckten Felder ist die
|
||||||
|
Reihenfolge -- verschiebt sich eine Zeile, verschiebt sich das Feld mit ihr.
|
||||||
|
Ziehen mit der Maus oder die zwei Knoepfe je Zeile; auf einem Tablet sind
|
||||||
|
die Knoepfe der verlaessliche Weg.
|
||||||
|
-------------------------------------------------------------------------- #}
|
||||||
|
{% macro _reorder_row(item, field_name, label) -%}
|
||||||
|
<li class="reorder__row" data-reorder-item draggable="true">
|
||||||
|
<span class="reorder__grip" aria-hidden="true">{{ icon('menu', size=14) }}</span>
|
||||||
|
<span class="reorder__pos" data-reorder-position-text>1</span>
|
||||||
|
<input type="hidden" name="{{ field_name }}" value="{{ item.key }}">
|
||||||
|
<span class="flex-1">{{ icon(item.icon, size=15) }} {{ item.label }}</span>
|
||||||
|
<span class="reorder__moves">
|
||||||
|
<button class="btn btn--icon btn--sm" type="button" data-reorder-move="up"
|
||||||
|
aria-label="{{ label }} nach oben">{{ icon('chevron-up', size=14) }}</button>
|
||||||
|
<button class="btn btn--icon btn--sm" type="button" data-reorder-move="down"
|
||||||
|
aria-label="{{ label }} nach unten">{{ icon('chevron-down', size=14) }}</button>
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
{%- endmacro %}
|
||||||
|
|
||||||
|
{% macro nav_order_card(items, order, child_order, action, csrf_field, csrf_value,
|
||||||
|
sub='Gilt für alle Benutzer; jeder sieht weiter nur, was er darf') -%}
|
||||||
|
<section class="card">
|
||||||
|
<div class="card__header">
|
||||||
|
<div class="flex-1">
|
||||||
|
<div class="card__title">{{ icon('sliders', size=17) }} Navigationsreihenfolge</div>
|
||||||
|
<div class="card__sub">{{ sub }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<form method="post" action="{{ action }}">
|
||||||
|
<input type="hidden" name="{{ csrf_field }}" value="{{ csrf_value }}">
|
||||||
|
<div class="card__body">
|
||||||
|
<ol class="reorder" data-behavior="reorder">
|
||||||
|
{% for key in order %}
|
||||||
|
{% set item = items | selectattr('key', 'equalto', key) | list | first %}
|
||||||
|
{% if item %}
|
||||||
|
{{ _reorder_row(item, 'order', item.label) }}
|
||||||
|
{% if item.children %}
|
||||||
|
<li class="reorder__group">
|
||||||
|
<ol class="reorder reorder--child" data-behavior="reorder">
|
||||||
|
{% set kinder = child_order.get(item.key) or (item.children | map(attribute='key') | list) %}
|
||||||
|
{% for child_key in kinder %}
|
||||||
|
{% set child = item.children | selectattr('key', 'equalto', child_key) | list | first %}
|
||||||
|
{% if child %}
|
||||||
|
{{ _reorder_row(child, 'child_order_' ~ item.key, item.label ~ ' / ' ~ child.label) }}
|
||||||
|
{% endif %}
|
||||||
|
{% endfor %}
|
||||||
|
</ol>
|
||||||
|
</li>
|
||||||
|
{% endif %}
|
||||||
|
{% endif %}
|
||||||
|
{% endfor %}
|
||||||
|
</ol>
|
||||||
|
<p class="fs-xs faint mt-3">
|
||||||
|
Zeilen ziehen oder mit den Pfeilen verschieben, dann speichern. Untergeordnete
|
||||||
|
Punkte werden innerhalb ihrer Gruppe sortiert.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="card__footer">
|
||||||
|
<button class="btn btn--sm" type="submit">Reihenfolge speichern</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
{%- endmacro %}
|
||||||
@@ -2,7 +2,7 @@
|
|||||||
{% from "tesm_core/macros.html" import field, select_field, checkbox, pill, search_box, modal, empty_state, alert %}
|
{% from "tesm_core/macros.html" import field, select_field, checkbox, pill, search_box, modal, empty_state, alert %}
|
||||||
|
|
||||||
{% block page_title %}Benutzer{% endblock %}
|
{% block page_title %}Benutzer{% endblock %}
|
||||||
{% block page_sub %}{{ users | length }} Konten · Rechte kommen ausschliesslich aus Gruppen{% endblock %}
|
{% block page_sub %}{{ users | length }} Konten · Rechte kommen ausschließlich aus Gruppen{% endblock %}
|
||||||
|
|
||||||
{% block topbar_extra %}
|
{% block topbar_extra %}
|
||||||
{{ search_box('users-table', 'Benutzer suchen ...', count_id='users-visible') }}
|
{{ search_box('users-table', 'Benutzer suchen ...', count_id='users-visible') }}
|
||||||
|
|||||||
@@ -84,7 +84,7 @@ def _derive_key(passphrase: str, salt: bytes, params: dict[str, int]) -> bytes:
|
|||||||
def seal(payload: dict[str, Any], passphrase: str, *, app_tag: str) -> str:
|
def seal(payload: dict[str, Any], passphrase: str, *, app_tag: str) -> str:
|
||||||
if len(passphrase or "") < MIN_PASSPHRASE_LENGTH:
|
if len(passphrase or "") < MIN_PASSPHRASE_LENGTH:
|
||||||
raise TransferError(
|
raise TransferError(
|
||||||
f"Die Passphrase muss mindestens {MIN_PASSPHRASE_LENGTH} Zeichen haben -- sie ist "
|
f"Die Passphrase muss mindestens {MIN_PASSPHRASE_LENGTH} Zeichen haben – sie ist "
|
||||||
"der einzige Schutz dieser Datei."
|
"der einzige Schutz dieser Datei."
|
||||||
)
|
)
|
||||||
salt = secrets.token_bytes(KDF_SALT_BYTES)
|
salt = secrets.token_bytes(KDF_SALT_BYTES)
|
||||||
@@ -116,21 +116,21 @@ def unseal(
|
|||||||
try:
|
try:
|
||||||
envelope = json.loads(text)
|
envelope = json.loads(text)
|
||||||
except ValueError as exc:
|
except ValueError as exc:
|
||||||
raise TransferError("Die Datei ist kein gueltiges JSON.") from exc
|
raise TransferError("Die Datei ist kein gültiges JSON.") from exc
|
||||||
if not isinstance(envelope, dict):
|
if not isinstance(envelope, dict):
|
||||||
raise TransferError("Die Datei enthaelt kein Objekt.")
|
raise TransferError("Die Datei enthält kein Objekt.")
|
||||||
if envelope.get("app") != app_tag:
|
if envelope.get("app") != app_tag:
|
||||||
raise TransferError(
|
raise TransferError(
|
||||||
f"Diese Sicherung gehoert zu einer anderen Anwendung ({envelope.get('app')!r})."
|
f"Diese Sicherung gehört zu einer anderen Anwendung ({envelope.get('app')!r})."
|
||||||
)
|
)
|
||||||
if int(envelope.get("format") or 0) != FORMAT_VERSION:
|
if int(envelope.get("format") or 0) != FORMAT_VERSION:
|
||||||
raise TransferError(
|
raise TransferError(
|
||||||
f"Nicht unterstuetztes Sicherungsformat ({envelope.get('format')!r}, "
|
f"Nicht unterstütztes Sicherungsformat ({envelope.get('format')!r}, "
|
||||||
f"erwartet {FORMAT_VERSION})."
|
f"erwartet {FORMAT_VERSION})."
|
||||||
)
|
)
|
||||||
kdf = envelope.get("kdf") or {}
|
kdf = envelope.get("kdf") or {}
|
||||||
if kdf.get("name") != "argon2id":
|
if kdf.get("name") != "argon2id":
|
||||||
raise TransferError("Unbekanntes Schluesselableitungsverfahren.")
|
raise TransferError("Unbekanntes Schlüsselableitungsverfahren.")
|
||||||
|
|
||||||
header = {
|
header = {
|
||||||
key: envelope[key]
|
key: envelope[key]
|
||||||
@@ -153,7 +153,7 @@ def unseal(
|
|||||||
)
|
)
|
||||||
except (InvalidTag, KeyError, ValueError) as exc:
|
except (InvalidTag, KeyError, ValueError) as exc:
|
||||||
raise TransferError(
|
raise TransferError(
|
||||||
"Die Datei konnte nicht geoeffnet werden. Passphrase falsch oder Datei beschaedigt."
|
"Die Datei konnte nicht geöffnet werden. Passphrase falsch oder Datei beschädigt."
|
||||||
) from exc
|
) from exc
|
||||||
return json.loads(plain.decode("utf-8")), header
|
return json.loads(plain.decode("utf-8")), header
|
||||||
|
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ ICONS: dict[str, str] = {
|
|||||||
"menu": '<path d="M4 6h16M4 12h16M4 18h16"/>',
|
"menu": '<path d="M4 6h16M4 12h16M4 18h16"/>',
|
||||||
"chevron-right": '<path d="m9 6 6 6-6 6"/>',
|
"chevron-right": '<path d="m9 6 6 6-6 6"/>',
|
||||||
"chevron-down": '<path d="m6 9 6 6 6-6"/>',
|
"chevron-down": '<path d="m6 9 6 6 6-6"/>',
|
||||||
|
"chevron-up": '<path d="m6 15 6-6 6 6"/>',
|
||||||
"sun": '<circle cx="12" cy="12" r="4"/><path d="M12 2v2M12 20v2M2 12h2M20 12h2M5 5l1.5 1.5M17.5 17.5 19 19M19 5l-1.5 1.5M6.5 17.5 5 19"/>',
|
"sun": '<circle cx="12" cy="12" r="4"/><path d="M12 2v2M12 20v2M2 12h2M20 12h2M5 5l1.5 1.5M17.5 17.5 19 19M19 5l-1.5 1.5M6.5 17.5 5 19"/>',
|
||||||
"moon": '<path d="M20 14.5A8.5 8.5 0 1 1 9.5 4a7 7 0 0 0 10.5 10.5z"/>',
|
"moon": '<path d="M20 14.5A8.5 8.5 0 1 1 9.5 4a7 7 0 0 0 10.5 10.5z"/>',
|
||||||
"monitor": '<rect x="2" y="4" width="20" height="13" rx="2"/><path d="M8 21h8M12 17v4"/>',
|
"monitor": '<rect x="2" y="4" width="20" height="13" rx="2"/><path d="M8 21h8M12 17v4"/>',
|
||||||
@@ -196,7 +197,7 @@ def yes_no(value: Any) -> str:
|
|||||||
#: Tippfehler. Uebersetzt wird an genau einer Stelle, damit die beiden
|
#: Tippfehler. Uebersetzt wird an genau einer Stelle, damit die beiden
|
||||||
#: Anwendungen nicht auseinanderlaufen.
|
#: Anwendungen nicht auseinanderlaufen.
|
||||||
ISSUER_SOURCES: dict[str, tuple[str, str]] = {
|
ISSUER_SOURCES: dict[str, tuple[str, str]] = {
|
||||||
"tofu": ("Erstkontakt", "beim ersten Einspielen automatisch uebernommen"),
|
"tofu": ("Erstkontakt", "beim ersten Einspielen automatisch übernommen"),
|
||||||
"admin-confirmed": (
|
"admin-confirmed": (
|
||||||
"Bestaetigt",
|
"Bestaetigt",
|
||||||
"Ausstellerwechsel von einem Administrator freigegeben",
|
"Ausstellerwechsel von einem Administrator freigegeben",
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ from ..extension import core, db, transaction
|
|||||||
from ..rbac import service as rbac
|
from ..rbac import service as rbac
|
||||||
from ..security import passwords, sessions as session_store, totp
|
from ..security import passwords, sessions as session_store, totp
|
||||||
|
|
||||||
bp = Blueprint("account", __name__, url_prefix="/konto")
|
bp = Blueprint("account", __name__, url_prefix="/account")
|
||||||
|
|
||||||
ALLOWED_AVATAR_SUFFIXES = {".png", ".jpg", ".jpeg", ".webp", ".gif"}
|
ALLOWED_AVATAR_SUFFIXES = {".png", ".jpg", ".jpeg", ".webp", ".gif"}
|
||||||
MAX_AVATAR_BYTES = 2 * 1024 * 1024
|
MAX_AVATAR_BYTES = 2 * 1024 * 1024
|
||||||
@@ -54,6 +54,19 @@ def _avatar_dir() -> Path:
|
|||||||
|
|
||||||
|
|
||||||
def _audit(conn, action: str, detail: str = "", severity: str = "info") -> None:
|
def _audit(conn, action: str, detail: str = "", severity: str = "info") -> None:
|
||||||
|
"""Protokolliert eine Aenderung am **eigenen** Konto.
|
||||||
|
|
||||||
|
Bewusst sparsam benutzt. Das Aenderungsprotokoll beantwortet die Frage
|
||||||
|
"wer hat woran etwas geaendert" -- und wer sein eigenes Passwort setzt,
|
||||||
|
ein Profilbild tauscht oder seine Anschrift korrigiert, aendert nichts,
|
||||||
|
was jemand anderen betrifft. Solche Zeilen fuellten das Protokoll und
|
||||||
|
schoben die Eintraege aus dem Blick, um die es geht.
|
||||||
|
|
||||||
|
Was hier trotzdem stehen bleibt: alles rund um den zweiten Faktor. Ob ein
|
||||||
|
Konto mit oder ohne zweiten Faktor geschuetzt ist, aendert die Staerke der
|
||||||
|
Anmeldung -- das ist die eine Selbstbedienung, nach der man nach einem
|
||||||
|
Vorfall sucht.
|
||||||
|
"""
|
||||||
audit.log(
|
audit.log(
|
||||||
conn,
|
conn,
|
||||||
actor=current_user.username,
|
actor=current_user.username,
|
||||||
@@ -80,13 +93,13 @@ def overview():
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@bp.post("/stammdaten")
|
@bp.post("/profile")
|
||||||
@login_required
|
@login_required
|
||||||
def update_profile():
|
def update_profile():
|
||||||
if current_user.is_directory_account:
|
if current_user.is_directory_account:
|
||||||
flash(
|
flash(
|
||||||
"Ihre Stammdaten kommen aus dem Verzeichnisdienst und werden bei jeder "
|
"Ihre Stammdaten kommen aus dem Verzeichnisdienst und werden bei jeder "
|
||||||
"Anmeldung uebernommen.",
|
"Anmeldung übernommen.",
|
||||||
"info",
|
"info",
|
||||||
)
|
)
|
||||||
return redirect(url_for("account.overview"))
|
return redirect(url_for("account.overview"))
|
||||||
@@ -101,17 +114,16 @@ def update_profile():
|
|||||||
current_user.id,
|
current_user.id,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
_audit(conn, "account.profile_updated")
|
|
||||||
flash("Stammdaten gespeichert.", "success")
|
flash("Stammdaten gespeichert.", "success")
|
||||||
return redirect(url_for("account.overview"))
|
return redirect(url_for("account.overview"))
|
||||||
|
|
||||||
|
|
||||||
@bp.post("/profilbild")
|
@bp.post("/avatar")
|
||||||
@login_required
|
@login_required
|
||||||
def upload_avatar():
|
def upload_avatar():
|
||||||
upload = request.files.get("avatar")
|
upload = request.files.get("avatar")
|
||||||
if upload is None or not upload.filename:
|
if upload is None or not upload.filename:
|
||||||
flash("Keine Datei ausgewaehlt.", "danger")
|
flash("Keine Datei ausgewählt.", "danger")
|
||||||
return redirect(url_for("account.overview"))
|
return redirect(url_for("account.overview"))
|
||||||
|
|
||||||
suffix = Path(upload.filename).suffix.lower()
|
suffix = Path(upload.filename).suffix.lower()
|
||||||
@@ -127,7 +139,7 @@ def upload_avatar():
|
|||||||
|
|
||||||
data = upload.stream.read(MAX_AVATAR_BYTES + 1)
|
data = upload.stream.read(MAX_AVATAR_BYTES + 1)
|
||||||
if len(data) > MAX_AVATAR_BYTES:
|
if len(data) > MAX_AVATAR_BYTES:
|
||||||
flash("Das Bild ist zu gross (maximal 2 MB).", "danger")
|
flash("Das Bild ist zu groß (maximal 2 MB).", "danger")
|
||||||
return redirect(url_for("account.overview"))
|
return redirect(url_for("account.overview"))
|
||||||
|
|
||||||
filename = f"{current_user.id}-{secrets.token_hex(8)}{suffix}"
|
filename = f"{current_user.id}-{secrets.token_hex(8)}{suffix}"
|
||||||
@@ -142,14 +154,13 @@ def upload_avatar():
|
|||||||
"UPDATE users SET avatar_filename=?, updated_at=? WHERE id=?",
|
"UPDATE users SET avatar_filename=?, updated_at=? WHERE id=?",
|
||||||
(target.name, datetime.now(timezone.utc).isoformat(timespec="seconds"), current_user.id),
|
(target.name, datetime.now(timezone.utc).isoformat(timespec="seconds"), current_user.id),
|
||||||
)
|
)
|
||||||
_audit(conn, "account.avatar_updated")
|
|
||||||
if previous:
|
if previous:
|
||||||
(_avatar_dir() / str(previous)).unlink(missing_ok=True)
|
(_avatar_dir() / str(previous)).unlink(missing_ok=True)
|
||||||
flash("Profilbild aktualisiert.", "success")
|
flash("Profilbild aktualisiert.", "success")
|
||||||
return redirect(url_for("account.overview"))
|
return redirect(url_for("account.overview"))
|
||||||
|
|
||||||
|
|
||||||
@bp.get("/profilbild/<path:filename>")
|
@bp.get("/avatar/<path:filename>")
|
||||||
@login_required
|
@login_required
|
||||||
def avatar(filename: str):
|
def avatar(filename: str):
|
||||||
safe = secure_filename(filename)
|
safe = secure_filename(filename)
|
||||||
@@ -163,7 +174,7 @@ def avatar(filename: str):
|
|||||||
# --------------------------------------------------------------------------- #
|
# --------------------------------------------------------------------------- #
|
||||||
|
|
||||||
|
|
||||||
@bp.get("/sicherheit")
|
@bp.get("/security")
|
||||||
@login_required
|
@login_required
|
||||||
def security():
|
def security():
|
||||||
conn = db()
|
conn = db()
|
||||||
@@ -199,12 +210,12 @@ def security():
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@bp.post("/passwort")
|
@bp.post("/password")
|
||||||
@login_required
|
@login_required
|
||||||
def change_password():
|
def change_password():
|
||||||
conn = db()
|
conn = db()
|
||||||
if current_user.is_directory_account:
|
if current_user.is_directory_account:
|
||||||
flash("Verzeichniskonten aendern ihr Passwort im Verzeichnisdienst.", "info")
|
flash("Verzeichniskonten ändern ihr Passwort im Verzeichnisdienst.", "info")
|
||||||
return redirect(url_for("account.security"))
|
return redirect(url_for("account.security"))
|
||||||
|
|
||||||
current = request.form.get("current_password") or ""
|
current = request.form.get("current_password") or ""
|
||||||
@@ -215,7 +226,7 @@ def change_password():
|
|||||||
flash("Das aktuelle Passwort ist nicht korrekt.", "danger")
|
flash("Das aktuelle Passwort ist nicht korrekt.", "danger")
|
||||||
return redirect(url_for("account.security"))
|
return redirect(url_for("account.security"))
|
||||||
if new != repeat:
|
if new != repeat:
|
||||||
flash("Die beiden neuen Passwoerter stimmen nicht ueberein.", "danger")
|
flash("Die beiden neuen Passwörter stimmen nicht überein.", "danger")
|
||||||
return redirect(url_for("account.security"))
|
return redirect(url_for("account.security"))
|
||||||
if passwords.constant_time_equals(current, new):
|
if passwords.constant_time_equals(current, new):
|
||||||
flash("Das neue Passwort muss sich vom alten unterscheiden.", "danger")
|
flash("Das neue Passwort muss sich vom alten unterscheiden.", "danger")
|
||||||
@@ -229,36 +240,30 @@ def change_password():
|
|||||||
revoked = session_store.revoke_all_for_user(
|
revoked = session_store.revoke_all_for_user(
|
||||||
conn, current_user.id, except_sid=getattr(session, "sid", ""), reason="password-change"
|
conn, current_user.id, except_sid=getattr(session, "sid", ""), reason="password-change"
|
||||||
)
|
)
|
||||||
_audit(
|
|
||||||
conn,
|
|
||||||
"account.password_changed",
|
|
||||||
f"{revoked} weitere Sitzungen beendet",
|
|
||||||
severity="notice",
|
|
||||||
)
|
|
||||||
except rbac.ValidationError as exc:
|
except rbac.ValidationError as exc:
|
||||||
flash(str(exc), "danger")
|
flash(str(exc), "danger")
|
||||||
return redirect(url_for("account.security"))
|
return redirect(url_for("account.security"))
|
||||||
|
|
||||||
flash("Passwort geaendert. Andere Sitzungen wurden beendet.", "success")
|
flash(f"Passwort geändert. {revoked} weitere Sitzung(en) wurden beendet.", "success")
|
||||||
return redirect(url_for("account.security"))
|
return redirect(url_for("account.security"))
|
||||||
|
|
||||||
|
|
||||||
@bp.post("/zwei-faktor/starten")
|
@bp.post("/two-factor/start")
|
||||||
@login_required
|
@login_required
|
||||||
@reauth_required
|
@reauth_required
|
||||||
def totp_start():
|
def totp_start():
|
||||||
session["_totp_setup"] = totp.generate_secret()
|
session["_totp_setup"] = totp.generate_secret()
|
||||||
flash("Scannen Sie den Code und bestaetigen Sie mit einem gueltigen Zahlencode.", "info")
|
flash("Scannen Sie den Code und bestätigen Sie mit einem gültigen Zahlencode.", "info")
|
||||||
return redirect(url_for("account.security"))
|
return redirect(url_for("account.security"))
|
||||||
|
|
||||||
|
|
||||||
@bp.post("/zwei-faktor/bestaetigen")
|
@bp.post("/two-factor/confirm")
|
||||||
@login_required
|
@login_required
|
||||||
@reauth_required
|
@reauth_required
|
||||||
def totp_confirm():
|
def totp_confirm():
|
||||||
secret = session.get("_totp_setup", "")
|
secret = session.get("_totp_setup", "")
|
||||||
if not secret:
|
if not secret:
|
||||||
flash("Es laeuft keine Einrichtung. Bitte neu starten.", "danger")
|
flash("Es läuft keine Einrichtung. Bitte neu starten.", "danger")
|
||||||
return redirect(url_for("account.security"))
|
return redirect(url_for("account.security"))
|
||||||
if not totp.verify_code(secret, request.form.get("code") or ""):
|
if not totp.verify_code(secret, request.form.get("code") or ""):
|
||||||
flash("Der Code ist nicht korrekt. Bitte erneut versuchen.", "danger")
|
flash("Der Code ist nicht korrekt. Bitte erneut versuchen.", "danger")
|
||||||
@@ -295,13 +300,13 @@ def totp_confirm():
|
|||||||
return redirect(url_for("account.security"))
|
return redirect(url_for("account.security"))
|
||||||
|
|
||||||
|
|
||||||
@bp.post("/zwei-faktor/deaktivieren")
|
@bp.post("/two-factor/disable")
|
||||||
@login_required
|
@login_required
|
||||||
@reauth_required
|
@reauth_required
|
||||||
def totp_disable():
|
def totp_disable():
|
||||||
if core().config.security.require_totp_for_admins and current_user.is_admin:
|
if core().config.security.require_totp_for_admins and current_user.is_admin:
|
||||||
flash(
|
flash(
|
||||||
"Fuer Administratoren ist die Zwei-Faktor-Authentisierung in dieser "
|
"Für Administratoren ist die Zwei-Faktor-Authentisierung in dieser "
|
||||||
"Installation verpflichtend.",
|
"Installation verpflichtend.",
|
||||||
"danger",
|
"danger",
|
||||||
)
|
)
|
||||||
@@ -317,7 +322,7 @@ def totp_disable():
|
|||||||
return redirect(url_for("account.security"))
|
return redirect(url_for("account.security"))
|
||||||
|
|
||||||
|
|
||||||
@bp.post("/zwei-faktor/codes-erneuern")
|
@bp.post("/two-factor/recovery-codes")
|
||||||
@login_required
|
@login_required
|
||||||
@reauth_required
|
@reauth_required
|
||||||
def recovery_codes():
|
def recovery_codes():
|
||||||
@@ -341,7 +346,7 @@ def recovery_codes():
|
|||||||
return redirect(url_for("account.security"))
|
return redirect(url_for("account.security"))
|
||||||
|
|
||||||
|
|
||||||
@bp.post("/sitzungen/<sid>/beenden")
|
@bp.post("/sessions/<sid>/end")
|
||||||
@login_required
|
@login_required
|
||||||
def revoke_session(sid: str):
|
def revoke_session(sid: str):
|
||||||
conn = db()
|
conn = db()
|
||||||
@@ -350,18 +355,16 @@ def revoke_session(sid: str):
|
|||||||
abort(404)
|
abort(404)
|
||||||
with transaction():
|
with transaction():
|
||||||
session_store.revoke(conn, sid, reason="self")
|
session_store.revoke(conn, sid, reason="self")
|
||||||
_audit(conn, "account.session_revoked", sid[:12])
|
|
||||||
flash("Sitzung beendet.", "success")
|
flash("Sitzung beendet.", "success")
|
||||||
return redirect(url_for("account.security"))
|
return redirect(url_for("account.security"))
|
||||||
|
|
||||||
|
|
||||||
@bp.post("/sitzungen/alle-beenden")
|
@bp.post("/sessions/end-all")
|
||||||
@login_required
|
@login_required
|
||||||
def revoke_other_sessions():
|
def revoke_other_sessions():
|
||||||
with transaction() as conn:
|
with transaction() as conn:
|
||||||
count = session_store.revoke_all_for_user(
|
count = session_store.revoke_all_for_user(
|
||||||
conn, current_user.id, except_sid=getattr(session, "sid", ""), reason="self"
|
conn, current_user.id, except_sid=getattr(session, "sid", ""), reason="self"
|
||||||
)
|
)
|
||||||
_audit(conn, "account.sessions_revoked", f"{count} Sitzungen")
|
|
||||||
flash(f"{count} weitere Sitzung(en) beendet.", "success")
|
flash(f"{count} weitere Sitzung(en) beendet.", "success")
|
||||||
return redirect(url_for("account.security"))
|
return redirect(url_for("account.security"))
|
||||||
@@ -16,7 +16,7 @@ from ..rbac import service as rbac
|
|||||||
from ..rbac.model import ACTION_META, Action
|
from ..rbac.model import ACTION_META, Action
|
||||||
from ..security import sessions as session_store
|
from ..security import sessions as session_store
|
||||||
|
|
||||||
bp = Blueprint("admin", __name__, url_prefix="/verwaltung")
|
bp = Blueprint("admin", __name__, url_prefix="/administration")
|
||||||
|
|
||||||
|
|
||||||
def _audit(conn: Any, action: str, target: str = "", detail: str = "", severity: str = "info") -> None:
|
def _audit(conn: Any, action: str, target: str = "", detail: str = "", severity: str = "info") -> None:
|
||||||
@@ -47,7 +47,7 @@ def _int_list(name: str) -> list[int]:
|
|||||||
# --------------------------------------------------------------------------- #
|
# --------------------------------------------------------------------------- #
|
||||||
|
|
||||||
|
|
||||||
@bp.get("/benutzer")
|
@bp.get("/users")
|
||||||
@permission_required("users.view")
|
@permission_required("users.view")
|
||||||
def users():
|
def users():
|
||||||
conn = db()
|
conn = db()
|
||||||
@@ -60,7 +60,7 @@ def users():
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@bp.post("/benutzer/neu")
|
@bp.post("/users/new")
|
||||||
@permission_required("users.create")
|
@permission_required("users.create")
|
||||||
def user_create():
|
def user_create():
|
||||||
conn = db()
|
conn = db()
|
||||||
@@ -94,7 +94,7 @@ def user_create():
|
|||||||
return redirect(url_for("admin.users"))
|
return redirect(url_for("admin.users"))
|
||||||
|
|
||||||
|
|
||||||
@bp.post("/benutzer/<int:user_id>")
|
@bp.post("/users/<int:user_id>")
|
||||||
@permission_required("users.edit")
|
@permission_required("users.edit")
|
||||||
def user_update(user_id: int):
|
def user_update(user_id: int):
|
||||||
conn = db()
|
conn = db()
|
||||||
@@ -120,7 +120,7 @@ def user_update(user_id: int):
|
|||||||
policy=core().password_policy,
|
policy=core().password_policy,
|
||||||
must_change=True,
|
must_change=True,
|
||||||
)
|
)
|
||||||
changes.append("Passwort zurueckgesetzt")
|
changes.append("Passwort zurückgesetzt")
|
||||||
session_store.revoke_all_for_user(conn, user_id, reason="password-reset")
|
session_store.revoke_all_for_user(conn, user_id, reason="password-reset")
|
||||||
target = rbac.get_user(conn, user_id) or {}
|
target = rbac.get_user(conn, user_id) or {}
|
||||||
if changes:
|
if changes:
|
||||||
@@ -131,13 +131,13 @@ def user_update(user_id: int):
|
|||||||
"; ".join(changes),
|
"; ".join(changes),
|
||||||
severity="notice",
|
severity="notice",
|
||||||
)
|
)
|
||||||
flash("Aenderungen gespeichert." if changes else "Keine Aenderungen.", "success" if changes else "info")
|
flash("Änderungen gespeichert." if changes else "Keine Änderungen.", "success" if changes else "info")
|
||||||
except (rbac.ValidationError, rbac.PermissionDenied) as exc:
|
except (rbac.ValidationError, rbac.PermissionDenied) as exc:
|
||||||
flash(str(exc), "danger")
|
flash(str(exc), "danger")
|
||||||
return redirect(url_for("admin.users"))
|
return redirect(url_for("admin.users"))
|
||||||
|
|
||||||
|
|
||||||
@bp.post("/benutzer/<int:user_id>/loeschen")
|
@bp.post("/users/<int:user_id>/delete")
|
||||||
@permission_required("users.delete")
|
@permission_required("users.delete")
|
||||||
def user_delete(user_id: int):
|
def user_delete(user_id: int):
|
||||||
conn = db()
|
conn = db()
|
||||||
@@ -153,7 +153,7 @@ def user_delete(user_id: int):
|
|||||||
severity="warning",
|
severity="warning",
|
||||||
)
|
)
|
||||||
flash(
|
flash(
|
||||||
"Konto endgueltig entfernt (Verzeichniskonto)."
|
"Konto endgültig entfernt (Verzeichniskonto)."
|
||||||
if mode == "endgueltig"
|
if mode == "endgueltig"
|
||||||
else "Konto in den Papierkorb verschoben.",
|
else "Konto in den Papierkorb verschoben.",
|
||||||
"success",
|
"success",
|
||||||
@@ -163,7 +163,7 @@ def user_delete(user_id: int):
|
|||||||
return redirect(url_for("admin.users"))
|
return redirect(url_for("admin.users"))
|
||||||
|
|
||||||
|
|
||||||
@bp.post("/benutzer/<int:user_id>/sitzungen-beenden")
|
@bp.post("/users/<int:user_id>/end-sessions")
|
||||||
@permission_required("users.edit")
|
@permission_required("users.edit")
|
||||||
def user_revoke_sessions(user_id: int):
|
def user_revoke_sessions(user_id: int):
|
||||||
conn = db()
|
conn = db()
|
||||||
@@ -184,7 +184,7 @@ def user_revoke_sessions(user_id: int):
|
|||||||
return redirect(url_for("admin.users"))
|
return redirect(url_for("admin.users"))
|
||||||
|
|
||||||
|
|
||||||
@bp.get("/benutzer/verzeichnis-suche")
|
@bp.get("/users/directory-search")
|
||||||
@permission_required("users.create")
|
@permission_required("users.create")
|
||||||
def directory_search():
|
def directory_search():
|
||||||
conn = db()
|
conn = db()
|
||||||
@@ -200,7 +200,7 @@ def directory_search():
|
|||||||
return jsonify({"results": results})
|
return jsonify({"results": results})
|
||||||
|
|
||||||
|
|
||||||
@bp.post("/benutzer/verzeichnis-uebernehmen")
|
@bp.post("/users/directory-import")
|
||||||
@permission_required("users.create")
|
@permission_required("users.create")
|
||||||
def directory_adopt():
|
def directory_adopt():
|
||||||
"""Legt ein Verzeichniskonto vorab an, damit Gruppen vor dem ersten Login sitzen."""
|
"""Legt ein Verzeichniskonto vorab an, damit Gruppen vor dem ersten Login sitzen."""
|
||||||
@@ -238,7 +238,7 @@ def directory_adopt():
|
|||||||
# --------------------------------------------------------------------------- #
|
# --------------------------------------------------------------------------- #
|
||||||
|
|
||||||
|
|
||||||
@bp.get("/gruppen")
|
@bp.get("/groups")
|
||||||
@permission_required("groups.view")
|
@permission_required("groups.view")
|
||||||
def groups():
|
def groups():
|
||||||
conn = db()
|
conn = db()
|
||||||
@@ -254,7 +254,7 @@ def groups():
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@bp.get("/gruppen/<int:group_id>")
|
@bp.get("/groups/<int:group_id>")
|
||||||
@permission_required("groups.view")
|
@permission_required("groups.view")
|
||||||
def group_detail(group_id: int):
|
def group_detail(group_id: int):
|
||||||
conn = db()
|
conn = db()
|
||||||
@@ -275,11 +275,11 @@ def group_detail(group_id: int):
|
|||||||
(group_id,),
|
(group_id,),
|
||||||
),
|
),
|
||||||
can_edit=current_user.has("groups.edit"),
|
can_edit=current_user.has("groups.edit"),
|
||||||
unlocked=request.args.get("freischalten") == "1",
|
unlocked=request.args.get("unlocked") == "1",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@bp.post("/gruppen/neu")
|
@bp.post("/groups/new")
|
||||||
@permission_required("groups.create")
|
@permission_required("groups.create")
|
||||||
def group_create():
|
def group_create():
|
||||||
conn = db()
|
conn = db()
|
||||||
@@ -301,7 +301,7 @@ def group_create():
|
|||||||
return redirect(url_for("admin.groups"))
|
return redirect(url_for("admin.groups"))
|
||||||
|
|
||||||
|
|
||||||
@bp.post("/gruppen/<int:group_id>")
|
@bp.post("/groups/<int:group_id>")
|
||||||
@permission_required("groups.edit")
|
@permission_required("groups.edit")
|
||||||
def group_update(group_id: int):
|
def group_update(group_id: int):
|
||||||
conn = db()
|
conn = db()
|
||||||
@@ -328,13 +328,13 @@ def group_update(group_id: int):
|
|||||||
"; ".join(changes),
|
"; ".join(changes),
|
||||||
severity="notice",
|
severity="notice",
|
||||||
)
|
)
|
||||||
flash("Aenderungen gespeichert." if changes else "Keine Aenderungen.", "success" if changes else "info")
|
flash("Änderungen gespeichert." if changes else "Keine Änderungen.", "success" if changes else "info")
|
||||||
except (rbac.ValidationError, rbac.PermissionDenied) as exc:
|
except (rbac.ValidationError, rbac.PermissionDenied) as exc:
|
||||||
flash(str(exc), "danger")
|
flash(str(exc), "danger")
|
||||||
return redirect(url_for("admin.group_detail", group_id=group_id))
|
return redirect(url_for("admin.group_detail", group_id=group_id))
|
||||||
|
|
||||||
|
|
||||||
@bp.post("/gruppen/<int:group_id>/loeschen")
|
@bp.post("/groups/<int:group_id>/delete")
|
||||||
@permission_required("groups.delete")
|
@permission_required("groups.delete")
|
||||||
def group_delete(group_id: int):
|
def group_delete(group_id: int):
|
||||||
conn = db()
|
conn = db()
|
||||||
@@ -354,7 +354,7 @@ def group_delete(group_id: int):
|
|||||||
return redirect(url_for("admin.groups"))
|
return redirect(url_for("admin.groups"))
|
||||||
|
|
||||||
|
|
||||||
@bp.post("/gruppen/<int:group_id>/vorlage-anwenden")
|
@bp.post("/groups/<int:group_id>/apply-preset")
|
||||||
@admin_required
|
@admin_required
|
||||||
def group_apply_preset(group_id: int):
|
def group_apply_preset(group_id: int):
|
||||||
"""Setzt eine Gruppe auf ihre Vorlage zurueck -- ausdruecklich, nie automatisch."""
|
"""Setzt eine Gruppe auf ihre Vorlage zurueck -- ausdruecklich, nie automatisch."""
|
||||||
@@ -364,7 +364,7 @@ def group_apply_preset(group_id: int):
|
|||||||
abort(404)
|
abort(404)
|
||||||
preset = next((p for p in core().role_presets if p.key == group.preset_key), None)
|
preset = next((p for p in core().role_presets if p.key == group.preset_key), None)
|
||||||
if preset is None:
|
if preset is None:
|
||||||
flash("Fuer diese Gruppe ist keine Vorlage hinterlegt.", "info")
|
flash("Für diese Gruppe ist keine Vorlage hinterlegt.", "info")
|
||||||
return redirect(url_for("admin.group_detail", group_id=group_id))
|
return redirect(url_for("admin.group_detail", group_id=group_id))
|
||||||
tree = core().permissions
|
tree = core().permissions
|
||||||
with transaction():
|
with transaction():
|
||||||
@@ -377,7 +377,7 @@ def group_apply_preset(group_id: int):
|
|||||||
unlock_system=True,
|
unlock_system=True,
|
||||||
)
|
)
|
||||||
_audit(conn, "group.preset_applied", group.name, preset.key, severity="notice")
|
_audit(conn, "group.preset_applied", group.name, preset.key, severity="notice")
|
||||||
flash(f"Rechte der Vorlage {preset.name} uebernommen.", "success")
|
flash(f"Rechte der Vorlage {preset.name} übernommen.", "success")
|
||||||
return redirect(url_for("admin.group_detail", group_id=group_id))
|
return redirect(url_for("admin.group_detail", group_id=group_id))
|
||||||
|
|
||||||
|
|
||||||
@@ -386,7 +386,7 @@ def group_apply_preset(group_id: int):
|
|||||||
# --------------------------------------------------------------------------- #
|
# --------------------------------------------------------------------------- #
|
||||||
|
|
||||||
|
|
||||||
@bp.get("/sitzungen")
|
@bp.get("/sessions")
|
||||||
@admin_required
|
@admin_required
|
||||||
def sessions():
|
def sessions():
|
||||||
conn = db()
|
conn = db()
|
||||||
@@ -399,7 +399,7 @@ def sessions():
|
|||||||
return render_template("tesm_core/sessions.html", sessions=rows)
|
return render_template("tesm_core/sessions.html", sessions=rows)
|
||||||
|
|
||||||
|
|
||||||
@bp.post("/sitzungen/<sid>/beenden")
|
@bp.post("/sessions/<sid>/end")
|
||||||
@admin_required
|
@admin_required
|
||||||
def session_revoke(sid: str):
|
def session_revoke(sid: str):
|
||||||
with transaction() as conn:
|
with transaction() as conn:
|
||||||
@@ -427,9 +427,15 @@ def nav_order():
|
|||||||
if submitted:
|
if submitted:
|
||||||
child_order[item.key] = extension.nav.sanitize_child_order(item.key, submitted)
|
child_order[item.key] = extension.nav.sanitize_child_order(item.key, submitted)
|
||||||
with transaction() as conn:
|
with transaction() as conn:
|
||||||
|
vorher = extension.settings.get(conn, "nav_order") or extension.nav.default_order()
|
||||||
extension.settings.set(conn, "nav_order", order, actor=current_user.username)
|
extension.settings.set(conn, "nav_order", order, actor=current_user.username)
|
||||||
extension.settings.set(conn, "nav_child_order", child_order, actor=current_user.username)
|
extension.settings.set(conn, "nav_child_order", child_order, actor=current_user.username)
|
||||||
_audit(conn, "system.nav_order_changed", severity="info")
|
_audit(
|
||||||
|
conn,
|
||||||
|
"system.nav_order_changed",
|
||||||
|
detail=f"{', '.join(vorher)} -> {', '.join(order)}",
|
||||||
|
severity="info",
|
||||||
|
)
|
||||||
flash("Reihenfolge der Navigation gespeichert.", "success")
|
flash("Reihenfolge der Navigation gespeichert.", "success")
|
||||||
return redirect(request.referrer or url_for("index"))
|
return redirect(request.referrer or url_for("index"))
|
||||||
|
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ from ..keystore import check_private_file
|
|||||||
from ..security import ratelimit
|
from ..security import ratelimit
|
||||||
from ..security import sessions as session_store
|
from ..security import sessions as session_store
|
||||||
|
|
||||||
bp = Blueprint("diagnostics", __name__, url_prefix="/diagnose")
|
bp = Blueprint("diagnostics", __name__, url_prefix="/administration/diagnostics")
|
||||||
|
|
||||||
|
|
||||||
@bp.get("/")
|
@bp.get("/")
|
||||||
@@ -82,13 +82,13 @@ def overview():
|
|||||||
"Debug": "ja" if config.debug else "nein",
|
"Debug": "ja" if config.debug else "nein",
|
||||||
"Cookie Secure": "ja" if config.security.cookie_secure else "nein",
|
"Cookie Secure": "ja" if config.security.cookie_secure else "nein",
|
||||||
"HSTS": "ja" if config.security.hsts_enabled else "nein",
|
"HSTS": "ja" if config.security.hsts_enabled else "nein",
|
||||||
"2FA-Pflicht fuer Admins": "ja" if config.security.require_totp_for_admins else "nein",
|
"2FA-Pflicht für Admins": "ja" if config.security.require_totp_for_admins else "nein",
|
||||||
},
|
},
|
||||||
now=datetime.now(timezone.utc),
|
now=datetime.now(timezone.utc),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@bp.post("/aufraeumen")
|
@bp.post("/cleanup")
|
||||||
@admin_required
|
@admin_required
|
||||||
def housekeeping():
|
def housekeeping():
|
||||||
"""Raeumt abgelaufene Sitzungen, alte Ratenbegrenzungs-Ereignisse und Protokolle."""
|
"""Raeumt abgelaufene Sitzungen, alte Ratenbegrenzungs-Ereignisse und Protokolle."""
|
||||||
@@ -109,14 +109,14 @@ def housekeeping():
|
|||||||
severity="notice",
|
severity="notice",
|
||||||
)
|
)
|
||||||
flash(
|
flash(
|
||||||
f"Aufgeraeumt: {sessions_removed} Sitzungen, {rate_removed} Ratenereignisse, "
|
f"Aufgeräumt: {sessions_removed} Sitzungen, {rate_removed} Ratenereignisse, "
|
||||||
f"{len(archived)} archivierte Protokolltage.",
|
f"{len(archived)} archivierte Protokolltage.",
|
||||||
"success",
|
"success",
|
||||||
)
|
)
|
||||||
return redirect(url_for("diagnostics.overview"))
|
return redirect(url_for("diagnostics.overview"))
|
||||||
|
|
||||||
|
|
||||||
@bp.post("/schluessel-rotieren")
|
@bp.post("/rotate-keys")
|
||||||
@admin_required
|
@admin_required
|
||||||
def rotate_keys():
|
def rotate_keys():
|
||||||
"""Legt einen neuen Datenschluessel an und verschluesselt bestehende Werte neu.
|
"""Legt einen neuen Datenschluessel an und verschluesselt bestehende Werte neu.
|
||||||
@@ -139,12 +139,12 @@ def rotate_keys():
|
|||||||
actor_id=current_user.id,
|
actor_id=current_user.id,
|
||||||
action="system.data_key_rotated",
|
action="system.data_key_rotated",
|
||||||
target=new_id,
|
target=new_id,
|
||||||
detail=f"{migrated} Werte neu verschluesselt",
|
detail=f"{migrated} Werte neu verschlüsselt",
|
||||||
severity="notice",
|
severity="notice",
|
||||||
)
|
)
|
||||||
flash(
|
flash(
|
||||||
f"Neuer Datenschluessel {new_id} aktiv, {migrated} gespeicherte Geheimnisse "
|
f"Neuer Datenschlüssel {new_id} aktiv, {migrated} gespeicherte Geheimnisse "
|
||||||
"wurden neu verschluesselt.",
|
"wurden neu verschlüsselt.",
|
||||||
"success",
|
"success",
|
||||||
)
|
)
|
||||||
return redirect(url_for("diagnostics.overview"))
|
return redirect(url_for("diagnostics.overview"))
|
||||||
@@ -29,7 +29,7 @@ from ..licensing_client import LicenseError
|
|||||||
from ..rbac import service as rbac
|
from ..rbac import service as rbac
|
||||||
from ..system import hostnet, webserver
|
from ..system import hostnet, webserver
|
||||||
|
|
||||||
bp = Blueprint("hostsettings", __name__, url_prefix="/einstellungen")
|
bp = Blueprint("hostsettings", __name__, url_prefix="/administration")
|
||||||
|
|
||||||
PENDING_NETWORK_KEY = "pending_network_revert"
|
PENDING_NETWORK_KEY = "pending_network_revert"
|
||||||
|
|
||||||
@@ -64,7 +64,7 @@ def _license_hint(feature: str) -> str:
|
|||||||
# --------------------------------------------------------------------------- #
|
# --------------------------------------------------------------------------- #
|
||||||
|
|
||||||
|
|
||||||
@bp.get("/netzwerk")
|
@bp.get("/network")
|
||||||
@permission_required("settings_network.view")
|
@permission_required("settings_network.view")
|
||||||
def network():
|
def network():
|
||||||
extension = core()
|
extension = core()
|
||||||
@@ -80,13 +80,13 @@ def network():
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@bp.post("/netzwerk")
|
@bp.post("/network")
|
||||||
@permission_required("settings_network.edit")
|
@permission_required("settings_network.edit")
|
||||||
def network_apply():
|
def network_apply():
|
||||||
extension = core()
|
extension = core()
|
||||||
sysops = extension.sysops
|
sysops = extension.sysops
|
||||||
if sysops is None or not sysops.available:
|
if sysops is None or not sysops.available:
|
||||||
flash("Systemaktionen sind in dieser Umgebung nicht verfuegbar.", "warning")
|
flash("Systemaktionen sind in dieser Umgebung nicht verfügbar.", "warning")
|
||||||
return redirect(url_for("hostsettings.network"))
|
return redirect(url_for("hostsettings.network"))
|
||||||
|
|
||||||
interface = (request.form.get("interface") or "").strip()
|
interface = (request.form.get("interface") or "").strip()
|
||||||
@@ -127,7 +127,7 @@ def network_apply():
|
|||||||
sysops.write_netplan(previous.stdout if previous.ok else "")
|
sysops.write_netplan(previous.stdout if previous.ok else "")
|
||||||
sysops.apply_netplan()
|
sysops.apply_netplan()
|
||||||
flash(
|
flash(
|
||||||
f"Uebernahme fehlgeschlagen, alter Zustand wiederhergestellt: {applied.message}",
|
f"Übernahme fehlgeschlagen, alter Zustand wiederhergestellt: {applied.message}",
|
||||||
"danger",
|
"danger",
|
||||||
)
|
)
|
||||||
return redirect(url_for("hostsettings.network"))
|
return redirect(url_for("hostsettings.network"))
|
||||||
@@ -140,21 +140,21 @@ def network_apply():
|
|||||||
_start_revert_timer(extension)
|
_start_revert_timer(extension)
|
||||||
_audit("settings.network_applied", interface, severity="warning")
|
_audit("settings.network_applied", interface, severity="warning")
|
||||||
flash(
|
flash(
|
||||||
f"Uebernommen. Bitte innerhalb von {hostnet.REVERT_SECONDS} Sekunden bestaetigen -- "
|
f"Übernommen. Bitte innerhalb von {hostnet.REVERT_SECONDS} Sekunden bestätigen – "
|
||||||
"sonst wird automatisch zurueckgerollt.",
|
"sonst wird automatisch zurückgerollt.",
|
||||||
"warning",
|
"warning",
|
||||||
)
|
)
|
||||||
return redirect(url_for("hostsettings.network"))
|
return redirect(url_for("hostsettings.network"))
|
||||||
|
|
||||||
|
|
||||||
@bp.post("/netzwerk/bestaetigen")
|
@bp.post("/network/confirm")
|
||||||
@permission_required("settings_network.edit")
|
@permission_required("settings_network.edit")
|
||||||
def network_confirm():
|
def network_confirm():
|
||||||
if core().registry.pop(PENDING_NETWORK_KEY, None) is None:
|
if core().registry.pop(PENDING_NETWORK_KEY, None) is None:
|
||||||
flash("Es liegt keine Aenderung zur Bestaetigung vor.", "info")
|
flash("Es liegt keine Änderung zur Bestätigung vor.", "info")
|
||||||
else:
|
else:
|
||||||
_audit("settings.network_confirmed", severity="notice")
|
_audit("settings.network_confirmed", severity="notice")
|
||||||
flash("Netzwerkeinstellungen bestaetigt.", "success")
|
flash("Netzwerkeinstellungen bestätigt.", "success")
|
||||||
return redirect(url_for("hostsettings.network"))
|
return redirect(url_for("hostsettings.network"))
|
||||||
|
|
||||||
|
|
||||||
@@ -173,7 +173,7 @@ def _start_revert_timer(extension: Any) -> None:
|
|||||||
conn,
|
conn,
|
||||||
actor="system",
|
actor="system",
|
||||||
action="settings.network_auto_reverted",
|
action="settings.network_auto_reverted",
|
||||||
detail="Keine Bestaetigung innerhalb der Frist -- automatisch zurueckgerollt.",
|
detail="Keine Bestätigung innerhalb der Frist – automatisch zurückgerollt.",
|
||||||
severity="warning",
|
severity="warning",
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -221,7 +221,7 @@ def _web_config() -> webserver.WebConfig:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@bp.get("/webserver")
|
@bp.get("/web")
|
||||||
@permission_required("settings_web.view")
|
@permission_required("settings_web.view")
|
||||||
def web():
|
def web():
|
||||||
extension = core()
|
extension = core()
|
||||||
@@ -240,7 +240,7 @@ def web():
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@bp.post("/webserver")
|
@bp.post("/web")
|
||||||
@permission_required("settings_web.edit")
|
@permission_required("settings_web.edit")
|
||||||
def web_save():
|
def web_save():
|
||||||
extension = core()
|
extension = core()
|
||||||
@@ -281,7 +281,7 @@ def web_save():
|
|||||||
cert_path = (request.form.get("web_cert_path") or "").strip()
|
cert_path = (request.form.get("web_cert_path") or "").strip()
|
||||||
key_path = (request.form.get("web_key_path") or "").strip()
|
key_path = (request.form.get("web_key_path") or "").strip()
|
||||||
if https_enabled and not (cert_path and key_path):
|
if https_enabled and not (cert_path and key_path):
|
||||||
flash("Fuer HTTPS werden Zertifikat und privater Schluessel gebraucht.", "danger")
|
flash("Für HTTPS werden Zertifikat und privater Schlüssel gebraucht.", "danger")
|
||||||
return redirect(url_for("hostsettings.web"))
|
return redirect(url_for("hostsettings.web"))
|
||||||
|
|
||||||
values = {
|
values = {
|
||||||
@@ -298,11 +298,11 @@ def web_save():
|
|||||||
with transaction():
|
with transaction():
|
||||||
changed = extension.settings.set_many(conn, values, actor=current_user.username)
|
changed = extension.settings.set_many(conn, values, actor=current_user.username)
|
||||||
_audit("settings.web_saved", domain, ", ".join(changed), severity="notice")
|
_audit("settings.web_saved", domain, ", ".join(changed), severity="notice")
|
||||||
flash("Gespeichert. Uebernehmen Sie die Konfiguration, damit sie wirksam wird.", "success")
|
flash("Gespeichert. Übernehmen Sie die Konfiguration, damit sie wirksam wird.", "success")
|
||||||
return redirect(url_for("hostsettings.web"))
|
return redirect(url_for("hostsettings.web"))
|
||||||
|
|
||||||
|
|
||||||
@bp.post("/webserver/uebernehmen")
|
@bp.post("/web/apply")
|
||||||
@permission_required("settings_web.execute")
|
@permission_required("settings_web.execute")
|
||||||
def web_apply():
|
def web_apply():
|
||||||
problem = _license_hint("nginx")
|
problem = _license_hint("nginx")
|
||||||
@@ -311,7 +311,7 @@ def web_apply():
|
|||||||
return redirect(url_for("hostsettings.web"))
|
return redirect(url_for("hostsettings.web"))
|
||||||
sysops = core().sysops
|
sysops = core().sysops
|
||||||
if sysops is None or not sysops.available:
|
if sysops is None or not sysops.available:
|
||||||
flash("Systemaktionen sind in dieser Umgebung nicht verfuegbar.", "warning")
|
flash("Systemaktionen sind in dieser Umgebung nicht verfügbar.", "warning")
|
||||||
return redirect(url_for("hostsettings.web"))
|
return redirect(url_for("hostsettings.web"))
|
||||||
|
|
||||||
result = webserver.apply_config(sysops, webserver.render(_web_config()))
|
result = webserver.apply_config(sysops, webserver.render(_web_config()))
|
||||||
@@ -327,7 +327,7 @@ def web_apply():
|
|||||||
return redirect(url_for("hostsettings.web"))
|
return redirect(url_for("hostsettings.web"))
|
||||||
|
|
||||||
|
|
||||||
@bp.post("/webserver/zertifikat")
|
@bp.post("/web/certificate")
|
||||||
@permission_required("settings_web.execute")
|
@permission_required("settings_web.execute")
|
||||||
def web_certificate():
|
def web_certificate():
|
||||||
problem = _license_hint("nginx")
|
problem = _license_hint("nginx")
|
||||||
@@ -338,7 +338,7 @@ def web_certificate():
|
|||||||
sysops = extension.sysops
|
sysops = extension.sysops
|
||||||
conn = db()
|
conn = db()
|
||||||
if sysops is None or not sysops.available:
|
if sysops is None or not sysops.available:
|
||||||
flash("Systemaktionen sind in dieser Umgebung nicht verfuegbar.", "warning")
|
flash("Systemaktionen sind in dieser Umgebung nicht verfügbar.", "warning")
|
||||||
return redirect(url_for("hostsettings.web"))
|
return redirect(url_for("hostsettings.web"))
|
||||||
|
|
||||||
config = _web_config()
|
config = _web_config()
|
||||||
@@ -364,7 +364,7 @@ def web_certificate():
|
|||||||
return redirect(url_for("hostsettings.web"))
|
return redirect(url_for("hostsettings.web"))
|
||||||
|
|
||||||
|
|
||||||
@bp.post("/webserver/selbstsigniert")
|
@bp.post("/web/self-signed")
|
||||||
@permission_required("settings_web.execute")
|
@permission_required("settings_web.execute")
|
||||||
def web_self_signed():
|
def web_self_signed():
|
||||||
"""Erzeugt ein selbstsigniertes Zertifikat -- fuer Netze ohne Domain.
|
"""Erzeugt ein selbstsigniertes Zertifikat -- fuer Netze ohne Domain.
|
||||||
@@ -381,7 +381,7 @@ def web_self_signed():
|
|||||||
sysops = extension.sysops
|
sysops = extension.sysops
|
||||||
conn = db()
|
conn = db()
|
||||||
if sysops is None or not sysops.available:
|
if sysops is None or not sysops.available:
|
||||||
flash("Systemaktionen sind in dieser Umgebung nicht verfuegbar.", "warning")
|
flash("Systemaktionen sind in dieser Umgebung nicht verfügbar.", "warning")
|
||||||
return redirect(url_for("hostsettings.web"))
|
return redirect(url_for("hostsettings.web"))
|
||||||
|
|
||||||
config = _web_config()
|
config = _web_config()
|
||||||
@@ -414,7 +414,7 @@ def web_self_signed():
|
|||||||
# --------------------------------------------------------------------------- #
|
# --------------------------------------------------------------------------- #
|
||||||
|
|
||||||
|
|
||||||
@bp.get("/verzeichnisdienst")
|
@bp.get("/directory")
|
||||||
@permission_required("settings_directory.view")
|
@permission_required("settings_directory.view")
|
||||||
def directory_settings():
|
def directory_settings():
|
||||||
extension = core()
|
extension = core()
|
||||||
@@ -435,7 +435,7 @@ def directory_settings():
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@bp.post("/verzeichnisdienst")
|
@bp.post("/directory")
|
||||||
@permission_required("settings_directory.edit")
|
@permission_required("settings_directory.edit")
|
||||||
def directory_save():
|
def directory_save():
|
||||||
problem = _license_hint("directory")
|
problem = _license_hint("directory")
|
||||||
@@ -454,8 +454,8 @@ def directory_save():
|
|||||||
|
|
||||||
if values.get("ldap_security") == "none" and not values.get("ldap_allow_insecure"):
|
if values.get("ldap_security") == "none" and not values.get("ldap_allow_insecure"):
|
||||||
flash(
|
flash(
|
||||||
"Eine unverschluesselte Verbindung muss ausdruecklich erlaubt werden -- das Passwort "
|
"Eine unverschlüsselte Verbindung muss ausdrücklich erlaubt werden – das Passwort "
|
||||||
"geht dabei im Klartext ueber das Netz.",
|
"geht dabei im Klartext über das Netz.",
|
||||||
"danger",
|
"danger",
|
||||||
)
|
)
|
||||||
return redirect(url_for("hostsettings.directory_settings"))
|
return redirect(url_for("hostsettings.directory_settings"))
|
||||||
@@ -481,7 +481,7 @@ def directory_save():
|
|||||||
return redirect(url_for("hostsettings.directory_settings"))
|
return redirect(url_for("hostsettings.directory_settings"))
|
||||||
|
|
||||||
|
|
||||||
@bp.post("/verzeichnisdienst/test")
|
@bp.post("/directory/test")
|
||||||
@permission_required("settings_directory.execute")
|
@permission_required("settings_directory.execute")
|
||||||
def directory_test():
|
def directory_test():
|
||||||
result = directory.test_connection(db())
|
result = directory.test_connection(db())
|
||||||
@@ -489,7 +489,7 @@ def directory_test():
|
|||||||
return redirect(url_for("hostsettings.directory_settings"))
|
return redirect(url_for("hostsettings.directory_settings"))
|
||||||
|
|
||||||
|
|
||||||
@bp.get("/verzeichnisdienst/gruppensuche")
|
@bp.get("/directory/group-search")
|
||||||
@permission_required("settings_directory.edit", "fileshare.edit", mode="any")
|
@permission_required("settings_directory.edit", "fileshare.edit", mode="any")
|
||||||
def directory_group_search():
|
def directory_group_search():
|
||||||
"""Sucht Gruppen im Verzeichnis -- fuer die Auswahlfelder.
|
"""Sucht Gruppen im Verzeichnis -- fuer die Auswahlfelder.
|
||||||
@@ -504,7 +504,7 @@ def directory_group_search():
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@bp.post("/verzeichnisdienst/zuordnung")
|
@bp.post("/directory/mapping")
|
||||||
@permission_required("settings_directory.edit")
|
@permission_required("settings_directory.edit")
|
||||||
def directory_mapping_add():
|
def directory_mapping_add():
|
||||||
conn = db()
|
conn = db()
|
||||||
@@ -534,7 +534,7 @@ def directory_mapping_add():
|
|||||||
return redirect(url_for("hostsettings.directory_settings"))
|
return redirect(url_for("hostsettings.directory_settings"))
|
||||||
|
|
||||||
|
|
||||||
@bp.post("/verzeichnisdienst/zuordnung/<int:mapping_id>/loeschen")
|
@bp.post("/directory/mapping/<int:mapping_id>/delete")
|
||||||
@permission_required("settings_directory.edit")
|
@permission_required("settings_directory.edit")
|
||||||
def directory_mapping_delete(mapping_id: int):
|
def directory_mapping_delete(mapping_id: int):
|
||||||
with transaction() as conn:
|
with transaction() as conn:
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ from ..auth.service import client_ip
|
|||||||
from ..extension import core, db, transaction
|
from ..extension import core, db, transaction
|
||||||
from ..security.sessions import fragment
|
from ..security.sessions import fragment
|
||||||
|
|
||||||
bp = Blueprint("logs", __name__, url_prefix="/protokolle")
|
bp = Blueprint("logs", __name__, url_prefix="/logs")
|
||||||
|
|
||||||
MAX_TAIL_LINES = 2000
|
MAX_TAIL_LINES = 2000
|
||||||
|
|
||||||
@@ -38,7 +38,7 @@ def _live_log():
|
|||||||
return log
|
return log
|
||||||
|
|
||||||
|
|
||||||
@bp.get("/laufend")
|
@bp.get("/live")
|
||||||
@permission_required("logs_live.view")
|
@permission_required("logs_live.view")
|
||||||
def live():
|
def live():
|
||||||
lines = min(int(request.args.get("lines") or 400), MAX_TAIL_LINES)
|
lines = min(int(request.args.get("lines") or 400), MAX_TAIL_LINES)
|
||||||
@@ -52,7 +52,7 @@ def live():
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@bp.get("/laufend/inhalt")
|
@bp.get("/live/content")
|
||||||
@permission_required("logs_live.view")
|
@permission_required("logs_live.view")
|
||||||
@fragment
|
@fragment
|
||||||
def live_fragment():
|
def live_fragment():
|
||||||
@@ -63,7 +63,7 @@ def live_fragment():
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@bp.get("/verlauf")
|
@bp.get("/history")
|
||||||
@permission_required("logs_history.view", "logs_history.export", mode="any")
|
@permission_required("logs_history.view", "logs_history.export", mode="any")
|
||||||
def history():
|
def history():
|
||||||
extension = core()
|
extension = core()
|
||||||
@@ -82,7 +82,7 @@ def history():
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@bp.get("/verlauf/datei/<path:name>")
|
@bp.get("/history/file/<path:name>")
|
||||||
@permission_required("logs_history.view")
|
@permission_required("logs_history.view")
|
||||||
def history_file(name: str):
|
def history_file(name: str):
|
||||||
extension = core()
|
extension = core()
|
||||||
@@ -101,7 +101,7 @@ def history_file(name: str):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@bp.get("/verlauf/archiv/<day>")
|
@bp.get("/history/archive/<day>")
|
||||||
@permission_required("logs_history.view")
|
@permission_required("logs_history.view")
|
||||||
def history_archive(day: str):
|
def history_archive(day: str):
|
||||||
if len(day) != 10 or day.count("-") != 2:
|
if len(day) != 10 or day.count("-") != 2:
|
||||||
@@ -112,7 +112,7 @@ def history_archive(day: str):
|
|||||||
return render_template("tesm_core/logs_archive.html", day=day, rows=rows)
|
return render_template("tesm_core/logs_archive.html", day=day, rows=rows)
|
||||||
|
|
||||||
|
|
||||||
@bp.post("/verlauf/archiv-export")
|
@bp.post("/history/archive-export")
|
||||||
@permission_required("logs_history.export")
|
@permission_required("logs_history.export")
|
||||||
def history_export():
|
def history_export():
|
||||||
"""Packt die archivierten Audit-Tage in ein ZIP.
|
"""Packt die archivierten Audit-Tage in ein ZIP.
|
||||||
@@ -152,7 +152,7 @@ def history_export():
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@bp.post("/verlauf/archiv-loeschen")
|
@bp.post("/history/archive-delete")
|
||||||
@permission_required("logs_history.delete")
|
@permission_required("logs_history.delete")
|
||||||
def history_purge():
|
def history_purge():
|
||||||
removed = 0
|
removed = 0
|
||||||
@@ -176,20 +176,20 @@ def history_purge():
|
|||||||
return redirect(url_for("logs.history"))
|
return redirect(url_for("logs.history"))
|
||||||
|
|
||||||
|
|
||||||
@bp.get("/aenderungen")
|
@bp.get("/audit")
|
||||||
@permission_required("logs_audit.view", "logs_audit.export", mode="any")
|
@permission_required("logs_audit.view", "logs_audit.export", mode="any")
|
||||||
def audit_view():
|
def audit_view():
|
||||||
conn = db()
|
conn = db()
|
||||||
page = max(1, int(request.args.get("seite") or 1))
|
page = max(1, int(request.args.get("page") or 1))
|
||||||
per_page = 100
|
per_page = 100
|
||||||
return render_template(
|
return render_template(
|
||||||
"tesm_core/logs_audit.html",
|
"tesm_core/logs_audit.html",
|
||||||
rows=audit.search(
|
rows=audit.search(
|
||||||
conn,
|
conn,
|
||||||
query=(request.args.get("q") or "").strip(),
|
query=(request.args.get("q") or "").strip(),
|
||||||
action=(request.args.get("aktion") or "").strip(),
|
action=(request.args.get("action") or "").strip(),
|
||||||
actor=(request.args.get("benutzer") or "").strip(),
|
actor=(request.args.get("actor") or "").strip(),
|
||||||
severity=(request.args.get("stufe") or "").strip(),
|
severity=(request.args.get("severity") or "").strip(),
|
||||||
limit=per_page,
|
limit=per_page,
|
||||||
offset=(page - 1) * per_page,
|
offset=(page - 1) * per_page,
|
||||||
),
|
),
|
||||||
@@ -200,14 +200,14 @@ def audit_view():
|
|||||||
per_page=per_page,
|
per_page=per_page,
|
||||||
filters={
|
filters={
|
||||||
"q": request.args.get("q") or "",
|
"q": request.args.get("q") or "",
|
||||||
"aktion": request.args.get("aktion") or "",
|
"action": request.args.get("action") or "",
|
||||||
"benutzer": request.args.get("benutzer") or "",
|
"actor": request.args.get("actor") or "",
|
||||||
"stufe": request.args.get("stufe") or "",
|
"severity": request.args.get("severity") or "",
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@bp.get("/aenderungen/export")
|
@bp.get("/audit/export")
|
||||||
@permission_required("logs_audit.export")
|
@permission_required("logs_audit.export")
|
||||||
def audit_export():
|
def audit_export():
|
||||||
conn = db()
|
conn = db()
|
||||||
@@ -235,7 +235,7 @@ def audit_export():
|
|||||||
mimetype="text/csv; charset=utf-8",
|
mimetype="text/csv; charset=utf-8",
|
||||||
headers={
|
headers={
|
||||||
"Content-Disposition": (
|
"Content-Disposition": (
|
||||||
f'attachment; filename="{core().config.app_key}-aenderungsprotokoll.csv"'
|
f'attachment; filename="{core().config.app_key}-änderungsprotokoll.csv"'
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
@@ -28,7 +28,7 @@ from ..db import Database
|
|||||||
from ..extension import core, db, transaction
|
from ..extension import core, db, transaction
|
||||||
from ..rbac import service as rbac
|
from ..rbac import service as rbac
|
||||||
|
|
||||||
bp = Blueprint("trash", __name__, url_prefix="/papierkorb")
|
bp = Blueprint("trash", __name__, url_prefix="/administration/trash")
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True, slots=True)
|
@dataclass(frozen=True, slots=True)
|
||||||
@@ -100,7 +100,7 @@ def register_core_providers() -> None:
|
|||||||
list_fn=_users_in_trash,
|
list_fn=_users_in_trash,
|
||||||
restore_fn=lambda conn, ident: rbac.restore_user(conn, int(ident)),
|
restore_fn=lambda conn, ident: rbac.restore_user(conn, int(ident)),
|
||||||
purge_fn=lambda conn, ident: rbac.purge_user(conn, int(ident)),
|
purge_fn=lambda conn, ident: rbac.purge_user(conn, int(ident)),
|
||||||
hint="Verzeichniskonten landen nie hier -- sie legen sich bei der naechsten "
|
hint="Verzeichniskonten landen nie hier – sie legen sich bei der nächsten "
|
||||||
"Anmeldung von selbst wieder an.",
|
"Anmeldung von selbst wieder an.",
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
@@ -112,7 +112,7 @@ def register_core_providers() -> None:
|
|||||||
list_fn=_groups_in_trash,
|
list_fn=_groups_in_trash,
|
||||||
restore_fn=lambda conn, ident: rbac.restore_group(conn, int(ident)),
|
restore_fn=lambda conn, ident: rbac.restore_group(conn, int(ident)),
|
||||||
purge_fn=lambda conn, ident: rbac.purge_group(conn, int(ident)),
|
purge_fn=lambda conn, ident: rbac.purge_group(conn, int(ident)),
|
||||||
hint="Beim endgueltigen Loeschen verschwinden auch Mitgliedschaften und Rechte.",
|
hint="Beim endgültigen Löschen verschwinden auch Mitgliedschaften und Rechte.",
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -158,11 +158,11 @@ def overview():
|
|||||||
conn,
|
conn,
|
||||||
actor="system",
|
actor="system",
|
||||||
action="trash.auto_purged",
|
action="trash.auto_purged",
|
||||||
detail=f"{removed} abgelaufene Eintraege",
|
detail=f"{removed} abgelaufene Einträge",
|
||||||
severity="notice",
|
severity="notice",
|
||||||
)
|
)
|
||||||
if removed:
|
if removed:
|
||||||
flash(f"{removed} abgelaufene Eintraege wurden endgueltig entfernt.", "info")
|
flash(f"{removed} abgelaufene Einträge wurden endgültig entfernt.", "info")
|
||||||
|
|
||||||
sections = []
|
sections = []
|
||||||
for provider in providers().values():
|
for provider in providers().values():
|
||||||
@@ -191,14 +191,14 @@ def _provider_or_404(key: str) -> TrashProvider:
|
|||||||
return provider
|
return provider
|
||||||
|
|
||||||
|
|
||||||
@bp.post("/<kind>/<ident>/wiederherstellen")
|
@bp.post("/<kind>/<ident>/restore")
|
||||||
@permission_required("trash.edit")
|
@permission_required("trash.edit")
|
||||||
def restore(kind: str, ident: str):
|
def restore(kind: str, ident: str):
|
||||||
extension = core()
|
extension = core()
|
||||||
if extension.license is not None and not extension.license.feature_allowed("trash_restore"):
|
if extension.license is not None and not extension.license.feature_allowed("trash_restore"):
|
||||||
flash(
|
flash(
|
||||||
"Das Wiederherstellen aus dem Papierkorb erfordert eine gueltige, "
|
"Das Wiederherstellen aus dem Papierkorb erfordert eine gültige, "
|
||||||
"aktivierte Lizenz. Ansehen bleibt jederzeit moeglich.",
|
"aktivierte Lizenz. Ansehen bleibt jederzeit möglich.",
|
||||||
"danger",
|
"danger",
|
||||||
)
|
)
|
||||||
return redirect(url_for("trash.overview"))
|
return redirect(url_for("trash.overview"))
|
||||||
@@ -222,13 +222,13 @@ def restore(kind: str, ident: str):
|
|||||||
return redirect(url_for("trash.overview"))
|
return redirect(url_for("trash.overview"))
|
||||||
|
|
||||||
|
|
||||||
@bp.post("/<kind>/<ident>/endgueltig-loeschen")
|
@bp.post("/<kind>/<ident>/purge")
|
||||||
@permission_required("trash.delete")
|
@permission_required("trash.delete")
|
||||||
def purge(kind: str, ident: str):
|
def purge(kind: str, ident: str):
|
||||||
extension = core()
|
extension = core()
|
||||||
if extension.license is not None and not extension.license.feature_allowed("trash_restore"):
|
if extension.license is not None and not extension.license.feature_allowed("trash_restore"):
|
||||||
flash(
|
flash(
|
||||||
"Das endgueltige Loeschen erfordert eine gueltige, aktivierte Lizenz.",
|
"Das endgültige Löschen erfordert eine gültige, aktivierte Lizenz.",
|
||||||
"danger",
|
"danger",
|
||||||
)
|
)
|
||||||
return redirect(url_for("trash.overview"))
|
return redirect(url_for("trash.overview"))
|
||||||
@@ -246,18 +246,18 @@ def purge(kind: str, ident: str):
|
|||||||
ip=client_ip(),
|
ip=client_ip(),
|
||||||
severity="warning",
|
severity="warning",
|
||||||
)
|
)
|
||||||
flash("Eintrag endgueltig entfernt.", "success")
|
flash("Eintrag endgültig entfernt.", "success")
|
||||||
except (rbac.ValidationError, rbac.PermissionDenied) as exc:
|
except (rbac.ValidationError, rbac.PermissionDenied) as exc:
|
||||||
flash(str(exc), "danger")
|
flash(str(exc), "danger")
|
||||||
return redirect(url_for("trash.overview"))
|
return redirect(url_for("trash.overview"))
|
||||||
|
|
||||||
|
|
||||||
@bp.post("/leeren")
|
@bp.post("/empty")
|
||||||
@permission_required("trash.delete")
|
@permission_required("trash.delete")
|
||||||
def purge_all():
|
def purge_all():
|
||||||
extension = core()
|
extension = core()
|
||||||
if extension.license is not None and not extension.license.feature_allowed("trash_restore"):
|
if extension.license is not None and not extension.license.feature_allowed("trash_restore"):
|
||||||
flash("Das endgueltige Loeschen erfordert eine gueltige, aktivierte Lizenz.", "danger")
|
flash("Das endgültige Löschen erfordert eine gültige, aktivierte Lizenz.", "danger")
|
||||||
return redirect(url_for("trash.overview"))
|
return redirect(url_for("trash.overview"))
|
||||||
|
|
||||||
kind = request.form.get("kind") or ""
|
kind = request.form.get("kind") or ""
|
||||||
@@ -274,9 +274,9 @@ def purge_all():
|
|||||||
actor_id=current_user.id,
|
actor_id=current_user.id,
|
||||||
action="trash.emptied",
|
action="trash.emptied",
|
||||||
target=kind or "alle",
|
target=kind or "alle",
|
||||||
detail=f"{removed} Eintraege",
|
detail=f"{removed} Einträge",
|
||||||
ip=client_ip(),
|
ip=client_ip(),
|
||||||
severity="warning",
|
severity="warning",
|
||||||
)
|
)
|
||||||
flash(f"{removed} Eintraege endgueltig entfernt.", "success")
|
flash(f"{removed} Einträge endgültig entfernt.", "success")
|
||||||
return redirect(url_for("trash.overview"))
|
return redirect(url_for("trash.overview"))
|
||||||
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "tesm-licensing"
|
name = "tesm-licensing"
|
||||||
version = "2.3.3"
|
version = "2.7.6"
|
||||||
description = "Ed25519-basiertes Lizenz- und Aktivierungsprotokoll fuer TESM und TESM-Lizenzserver"
|
description = "Ed25519-basiertes Lizenz- und Aktivierungsprotokoll fuer TESM und TESM-Lizenzserver"
|
||||||
requires-python = ">=3.11"
|
requires-python = ">=3.11"
|
||||||
dependencies = ["cryptography>=42"]
|
dependencies = ["cryptography>=42"]
|
||||||
|
|||||||
@@ -42,7 +42,7 @@ def _b64d(value: str, *, field: str) -> bytes:
|
|||||||
try:
|
try:
|
||||||
return base64.b64decode(value, validate=True)
|
return base64.b64decode(value, validate=True)
|
||||||
except (binascii.Error, ValueError, TypeError) as exc: # pragma: no cover - defensiv
|
except (binascii.Error, ValueError, TypeError) as exc: # pragma: no cover - defensiv
|
||||||
raise ValueError(f"{field} ist kein gueltiges Base64") from exc
|
raise ValueError(f"{field} ist kein gültiges Base64") from exc
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@@ -96,7 +96,7 @@ def decode_code(code: str) -> dict[str, Any]:
|
|||||||
try:
|
try:
|
||||||
data = json.loads(base64.urlsafe_b64decode(cleaned.encode("ascii")).decode("utf-8"))
|
data = json.loads(base64.urlsafe_b64decode(cleaned.encode("ascii")).decode("utf-8"))
|
||||||
except (binascii.Error, ValueError, UnicodeDecodeError) as exc:
|
except (binascii.Error, ValueError, UnicodeDecodeError) as exc:
|
||||||
raise ValueError("Code ist unlesbar oder beschaedigt") from exc
|
raise ValueError("Code ist unlesbar oder beschädigt") from exc
|
||||||
if not isinstance(data, dict):
|
if not isinstance(data, dict):
|
||||||
raise ValueError("Code enthaelt kein Objekt")
|
raise ValueError("Code enthält kein Objekt")
|
||||||
return data
|
return data
|
||||||
@@ -161,7 +161,7 @@ def split_bundle(bundle: Mapping[str, Any]) -> tuple[dict[str, Any], str]:
|
|||||||
eingebettetem ``license_privkey``.
|
eingebettetem ``license_privkey``.
|
||||||
"""
|
"""
|
||||||
if not isinstance(bundle, Mapping):
|
if not isinstance(bundle, Mapping):
|
||||||
raise InvalidLicenseFile("Lizenzdatei enthaelt kein Objekt")
|
raise InvalidLicenseFile("Lizenzdatei enthält kein Objekt")
|
||||||
|
|
||||||
inner = bundle.get("license")
|
inner = bundle.get("license")
|
||||||
if isinstance(inner, Mapping):
|
if isinstance(inner, Mapping):
|
||||||
@@ -194,10 +194,10 @@ def verify_license(
|
|||||||
|
|
||||||
missing = [name for name in _PAYLOAD_FIELDS if name not in document]
|
missing = [name for name in _PAYLOAD_FIELDS if name not in document]
|
||||||
if missing:
|
if missing:
|
||||||
raise InvalidLicenseFile(f"Lizenzdokument unvollstaendig: {', '.join(missing)}")
|
raise InvalidLicenseFile(f"Lizenzdokument unvollständig: {', '.join(missing)}")
|
||||||
if int(document.get("fmt") or 0) != FORMAT_VERSION:
|
if int(document.get("fmt") or 0) != FORMAT_VERSION:
|
||||||
raise InvalidLicenseFile(
|
raise InvalidLicenseFile(
|
||||||
f"Nicht unterstuetzte Formatversion: {document.get('fmt')!r}"
|
f"Nicht unterstützte Formatversion: {document.get('fmt')!r}"
|
||||||
f" (erwartet {FORMAT_VERSION})"
|
f" (erwartet {FORMAT_VERSION})"
|
||||||
)
|
)
|
||||||
if document.get("type") not in LICENSE_TYPES:
|
if document.get("type") not in LICENSE_TYPES:
|
||||||
@@ -207,17 +207,17 @@ def verify_license(
|
|||||||
|
|
||||||
master_pubkey = str(document.get("master_pubkey") or "")
|
master_pubkey = str(document.get("master_pubkey") or "")
|
||||||
if key_id(master_pubkey) != str(document.get("master_key_id") or ""):
|
if key_id(master_pubkey) != str(document.get("master_key_id") or ""):
|
||||||
raise InvalidLicenseFile("master_key_id passt nicht zum eingebetteten Schluessel")
|
raise InvalidLicenseFile("master_key_id passt nicht zum eingebetteten Schlüssel")
|
||||||
|
|
||||||
payload = {k: v for k, v in document.items() if k != "signature"}
|
payload = {k: v for k, v in document.items() if k != "signature"}
|
||||||
if not verify(
|
if not verify(
|
||||||
payload, str(document.get("signature") or ""), master_pubkey, SIG_CONTEXT_LICENSE
|
payload, str(document.get("signature") or ""), master_pubkey, SIG_CONTEXT_LICENSE
|
||||||
):
|
):
|
||||||
raise SignatureInvalid("Signatur der Lizenzdatei ist ungueltig")
|
raise SignatureInvalid("Signatur der Lizenzdatei ist ungültig")
|
||||||
|
|
||||||
if trusted_master_keys and master_pubkey not in set(trusted_master_keys):
|
if trusted_master_keys and master_pubkey not in set(trusted_master_keys):
|
||||||
raise UntrustedIssuer(
|
raise UntrustedIssuer(
|
||||||
"Die Lizenz wurde nicht von einem vertrauenswuerdigen Lizenzserver ausgestellt"
|
"Die Lizenz wurde nicht von einem vertrauenswürdigen Lizenzserver ausgestellt"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -236,7 +236,7 @@ def license_status(
|
|||||||
lifetime = bool(document.get("lifetime"))
|
lifetime = bool(document.get("lifetime"))
|
||||||
warnings: list[str] = []
|
warnings: list[str] = []
|
||||||
if not issuer_pinned:
|
if not issuer_pinned:
|
||||||
warnings.append("Aussteller nicht gepinnt -- nur innere Konsistenz geprueft")
|
warnings.append("Aussteller nicht gepinnt – nur innere Konsistenz geprüft")
|
||||||
|
|
||||||
issued_at: datetime | None = None
|
issued_at: datetime | None = None
|
||||||
expires_at: datetime | None = None
|
expires_at: datetime | None = None
|
||||||
@@ -248,7 +248,7 @@ def license_status(
|
|||||||
try:
|
try:
|
||||||
parsed = parse_iso(str(raw))
|
parsed = parse_iso(str(raw))
|
||||||
except ValueError:
|
except ValueError:
|
||||||
warnings.append(f"Feld {name} ist kein gueltiges Datum")
|
warnings.append(f"Feld {name} ist kein gültiges Datum")
|
||||||
continue
|
continue
|
||||||
if name == "issued_at":
|
if name == "issued_at":
|
||||||
issued_at = parsed
|
issued_at = parsed
|
||||||
|
|||||||
@@ -77,7 +77,7 @@ def build_client_request(
|
|||||||
if action not in ACTIONS:
|
if action not in ACTIONS:
|
||||||
raise ProtocolViolation(f"Unbekannte Aktion: {action!r}")
|
raise ProtocolViolation(f"Unbekannte Aktion: {action!r}")
|
||||||
if not client_private_key:
|
if not client_private_key:
|
||||||
raise ProtocolViolation("Kein Client-Schluessel vorhanden -- Lizenz unvollstaendig importiert")
|
raise ProtocolViolation("Kein Client-Schlüssel vorhanden | Lizenz unvollständig importiert")
|
||||||
|
|
||||||
payload: dict[str, Any] = {
|
payload: dict[str, Any] = {
|
||||||
"fmt": FORMAT_VERSION,
|
"fmt": FORMAT_VERSION,
|
||||||
@@ -130,22 +130,22 @@ def verify_master_response(
|
|||||||
if not isinstance(response, Mapping):
|
if not isinstance(response, Mapping):
|
||||||
raise ProtocolViolation("Antwort ist kein Objekt")
|
raise ProtocolViolation("Antwort ist kein Objekt")
|
||||||
if int(response.get("fmt") or 0) != FORMAT_VERSION:
|
if int(response.get("fmt") or 0) != FORMAT_VERSION:
|
||||||
raise ProtocolViolation("Nicht unterstuetzte Protokollversion")
|
raise ProtocolViolation("Nicht unterstützte Protokollversion")
|
||||||
|
|
||||||
payload = {k: v for k, v in response.items() if k != "signature"}
|
payload = {k: v for k, v in response.items() if k != "signature"}
|
||||||
if not verify(
|
if not verify(
|
||||||
payload, str(response.get("signature") or ""), master_public_key, SIG_CONTEXT_RESPONSE
|
payload, str(response.get("signature") or ""), master_public_key, SIG_CONTEXT_RESPONSE
|
||||||
):
|
):
|
||||||
raise SignatureInvalid("Signatur der Antwort ist ungueltig")
|
raise SignatureInvalid("Signatur der Antwort ist ungültig")
|
||||||
|
|
||||||
if str(response.get("license_id") or "") != expected_license_id:
|
if str(response.get("license_id") or "") != expected_license_id:
|
||||||
raise ProtocolViolation("Antwort gehoert zu einer anderen Lizenz")
|
raise ProtocolViolation("Antwort gehört zu einer anderen Lizenz")
|
||||||
if str(response.get("fingerprint") or "") != expected_fingerprint:
|
if str(response.get("fingerprint") or "") != expected_fingerprint:
|
||||||
raise ProtocolViolation("Antwort gehoert zu einer anderen Maschine")
|
raise ProtocolViolation("Antwort gehört zu einer anderen Maschine")
|
||||||
if str(response.get("action") or "") != expected_action:
|
if str(response.get("action") or "") != expected_action:
|
||||||
raise ProtocolViolation("Antwort gehoert zu einer anderen Aktion")
|
raise ProtocolViolation("Antwort gehört zu einer anderen Aktion")
|
||||||
if str(response.get("nonce") or "") != expected_nonce:
|
if str(response.get("nonce") or "") != expected_nonce:
|
||||||
raise ReplayDetected("Antwort gehoert nicht zu dieser Anfrage")
|
raise ReplayDetected("Antwort gehört nicht zu dieser Anfrage")
|
||||||
if str(response.get("status") or "") not in RESPONSE_STATES:
|
if str(response.get("status") or "") not in RESPONSE_STATES:
|
||||||
raise ProtocolViolation("Unbekannter Antwortstatus")
|
raise ProtocolViolation("Unbekannter Antwortstatus")
|
||||||
|
|
||||||
@@ -156,4 +156,4 @@ def verify_master_response(
|
|||||||
if not isinstance(update, Mapping):
|
if not isinstance(update, Mapping):
|
||||||
raise ProtocolViolation("license_update ist kein Objekt")
|
raise ProtocolViolation("license_update ist kein Objekt")
|
||||||
if int(update.get("seq") or 0) < int(current_seq or 0):
|
if int(update.get("seq") or 0) < int(current_seq or 0):
|
||||||
raise ReplayDetected("license_update ist aelter als die installierte Lizenz")
|
raise ReplayDetected("license_update ist älter als die installierte Lizenz")
|
||||||
Reference in new issue
Block a user