Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3ea7937b5b | ||
|
|
80d9e30bd4 | ||
|
|
bde2df2b44 | ||
|
|
76a73017db | ||
|
|
db06ddc054 | ||
|
|
e0a048fbcc | ||
|
|
9d736bb91f | ||
|
|
914609b758 | ||
|
|
af81c92695 | ||
|
|
a528e9f3b2 | ||
|
|
3b3ce96678 | ||
|
|
42316ba137 | ||
|
|
d34baa67be | ||
|
|
491724beb5 | ||
|
|
b54b87ca6f | ||
|
|
270ba455a8 | ||
|
|
f3b312046f | ||
|
|
436b080deb | ||
|
|
85c40ccfbd | ||
|
|
0ac651a1b9 | ||
|
|
4275c84a6c | ||
|
|
4f4310bfe4 | ||
|
|
1ad2f3f9a5 | ||
|
|
9d5eac1db2 | ||
|
|
2fd78ac26b | ||
|
|
86c09d8a0f | ||
|
|
70f6b005a9 | ||
|
|
57eb7764cc | ||
|
|
30c2c56656 | ||
|
|
292ea622a7 | ||
|
|
0d99760d89 | ||
|
|
713ff12db2 | ||
|
|
2db7e03154 | ||
|
|
5dcb87d6ec | ||
|
|
dc94a8e666 | ||
|
|
c639f4f507 | ||
|
|
ad0195bab0 | ||
|
|
ce22239d78 | ||
|
|
92c8abb79b | ||
|
|
dcb4f57eeb | ||
|
|
11a55b20f8 | ||
|
|
ed17bce40a | ||
|
|
5282cd8f2d | ||
|
|
754cbea6bb | ||
|
|
07ae69e3b7 | ||
|
|
d3c700283a | ||
|
|
61a47debdc | ||
|
|
bec74e6936 | ||
|
|
d2679361bc | ||
|
|
20c7fd8813 | ||
|
|
eaca6f7990 | ||
|
|
4eb1c0c2d8 | ||
|
|
c0a6dd057c | ||
|
|
8c2cd10ee4 | ||
|
|
2c3ca4acc1 | ||
|
|
f16ba7d875 | ||
|
|
7bfe328e4b | ||
|
|
5b9373bb24 | ||
|
|
f77c8b221c | ||
|
|
985deb7a24 | ||
|
|
258e90bf4f | ||
|
|
337a52cc38 | ||
|
|
160675e368 | ||
|
|
3bbddd7d29 |
No files matched your search
@@ -23,7 +23,8 @@ mitbringen.
|
||||
|
||||
| Wo sonst noch | |
|
||||
|---|---|
|
||||
| [alientim/tesm-license](https://gitea.int.eertmoed.net/alientim/tesm-license) | der Lizenzserver |
|
||||
| [alientim/tesm-license](https://gitea.int.eertmoed.net/alientim/tesm-license) | der Lizenzserver (Sub-Instanzen) |
|
||||
| [alientim/tesm-license-master](https://gitea.int.eertmoed.net/alientim/tesm-license-master) | Lizenzserver, Master-Ausgabe (privat) |
|
||||
| [alientim/TESM-DEV](https://gitea.int.eertmoed.net/alientim/TESM-DEV) | Entwicklung: beide Anwendungen, Tests, Architekturdoku |
|
||||
|
||||
## Zweige
|
||||
@@ -38,10 +39,18 @@ mitbringen.
|
||||
## Installation
|
||||
|
||||
```bash
|
||||
sudo bash deploy/install.sh --app tesm --https
|
||||
sudo bash install.sh --app tesm --https
|
||||
sudo -u tesm tesm-admin create-admin
|
||||
```
|
||||
|
||||
`install.sh` liegt oben im Paket und genügt auch **allein**: ohne Paket
|
||||
drumherum lädt es sich das aktuelle Release selbst.
|
||||
|
||||
```bash
|
||||
curl -fsSLO https://gitea.int.eertmoed.net/alientim/tesm/raw/branch/main/install.sh
|
||||
sudo bash install.sh --app tesm --https
|
||||
```
|
||||
|
||||
Danach `https://<host>/`. Ohne `--domain` entsteht ein selbstsigniertes
|
||||
Zertifikat; der Browser warnt einmal, die Verbindung ist verschlüsselt.
|
||||
|
||||
@@ -152,3 +161,19 @@ anders gelöst ist. Die wichtigsten:
|
||||
* **Zwei Installationen stören sich nicht** -- eigener `SITE_KEY` für
|
||||
nginx-Site, netplan, logrotate, Zertifikate und Sitzungscookie.
|
||||
* **Verschachtelte Datenbankverbindungen sind ein Fehler**, kein Deadlock.
|
||||
|
||||
---
|
||||
|
||||
## Von einer Installation der Fassung 1.x
|
||||
|
||||
Eine einzelne Datei genügt -- sie lädt das Release selbst:
|
||||
|
||||
```bash
|
||||
curl -fsSLO https://gitea.int.eertmoed.net/alientim/tesm/raw/branch/main/deploy/upgrade.sh
|
||||
sudo bash upgrade.sh --app tesm
|
||||
```
|
||||
|
||||
Sichert die alte Installation, schaltet sie ab, installiert diese Fassung und
|
||||
übernimmt die Daten. Einzelheiten und was **nicht** mitkommt:
|
||||
[docs/BETRIEB.md](docs/BETRIEB.md).
|
||||
|
||||
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "tesm"
|
||||
version = "2.0.7"
|
||||
version = "2.8.30"
|
||||
description = "TESM -- Verwaltung und Ueberwachung von PoE-Endgeraeten und Aruba-Switchen"
|
||||
requires-python = ">=3.11"
|
||||
dependencies = [
|
||||
|
||||
@@ -19,7 +19,7 @@ from tesm_core.extension import core
|
||||
from tesm_core.views import trash as trash_views
|
||||
|
||||
from .navigation import NAVIGATION
|
||||
from .permissions import PERMISSIONS, ROLE_PRESETS
|
||||
from .permissions import DHCP_PAGE, PERMISSIONS, ROLE_PRESETS
|
||||
from .schema import TESM_MIGRATIONS
|
||||
from .settings_spec import TESM_SETTINGS
|
||||
|
||||
@@ -100,6 +100,7 @@ def _register_blueprints(app: Flask) -> None:
|
||||
settings,
|
||||
switches,
|
||||
hostkeys,
|
||||
screen,
|
||||
terminal,
|
||||
)
|
||||
|
||||
@@ -116,6 +117,7 @@ def _register_blueprints(app: Flask) -> None:
|
||||
app.register_blueprint(license_bp.bp)
|
||||
app.register_blueprint(backup.bp)
|
||||
terminal.init_app(app)
|
||||
screen.init_app(app)
|
||||
|
||||
# Die Statusuebersicht ist die Startseite; ``index`` bleibt als Endpunkt
|
||||
# bestehen, weil der Kern (Weiterleitungen, Fehlerseiten) darauf verweist.
|
||||
@@ -164,7 +166,7 @@ def _mount_shares_after_login(conn: Any, user: Any, secret: str, group_dns: tupl
|
||||
group_dns=group_dns,
|
||||
)
|
||||
for message in messages:
|
||||
current_app.logger.info("Dateifreigabe fuer %s -- %s", user.username, message)
|
||||
current_app.logger.info("Dateifreigabe für %s | %s", user.username, message)
|
||||
|
||||
|
||||
def _release_shares_on_logout(conn: Any, user: Any) -> None:
|
||||
@@ -174,14 +176,19 @@ def _release_shares_on_logout(conn: Any, user: Any) -> None:
|
||||
dem Abmelden bestehen, waere sein Zugriff weiter offen -- bis irgendwann
|
||||
die Zeitgrenze greift. Das ist zu lange.
|
||||
"""
|
||||
from flask import session
|
||||
|
||||
from .services import fileshare
|
||||
|
||||
extension = core()
|
||||
# Nur die Einbindungen dieser Sitzung: wer an zwei Geraeten angemeldet ist,
|
||||
# soll am zweiten weiterarbeiten koennen.
|
||||
sitzung = str(getattr(session, "sid", "") or "")
|
||||
with extension.database.transaction(conn):
|
||||
released = fileshare.release_all_for_user(conn, user.id)
|
||||
released = fileshare.release_all_for_user(conn, user.id, sitzung)
|
||||
if released:
|
||||
current_app.logger.info(
|
||||
"Dateifreigaben fuer %s geloest: %d", user.username, released
|
||||
"Dateifreigaben für %s gelöst: %d", user.username, released
|
||||
)
|
||||
|
||||
|
||||
@@ -200,24 +207,24 @@ def _status_pills(user: Any) -> list[dict[str, str]]:
|
||||
|
||||
# Timer bis zum naechsten Durchlauf, mit Knopf fuer "sofort pruefen". Der
|
||||
# Knopf gehoert in die Pille, weil er genau die Angabe daneben zuruecksetzt.
|
||||
if user.has_any(("devices.view", "monitoring.view")):
|
||||
if user.has_any(("devices.read", "monitoring.read")):
|
||||
intervall = int(extension.settings.get(verbindung, "monitor_interval_seconds") or 300)
|
||||
faellig = monitor.next_check_at(verbindung, intervall)
|
||||
if faellig is not None:
|
||||
eintrag = {
|
||||
"label": "Naechste Pruefung in",
|
||||
"label": "Nächste Prüfung in",
|
||||
"countdown_to": faellig.isoformat(),
|
||||
"countdown_done": "jetzt",
|
||||
"css_class": "",
|
||||
"dot": "1",
|
||||
"dot_class": "",
|
||||
"title": f"Der Ueberwachungsdienst prueft alle {intervall} Sekunden.",
|
||||
"title": f"Der Überwachungsdienst prüft alle {intervall} Sekunden.",
|
||||
}
|
||||
if user.has("settings_system.execute"):
|
||||
from flask import url_for
|
||||
|
||||
eintrag["action"] = url_for("settings.run_check_now")
|
||||
eintrag["action_title"] = "Alle Clients sofort pruefen"
|
||||
eintrag["action_title"] = "Alle Clients sofort prüfen"
|
||||
pills.append(eintrag)
|
||||
|
||||
counts = monitor.dashboard_counts(verbindung)
|
||||
@@ -228,11 +235,11 @@ def _status_pills(user: Any) -> list[dict[str, str]]:
|
||||
"css_class": "status-pill--danger",
|
||||
"dot": "1",
|
||||
"dot_class": "status-pill__dot--pulse",
|
||||
"title": "Geraete, die beim letzten Durchlauf nicht geantwortet haben.",
|
||||
"title": "Geräte, die beim letzten Durchlauf nicht geantwortet haben.",
|
||||
}
|
||||
)
|
||||
|
||||
if user.has_any(("dhcp.view", "dhcp.edit")) and extension.license is not None:
|
||||
if user.has_any(DHCP_PAGE) and extension.license is not None:
|
||||
if extension.license.module_active("dhcp") and extension.sysops is not None:
|
||||
state = extension.sysops.service_state(kea.SERVICE_UNIT)
|
||||
if state["installed"] == "ja":
|
||||
@@ -265,7 +272,7 @@ def _register_trash_providers(app: Flask) -> None:
|
||||
conn, "devices", int(ident), unique_column="mac"
|
||||
),
|
||||
purge_fn=lambda conn, ident: inventory.purge_row(conn, "devices", int(ident)),
|
||||
hint="Die MAC-Adresse bleibt reserviert, solange das Geraet hier liegt.",
|
||||
hint="Die MAC-Adresse bleibt reserviert, solange das Gerät hier liegt.",
|
||||
)
|
||||
)
|
||||
trash_views.register(
|
||||
@@ -294,7 +301,7 @@ def _register_trash_providers(app: Flask) -> None:
|
||||
conn, "credentials", int(ident), unique_column="name"
|
||||
),
|
||||
purge_fn=lambda conn, ident: inventory.purge_row(conn, "credentials", int(ident)),
|
||||
hint="Endgueltiges Loeschen entfernt auch das gespeicherte Passwort.",
|
||||
hint="Endgültiges Löschen entfernt auch das gespeicherte Passwort.",
|
||||
)
|
||||
)
|
||||
|
||||
@@ -310,7 +317,7 @@ def _startup_tasks(app: Flask) -> None:
|
||||
extension = core(app)
|
||||
if not extension.config.web_process:
|
||||
logger.info(
|
||||
"Startaufgaben uebersprungen -- dieser Prozess ist nicht als Webprozess markiert."
|
||||
"Startaufgaben übersprungen – dieser Prozess ist nicht als Webprozess markiert."
|
||||
)
|
||||
return
|
||||
|
||||
@@ -323,7 +330,7 @@ def _startup_tasks(app: Flask) -> None:
|
||||
# die Oberflaeche Freigaben, die es nicht gibt.
|
||||
vergessen = fileshare.forget_all_mounts(conn)
|
||||
if orphaned:
|
||||
logger.warning("%s unterbrochene Wartungsauftraege als unklar markiert", orphaned)
|
||||
logger.warning("%s unterbrochene Wartungsaufträge als unklar markiert", orphaned)
|
||||
if vergessen:
|
||||
logger.info(
|
||||
"%s Freigabe-Einbindungen eines vorherigen Prozesses vergessen "
|
||||
@@ -353,19 +360,32 @@ def _start_fileshare_sweep(app: Flask) -> None:
|
||||
weiter eingebunden.
|
||||
"""
|
||||
import threading
|
||||
import time
|
||||
|
||||
from .services import fileshare
|
||||
|
||||
def einmal() -> None:
|
||||
with app.app_context():
|
||||
geloest = fileshare.sweep_expired(core(app).database)
|
||||
if geloest:
|
||||
logger.info("%s abgelaufene Freigabe-Einbindungen gelöst", geloest)
|
||||
|
||||
def schleife() -> None:
|
||||
while True:
|
||||
time.sleep(FILESHARE_SWEEP_SECONDS)
|
||||
try:
|
||||
with app.app_context():
|
||||
geloest = fileshare.sweep_expired(core(app).database)
|
||||
if geloest:
|
||||
logger.info("%s abgelaufene Freigabe-Einbindungen geloest", geloest)
|
||||
except Exception: # noqa: BLE001 - der Webprozess darf nie sterben
|
||||
logger.exception("Aufraeumen der Dateifreigaben fehlgeschlagen")
|
||||
# Zwei Netze: das innere haelt den Faden am Leben, wenn ein einzelner
|
||||
# Durchlauf scheitert; das aeussere sorgt dafuer, dass sein Ende
|
||||
# ueberhaupt auffaellt. Vorher fehlte der Import von "time" -- der
|
||||
# NameError kam beim ersten sleep, also vor jedem try, und der Faden
|
||||
# war weg, ohne eine Zeile zu hinterlassen.
|
||||
try:
|
||||
while True:
|
||||
time.sleep(FILESHARE_SWEEP_SECONDS)
|
||||
try:
|
||||
einmal()
|
||||
except Exception: # noqa: BLE001 - der Webprozess darf nie sterben
|
||||
logger.exception("Aufräumen der Dateifreigaben fehlgeschlagen")
|
||||
except BaseException: # noqa: BLE001 - auch ein Abbruch gehoert protokolliert
|
||||
logger.exception("Der Aufräumfaden der Dateifreigaben ist beendet")
|
||||
raise
|
||||
|
||||
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
|
||||
|
||||
bp = Blueprint("backup", __name__, url_prefix="/sicherung")
|
||||
bp = Blueprint("backup", __name__, url_prefix="/administration/backup")
|
||||
|
||||
MAX_IMPORT_BYTES = 64 * 1024 * 1024
|
||||
PREVIEW_SESSION_KEY = "_import_token"
|
||||
@@ -37,14 +37,14 @@ def _allowed_categories() -> list[transfer.Category]:
|
||||
for category in transfer.CATEGORIES:
|
||||
if category.admin_only and not current_user.is_admin:
|
||||
continue
|
||||
if category.sensitive and not current_user.has("backup.secrets"):
|
||||
if category.sensitive and not current_user.has("backup_export.secrets"):
|
||||
continue
|
||||
allowed.append(category)
|
||||
return allowed
|
||||
|
||||
|
||||
@bp.get("/")
|
||||
@permission_required("backup.view", "backup.export", "backup.execute", mode="any")
|
||||
@permission_required("backup_export.read", "backup_import.read", mode="any")
|
||||
def overview():
|
||||
extension = core()
|
||||
conn = db()
|
||||
@@ -72,13 +72,13 @@ def overview():
|
||||
min_passphrase=transfer.MIN_PASSPHRASE_LENGTH,
|
||||
preview_ttl=transfer.PREVIEW_TTL_MINUTES,
|
||||
license_hint=license_hint,
|
||||
can_secrets=current_user.has("backup.secrets"),
|
||||
can_secrets=current_user.has("backup_export.secrets"),
|
||||
)
|
||||
|
||||
|
||||
@bp.post("/export")
|
||||
@permission_required("backup.export")
|
||||
@reauth_required
|
||||
@permission_required("backup_export.export")
|
||||
# Die frische Bestaetigung verlangt das Recht selbst (SENSITIVE_ACTIONS).
|
||||
def export():
|
||||
extension = core()
|
||||
conn = db()
|
||||
@@ -92,16 +92,16 @@ def export():
|
||||
allowed = {category.key for category in _allowed_categories()}
|
||||
chosen = [key for key in request.form.getlist("categories") if key in allowed]
|
||||
if not chosen:
|
||||
flash("Es wurde keine Kategorie ausgewaehlt.", "warning")
|
||||
flash("Es wurde keine Kategorie ausgewählt.", "warning")
|
||||
return redirect(url_for("backup.overview"))
|
||||
|
||||
include_secrets = (
|
||||
request.form.get("include_secrets") == "1" and current_user.has("backup.secrets")
|
||||
request.form.get("include_secrets") == "1" and current_user.has("backup_export.secrets")
|
||||
)
|
||||
passphrase = request.form.get("passphrase") or ""
|
||||
repeat = request.form.get("passphrase_repeat") or ""
|
||||
if passphrase != repeat:
|
||||
flash("Die beiden Passphrasen stimmen nicht ueberein.", "danger")
|
||||
flash("Die beiden Passphrasen stimmen nicht überein.", "danger")
|
||||
return redirect(url_for("backup.overview"))
|
||||
|
||||
try:
|
||||
@@ -129,18 +129,18 @@ def export():
|
||||
)
|
||||
|
||||
|
||||
@bp.post("/import/vorschau")
|
||||
@permission_required("backup.execute")
|
||||
@bp.post("/import/preview")
|
||||
@permission_required("backup_import.execute")
|
||||
@reauth_required
|
||||
def import_preview():
|
||||
conn = db()
|
||||
upload = request.files.get("backup_file")
|
||||
if upload is None or not upload.filename:
|
||||
flash("Keine Datei ausgewaehlt.", "danger")
|
||||
flash("Keine Datei ausgewählt.", "danger")
|
||||
return redirect(url_for("backup.overview"))
|
||||
raw = upload.stream.read(MAX_IMPORT_BYTES + 1)
|
||||
if len(raw) > MAX_IMPORT_BYTES:
|
||||
flash("Die Datei ist zu gross.", "danger")
|
||||
flash("Die Datei ist zu groß.", "danger")
|
||||
return redirect(url_for("backup.overview"))
|
||||
|
||||
try:
|
||||
@@ -154,28 +154,28 @@ def import_preview():
|
||||
session[PREVIEW_SESSION_KEY] = token
|
||||
_audit("backup.import_previewed", f"Erstellt {header.get('created_at')}", severity="notice")
|
||||
flash(
|
||||
f"Datei geoeffnet. Sie haben {transfer.PREVIEW_TTL_MINUTES} Minuten Zeit, die "
|
||||
"Uebernahme zu bestaetigen.",
|
||||
f"Datei geöffnet. Sie haben {transfer.PREVIEW_TTL_MINUTES} Minuten Zeit, die "
|
||||
"Übernahme zu bestätigen.",
|
||||
"success",
|
||||
)
|
||||
return redirect(url_for("backup.overview"))
|
||||
|
||||
|
||||
@bp.post("/import/uebernehmen")
|
||||
@permission_required("backup.execute")
|
||||
@bp.post("/import/apply")
|
||||
@permission_required("backup_import.execute")
|
||||
@reauth_required
|
||||
def import_apply():
|
||||
extension = core()
|
||||
conn = db()
|
||||
token = session.get(PREVIEW_SESSION_KEY, "")
|
||||
if not token:
|
||||
flash("Es liegt keine geoeffnete Sicherung vor.", "danger")
|
||||
flash("Es liegt keine geöffnete Sicherung vor.", "danger")
|
||||
return redirect(url_for("backup.overview"))
|
||||
|
||||
allowed = {category.key for category in _allowed_categories()}
|
||||
chosen = [key for key in request.form.getlist("categories") if key in allowed]
|
||||
if not chosen:
|
||||
flash("Es wurde keine Kategorie ausgewaehlt.", "warning")
|
||||
flash("Es wurde keine Kategorie ausgewählt.", "warning")
|
||||
return redirect(url_for("backup.overview"))
|
||||
|
||||
try:
|
||||
@@ -206,7 +206,7 @@ def import_apply():
|
||||
action="backup.imported",
|
||||
detail="; ".join(
|
||||
f"{key}: {counts['created']} neu, {counts['updated']} aktualisiert, "
|
||||
f"{counts['skipped']} uebersprungen"
|
||||
f"{counts['skipped']} übersprungen"
|
||||
for key, counts in report.items()
|
||||
),
|
||||
ip=client_ip(),
|
||||
@@ -217,16 +217,16 @@ def import_apply():
|
||||
lines = [
|
||||
f"{transfer.CATEGORY_BY_KEY[key].label}: {counts['created']} neu, "
|
||||
f"{counts['updated']} aktualisiert"
|
||||
+ (f", {counts['skipped']} uebersprungen" if counts["skipped"] else "")
|
||||
+ (f", {counts['skipped']} übersprungen" if counts["skipped"] else "")
|
||||
for key, counts in report.items()
|
||||
if key in transfer.CATEGORY_BY_KEY
|
||||
]
|
||||
flash("Uebernahme abgeschlossen. " + " | ".join(lines), "success")
|
||||
flash("Übernahme abgeschlossen. " + " – ".join(lines), "success")
|
||||
return redirect(url_for("backup.overview"))
|
||||
|
||||
|
||||
@bp.post("/import/verwerfen")
|
||||
@permission_required("backup.execute")
|
||||
@bp.post("/import/discard")
|
||||
@permission_required("backup_import.execute")
|
||||
def import_discard():
|
||||
token = session.pop(PREVIEW_SESSION_KEY, "")
|
||||
if token:
|
||||
|
||||
@@ -6,13 +6,13 @@ from typing import Any
|
||||
|
||||
from flask import Blueprint, abort, flash, redirect, render_template, request, url_for
|
||||
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.extension import core, db, transaction
|
||||
|
||||
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:
|
||||
@@ -29,7 +29,7 @@ def _audit(conn: Any, action: str, target: str, detail: str = "", severity: str
|
||||
|
||||
|
||||
@bp.get("/")
|
||||
@permission_required("credentials.view")
|
||||
@permission_required("credentials.read")
|
||||
def overview():
|
||||
conn = db()
|
||||
return render_template(
|
||||
@@ -39,7 +39,7 @@ def overview():
|
||||
)
|
||||
|
||||
|
||||
@bp.post("/neu")
|
||||
@bp.post("/new")
|
||||
@permission_required("credentials.create")
|
||||
def create():
|
||||
try:
|
||||
@@ -62,7 +62,7 @@ def create():
|
||||
|
||||
|
||||
@bp.post("/<int:credential_id>")
|
||||
@permission_required("credentials.edit")
|
||||
@permission_required("credentials.write")
|
||||
def update(credential_id: int):
|
||||
try:
|
||||
with transaction() as conn:
|
||||
@@ -85,13 +85,13 @@ def update(credential_id: int):
|
||||
"; ".join(changes),
|
||||
severity="notice",
|
||||
)
|
||||
flash("Aenderungen gespeichert." if changes else "Keine Aenderungen.", "success" if changes else "info")
|
||||
flash("Änderungen gespeichert." if changes else "Keine Änderungen.", "success" if changes else "info")
|
||||
except inventory.ValidationError as exc:
|
||||
flash(str(exc), "danger")
|
||||
return redirect(url_for("credentials.overview"))
|
||||
|
||||
|
||||
@bp.post("/<int:credential_id>/loeschen")
|
||||
@bp.post("/<int:credential_id>/delete")
|
||||
@permission_required("credentials.delete")
|
||||
def delete(credential_id: int):
|
||||
try:
|
||||
@@ -109,9 +109,9 @@ def delete(credential_id: int):
|
||||
return redirect(url_for("credentials.overview"))
|
||||
|
||||
|
||||
@bp.post("/<int:credential_id>/anzeigen")
|
||||
@bp.post("/<int:credential_id>/reveal")
|
||||
@permission_required("credentials.secrets")
|
||||
@reauth_required
|
||||
# Die frische Bestaetigung verlangt das Recht selbst (SENSITIVE_ACTIONS).
|
||||
def reveal(credential_id: int):
|
||||
"""Zeigt ein gespeichertes Passwort im Klartext.
|
||||
|
||||
|
||||
@@ -14,7 +14,7 @@ from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from flask import Blueprint, abort, render_template, request
|
||||
from flask import Blueprint, abort, render_template, request, url_for
|
||||
from tesm_core.security.sessions import fragment
|
||||
from tesm_core.extension import core, db
|
||||
|
||||
@@ -39,11 +39,28 @@ def _tiles(*, authenticated: bool) -> dict[str, Any]:
|
||||
|
||||
from ..services import inventory, restart
|
||||
|
||||
from ..services import vnc as vnc_dienst
|
||||
|
||||
conn = db()
|
||||
counts = monitor.dashboard_counts(conn)
|
||||
rows = inventory.list_devices(conn, only_active=not authenticated)
|
||||
|
||||
detailed = authenticated and current_user.has("devices.view")
|
||||
detailed = authenticated and current_user.has("devices.read")
|
||||
# Einmal je Seitenaufbau, nicht je Kachel: der Port ist fuer alle derselbe,
|
||||
# und ohne das Recht wird gar nicht erst nachgesehen.
|
||||
vnc_erlaubt = detailed and current_user.has("device_vnc.execute")
|
||||
# Das Fenster setzt eine frische Passwortbestaetigung voraus -- dieselbe
|
||||
# Schwelle wie die Geraeteseite. Fehlt sie, fuehrt der Knopf dorthin: dort
|
||||
# wird danach gefragt, und ein Fenster, das sich mit "bitte erst
|
||||
# bestaetigen" oeffnet, waere ein Klick fuer nichts.
|
||||
from tesm_core.auth.service import reauth_is_fresh
|
||||
|
||||
vnc_fenster = vnc_erlaubt and reauth_is_fresh()
|
||||
vnc_port = (
|
||||
int(core().settings.get(conn, "vnc_port") or vnc_dienst.STANDARD_PORT)
|
||||
if vnc_erlaubt and core().settings.get(conn, "vnc_enabled")
|
||||
else 0
|
||||
)
|
||||
tiles: list[dict[str, Any]] = []
|
||||
for row in rows:
|
||||
entry: dict[str, Any] = {
|
||||
@@ -67,12 +84,34 @@ def _tiles(*, authenticated: bool) -> dict[str, Any]:
|
||||
"credential_category": str(row["credential_category"] or ""),
|
||||
# Nicht nur PoE: ein Geraet kann auch ueber SSH oder RPC
|
||||
# neu startbar sein. Die Kachel zeigt den bevorzugten Weg.
|
||||
# Mit Rechtefilter: die Kachel zeigt nur Wege, die
|
||||
# dieser Benutzer auch ausloesen darf.
|
||||
"restart_methods": restart.available(
|
||||
row, maintenance_active=_maintenance_active()
|
||||
row,
|
||||
maintenance_active=_maintenance_active(),
|
||||
darf=current_user.has,
|
||||
),
|
||||
# Der Knopf oeffnet die Sitzung in TESM selbst. Ein
|
||||
# vnc://-Link setzte einen eingerichteten Viewer auf dem
|
||||
# Arbeitsplatz voraus -- und tat dort, wo keiner
|
||||
# registriert ist, schlicht nichts.
|
||||
#
|
||||
# Ob dort ein Server antwortet, steht aus dem letzten
|
||||
# Durchlauf in der Datenbank. Die Uebersicht zeigt viele
|
||||
# Kacheln und laedt sich selbst nach; ein
|
||||
# Verbindungsversuch je Kachel und Aufbau waere genau die
|
||||
# Art Hintergrundlast, die niemand bestellt hat.
|
||||
"vnc": bool(vnc_port and row["vnc_open"]),
|
||||
"vnc_quelle": (
|
||||
url_for("screen.session_json", device_id=int(row["id"]))
|
||||
if vnc_port and row["vnc_open"]
|
||||
else ""
|
||||
),
|
||||
}
|
||||
)
|
||||
tiles.append(entry)
|
||||
# Merker fuer die Vorlage: nur mit frischer Bestaetigung gibt es das
|
||||
# Fenster; sonst zeigt die Kachel auf die Seite.
|
||||
|
||||
sections = [
|
||||
{
|
||||
@@ -80,7 +119,7 @@ def _tiles(*, authenticated: bool) -> dict[str, Any]:
|
||||
# erwartbar weg. Wer die Uebersicht ansieht, will genau das zuerst
|
||||
# wissen -- sonst haelt er einen laufenden Neustart fuer einen Ausfall.
|
||||
"key": "restarting",
|
||||
"label": "Neustart laeuft",
|
||||
"label": "Neustart läuft",
|
||||
"kind": "warning",
|
||||
"tiles": [tile for tile in tiles if tile["state"] == "restarting"],
|
||||
},
|
||||
@@ -98,7 +137,7 @@ def _tiles(*, authenticated: bool) -> dict[str, Any]:
|
||||
},
|
||||
{
|
||||
"key": "unknown",
|
||||
"label": "Noch nicht geprueft",
|
||||
"label": "Noch nicht geprüft",
|
||||
"kind": "warning",
|
||||
"tiles": [tile for tile in tiles if tile["state"] == "unknown"],
|
||||
},
|
||||
@@ -117,11 +156,12 @@ def _tiles(*, authenticated: bool) -> dict[str, Any]:
|
||||
"counts": counts,
|
||||
"sections": sections,
|
||||
"detailed": detailed,
|
||||
"vnc_fenster": vnc_fenster,
|
||||
"interval": int(core().settings.get(conn, "monitor_interval_seconds") or 300),
|
||||
}
|
||||
|
||||
|
||||
@bp.get("/uebersicht")
|
||||
@bp.get("/overview")
|
||||
def index():
|
||||
from tesm_core.auth.models import current_user
|
||||
|
||||
@@ -138,7 +178,7 @@ def index_view():
|
||||
return index()
|
||||
|
||||
|
||||
@bp.get("/uebersicht/kacheln")
|
||||
@bp.get("/overview/tiles")
|
||||
@fragment
|
||||
def tiles():
|
||||
"""Teilbereich fuer die automatische Aktualisierung -- liefert nur HTML-Fragment."""
|
||||
|
||||
@@ -11,16 +11,25 @@ from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from flask import Blueprint, abort, flash, redirect, render_template, request, url_for
|
||||
from flask import (
|
||||
Blueprint,
|
||||
Response,
|
||||
abort,
|
||||
flash,
|
||||
redirect,
|
||||
render_template,
|
||||
request,
|
||||
url_for,
|
||||
)
|
||||
from tesm_core import audit
|
||||
from tesm_core.auth.models import current_user, permission_required, reauth_required
|
||||
from tesm_core.auth.service import client_ip
|
||||
from tesm_core.db import Database
|
||||
from tesm_core.extension import core, db, transaction
|
||||
|
||||
from ..services import inventory, monitor, restart, ssh
|
||||
from ..services import inventory, monitor, restart, ssh, vnc
|
||||
|
||||
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:
|
||||
@@ -60,7 +69,7 @@ def _form_values() -> dict[str, Any]:
|
||||
|
||||
|
||||
@bp.get("/")
|
||||
@permission_required("devices.view")
|
||||
@permission_required("devices.read")
|
||||
def overview():
|
||||
conn = db()
|
||||
devices = inventory.list_devices(conn)
|
||||
@@ -100,7 +109,7 @@ def _credential_from_form(conn: Any) -> int | None:
|
||||
)
|
||||
name = (request.form.get("new_credential_name") or "").strip()
|
||||
if not name:
|
||||
raise inventory.ValidationError("Fuer neue Zugangsdaten wird eine Bezeichnung gebraucht.")
|
||||
raise inventory.ValidationError("Für neue Zugangsdaten wird eine Bezeichnung gebraucht.")
|
||||
return inventory.create_credential(
|
||||
conn,
|
||||
core().keystore,
|
||||
@@ -108,12 +117,12 @@ def _credential_from_form(conn: Any) -> int | None:
|
||||
username=(request.form.get("new_credential_username") or "").strip(),
|
||||
secret=request.form.get("new_credential_secret") or "",
|
||||
category=request.form.get("new_credential_category", "linux"),
|
||||
description="Beim Anlegen eines Geraets erfasst.",
|
||||
description="Beim Anlegen eines Geräts erfasst.",
|
||||
actor=current_user.username,
|
||||
)
|
||||
|
||||
|
||||
@bp.post("/neu")
|
||||
@bp.post("/new")
|
||||
@permission_required("devices.create")
|
||||
def create():
|
||||
try:
|
||||
@@ -130,7 +139,7 @@ def create():
|
||||
f"MAC {request.form.get('mac')}, IP {request.form.get('ip')}",
|
||||
severity="notice",
|
||||
)
|
||||
flash("Geraet angelegt.", "success")
|
||||
flash("Gerät angelegt.", "success")
|
||||
return redirect(url_for("devices.overview", highlight=device_id))
|
||||
except inventory.ValidationError as exc:
|
||||
flash(str(exc), "danger")
|
||||
@@ -138,7 +147,7 @@ def create():
|
||||
|
||||
|
||||
@bp.post("/<int:device_id>")
|
||||
@permission_required("devices.edit")
|
||||
@permission_required("devices.write")
|
||||
def update(device_id: int):
|
||||
try:
|
||||
with transaction() as conn:
|
||||
@@ -155,14 +164,14 @@ def update(device_id: int):
|
||||
"; ".join(changes),
|
||||
severity="notice",
|
||||
)
|
||||
flash("Aenderungen gespeichert." if changes else "Keine Aenderungen.", "success" if changes else "info")
|
||||
flash("Änderungen gespeichert." if changes else "Keine Änderungen.", "success" if changes else "info")
|
||||
except inventory.ValidationError as exc:
|
||||
flash(str(exc), "danger")
|
||||
return redirect(url_for("devices.overview"))
|
||||
|
||||
|
||||
@bp.post("/<int:device_id>/aktiv")
|
||||
@permission_required("devices.edit")
|
||||
@bp.post("/<int:device_id>/active")
|
||||
@permission_required("devices.write")
|
||||
def toggle_active(device_id: int):
|
||||
active = request.form.get("active") == "1"
|
||||
with transaction() as conn:
|
||||
@@ -176,11 +185,11 @@ def toggle_active(device_id: int):
|
||||
str(device["name"]),
|
||||
severity="notice",
|
||||
)
|
||||
flash("Geraet aktiviert." if active else "Geraet deaktiviert.", "success")
|
||||
flash("Gerät aktiviert." if active else "Gerät deaktiviert.", "success")
|
||||
return redirect(request.referrer or url_for("devices.overview"))
|
||||
|
||||
|
||||
@bp.post("/<int:device_id>/loeschen")
|
||||
@bp.post("/<int:device_id>/delete")
|
||||
@permission_required("devices.delete")
|
||||
def delete(device_id: int):
|
||||
with transaction() as conn:
|
||||
@@ -189,12 +198,17 @@ def delete(device_id: int):
|
||||
abort(404)
|
||||
inventory.delete_device(conn, device_id)
|
||||
_audit(conn, "device.deleted", str(device["name"]), severity="warning")
|
||||
flash("Geraet in den Papierkorb verschoben.", "success")
|
||||
flash("Gerät in den Papierkorb verschoben.", "success")
|
||||
return redirect(url_for("devices.overview"))
|
||||
|
||||
|
||||
@bp.post("/<int:device_id>/neustart")
|
||||
@permission_required("devices.execute")
|
||||
@bp.post("/<int:device_id>/restart")
|
||||
# Die Seite darf sehen, wer Clients sehen darf; welcher Weg offensteht,
|
||||
# entscheidet das Recht des Weges -- geprueft in restart.resolve() ueber
|
||||
# "darf". Ein Weg, den der Benutzer nicht ausloesen darf, existiert fuer ihn
|
||||
# nicht, und die Antwort ist dieselbe wie bei einem technisch unmoeglichen:
|
||||
# "dieser Weg steht nicht zur Verfuegung".
|
||||
@permission_required("devices.read")
|
||||
def restart_device(device_id: int):
|
||||
"""Startet ein Geraet neu -- ueber den Weg, der dafuer gewaehlt wurde.
|
||||
|
||||
@@ -214,19 +228,22 @@ def restart_device(device_id: int):
|
||||
dict(device),
|
||||
(request.form.get("method") or "").strip(),
|
||||
maintenance_active=maintenance_active,
|
||||
darf=current_user.has,
|
||||
)
|
||||
if method is None:
|
||||
options = restart.available(dict(device), maintenance_active=maintenance_active)
|
||||
options = restart.available(
|
||||
dict(device), maintenance_active=maintenance_active, darf=current_user.has
|
||||
)
|
||||
if options:
|
||||
flash(
|
||||
"Der gewaehlte Weg steht fuer dieses Geraet nicht zur Verfuegung. "
|
||||
"Moeglich waere: " + ", ".join(entry.label for entry in options),
|
||||
"Der gewählte Weg steht für dieses Gerät nicht zur Verfügung. "
|
||||
"Möglich wäre: " + ", ".join(entry.label for entry in options),
|
||||
"warning",
|
||||
)
|
||||
else:
|
||||
flash(
|
||||
f"Fuer {device['name']} ist kein Neustart moeglich: es fehlen Switch und Port "
|
||||
"fuer den PoE-Weg und passende Zugangsdaten fuer SSH oder RPC.",
|
||||
f"Für {device['name']} ist kein Neustart möglich: es fehlen Switch und Port "
|
||||
"für den PoE-Weg und passende Zugangsdaten für SSH oder RPC.",
|
||||
"warning",
|
||||
)
|
||||
return redirect(request.referrer or url_for("devices.overview"))
|
||||
@@ -262,7 +279,7 @@ def _restart_over_poe(extension: Any, conn: Any, device: Any): # type: ignore[n
|
||||
return redirect(request.referrer or url_for("devices.overview"))
|
||||
|
||||
flash(
|
||||
f"PoE-Neustart fuer {device['name']} durchgefuehrt."
|
||||
f"PoE-Neustart für {device['name']} durchgeführt."
|
||||
if outcome["ok"]
|
||||
else f"PoE-Neustart fehlgeschlagen: {outcome['message']}",
|
||||
"success" if outcome["ok"] else "danger",
|
||||
@@ -301,12 +318,16 @@ def _restart_as_job(extension: Any, conn: Any, device: Any, method: Any): # typ
|
||||
# Der Auftrag laeuft im Hintergrund und meldet sein Ergebnis erst, wenn das
|
||||
# Geraet zurueck ist. Der Zustand muss aber sofort umschlagen -- sonst sieht
|
||||
# die Uebersicht aus wie eine, in der nichts passiert ist.
|
||||
live = extension.registry.get("live_log")
|
||||
with transaction():
|
||||
monitor.mark_restarting(conn, int(device["id"]))
|
||||
monitor.mark_restarting(
|
||||
conn, int(device["id"]), live=live, trigger="manuell", method=method.key
|
||||
)
|
||||
|
||||
maintenance_service.start_background(
|
||||
maintenance_service.run_reboot,
|
||||
database=extension.database,
|
||||
live=live,
|
||||
job_id=handle.id,
|
||||
device=dict(device),
|
||||
username=username,
|
||||
@@ -319,14 +340,14 @@ def _restart_as_job(extension: Any, conn: Any, device: Any, method: Any): # typ
|
||||
method=method.key,
|
||||
)
|
||||
flash(
|
||||
f"{method.label} fuer {device['name']} gestartet. Der Verlauf steht unter Wartung.",
|
||||
f"{method.label} für {device['name']} gestartet. Der Verlauf steht unter Wartung.",
|
||||
"success",
|
||||
)
|
||||
return redirect(request.referrer or url_for("devices.overview"))
|
||||
|
||||
|
||||
@bp.get("/<int:device_id>")
|
||||
@permission_required("devices.view")
|
||||
@permission_required("devices.read")
|
||||
def detail(device_id: int):
|
||||
conn = db()
|
||||
device = inventory.get_device(conn, device_id)
|
||||
@@ -351,7 +372,71 @@ def detail(device_id: int):
|
||||
categories=inventory.DEVICE_CATEGORIES,
|
||||
host_keys=host_keys,
|
||||
method_labels=monitor.METHOD_LABELS,
|
||||
terminal_enabled=bool(extension.settings.get(conn, "terminal_enabled")),
|
||||
terminal_enabled=bool(extension.settings.get(conn, "terminal_clients_enabled")),
|
||||
vnc=_vnc_zugang(conn, device),
|
||||
)
|
||||
|
||||
|
||||
def _vnc_zugang(conn: Any, device: dict[str, Any]) -> vnc.Zugang | None:
|
||||
"""Der Bildschirmzugriff dieses Clients -- aus dem, was bekannt ist.
|
||||
|
||||
Diese Seite fragt das Geraet **nicht**. Erreichbarkeit kommt aus dem
|
||||
Ueberwachungsdurchlauf, die Anmeldeverfahren aus der letzten
|
||||
ausdruecklichen Pruefung. Der Grund steht in :func:`vnc.zugang_gemerkt`:
|
||||
jede Messung ist eine angefangene Protokollverhandlung, und ein RealVNC
|
||||
Server zaehlt so etwas mit.
|
||||
"""
|
||||
extension = core()
|
||||
if not current_user.has("device_vnc.execute"):
|
||||
return None
|
||||
if not extension.settings.get(conn, "vnc_enabled"):
|
||||
return None
|
||||
port = int(extension.settings.get(conn, "vnc_port") or vnc.STANDARD_PORT)
|
||||
return vnc.zugang_gemerkt(device, port=port)
|
||||
|
||||
|
||||
@bp.get("/<int:device_id>/vnc")
|
||||
@permission_required("device_vnc.execute")
|
||||
def vnc_connection(device_id: int):
|
||||
"""Die Verbindungsdatei -- drei Zeilen, sonst nichts.
|
||||
|
||||
Kein Passwort, kein Benutzername, keine Verschluesselungsvorgabe: was beim
|
||||
Verbinden verlangt wird, entscheidet der VNC-Server auf dem Geraet. Die
|
||||
Datei traegt nur, wohin es geht und wie die Verbindung im Viewer heisst.
|
||||
"""
|
||||
conn = db()
|
||||
device = inventory.get_device(conn, device_id)
|
||||
if device is None:
|
||||
abort(404)
|
||||
extension = core()
|
||||
if not extension.settings.get(conn, "vnc_enabled"):
|
||||
abort(404)
|
||||
port = int(extension.settings.get(conn, "vnc_port") or vnc.STANDARD_PORT)
|
||||
ziel = vnc.ziel(device)
|
||||
if not ziel:
|
||||
abort(404)
|
||||
# Kein Eintrag in der Aenderungskette: eine Verbindungsdatei ist derselbe
|
||||
# Vorgang wie die Sitzung im Browser, nur fuer einen Viewer auf dem
|
||||
# Arbeitsplatz -- und sie aendert nichts. Wer zusieht, steht im laufenden
|
||||
# Protokoll; das Aenderungsprotokoll bleibt die Liste der Eingriffe.
|
||||
live = core().registry.get("live_log")
|
||||
if live is not None:
|
||||
live.info(
|
||||
"vnc", f"{current_user.username} lädt die Verbindungsdatei für {device['name']}"
|
||||
)
|
||||
return Response(
|
||||
vnc.verbindungsdatei(
|
||||
device,
|
||||
port=port,
|
||||
warnung_aus=bool(extension.settings.get(conn, "vnc_warn_unencrypted_off")),
|
||||
),
|
||||
mimetype="application/octet-stream",
|
||||
headers={
|
||||
"Content-Disposition": f'attachment; filename="{vnc.dateiname(device)}"',
|
||||
# Eine Verbindungsdatei ist kein Dokument, das ein Proxy aufheben
|
||||
# soll -- und sie ist billig genug, sie jedes Mal neu zu erzeugen.
|
||||
"Cache-Control": "no-store",
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@@ -360,8 +445,8 @@ def detail(device_id: int):
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
@bp.post("/<int:device_id>/hostkey/pruefen")
|
||||
@permission_required("devices.edit")
|
||||
@bp.post("/<int:device_id>/host-key/probe")
|
||||
@permission_required("devices.write")
|
||||
def probe_host_key(device_id: int):
|
||||
"""Liest den Host-Schluessel des Clients, ohne sich anzumelden.
|
||||
|
||||
@@ -390,8 +475,8 @@ def probe_host_key(device_id: int):
|
||||
)
|
||||
|
||||
|
||||
@bp.post("/<int:device_id>/hostkey/freigeben")
|
||||
@permission_required("devices.edit")
|
||||
@bp.post("/<int:device_id>/host-key/trust")
|
||||
@permission_required("devices.write")
|
||||
@reauth_required
|
||||
def trust_host_key(device_id: int):
|
||||
conn = db()
|
||||
@@ -401,7 +486,7 @@ def trust_host_key(device_id: int):
|
||||
key_type = (request.form.get("key_type") or "").strip()
|
||||
fingerprint = (request.form.get("fingerprint") or "").strip()
|
||||
if not key_type or not ssh.valid_fingerprint(fingerprint):
|
||||
flash("Es wurde kein gueltiger Fingerprint uebergeben.", "danger")
|
||||
flash("Es wurde kein gültiger Fingerprint übergeben.", "danger")
|
||||
return redirect(url_for("devices.detail", device_id=device_id))
|
||||
|
||||
with transaction():
|
||||
@@ -420,12 +505,12 @@ def trust_host_key(device_id: int):
|
||||
f"{key_type} {fingerprint}",
|
||||
severity="notice",
|
||||
)
|
||||
flash("Host-Schluessel freigegeben.", "success")
|
||||
flash("Host-Schlüssel freigegeben.", "success")
|
||||
return redirect(url_for("devices.detail", device_id=device_id))
|
||||
|
||||
|
||||
@bp.post("/<int:device_id>/hostkey/verwerfen")
|
||||
@permission_required("devices.edit")
|
||||
@bp.post("/<int:device_id>/host-key/forget")
|
||||
@permission_required("devices.write")
|
||||
def forget_host_key(device_id: int):
|
||||
conn = db()
|
||||
device = inventory.get_device(conn, device_id)
|
||||
@@ -434,5 +519,5 @@ def forget_host_key(device_id: int):
|
||||
with transaction():
|
||||
ssh.forget_key(conn, str(device["ip"]), int(device["ssh_port"] or 22))
|
||||
_audit(conn, "device.host_key_forgotten", str(device["name"]), severity="warning")
|
||||
flash("Gespeicherte Host-Schluessel entfernt.", "info")
|
||||
flash("Gespeicherte Host-Schlüssel entfernt.", "info")
|
||||
return redirect(url_for("devices.detail", device_id=device_id))
|
||||
@@ -20,7 +20,8 @@ from tesm_core.auth.service import client_ip
|
||||
from tesm_core.db import Database
|
||||
from tesm_core.extension import core, db, transaction
|
||||
|
||||
from ..services import kea
|
||||
from ..services import inventory, kea
|
||||
from ..permissions import DHCP_PAGE
|
||||
|
||||
bp = Blueprint("dhcp", __name__, url_prefix="/dhcp")
|
||||
|
||||
@@ -53,7 +54,7 @@ def _settings_values() -> dict[str, Any]:
|
||||
|
||||
|
||||
@bp.get("/")
|
||||
@permission_required("dhcp.view", "dhcp.edit", "dhcp.execute", mode="any")
|
||||
@permission_required(*DHCP_PAGE, mode="any")
|
||||
def overview():
|
||||
extension = core()
|
||||
conn = db()
|
||||
@@ -72,6 +73,12 @@ def overview():
|
||||
# Zustand aus den laufenden Leases -- wie im Windows-DHCP.
|
||||
reservations = kea.annotate_reservations(reservations, leases)
|
||||
reserved_ips = {str(entry["ip"]) for entry in reservations}
|
||||
# Wozu es schon einen Client gibt, ist nicht mehr zu uebernehmen -- auch
|
||||
# wenn dessen Adresse (noch) keine Reservierung hat.
|
||||
bekannte_macs = {
|
||||
inventory.normalize_mac(str(zeile["mac"]))
|
||||
for zeile in Database.all(conn, "SELECT mac FROM devices WHERE deleted_at IS NULL")
|
||||
}
|
||||
|
||||
return render_template(
|
||||
"tesm/dhcp.html",
|
||||
@@ -89,6 +96,7 @@ def overview():
|
||||
manual_reservations=Database.all(conn, "SELECT * FROM dhcp_reservations ORDER BY ip"),
|
||||
leases=leases,
|
||||
reserved_ips=reserved_ips,
|
||||
bekannte_macs=bekannte_macs,
|
||||
networks=networks,
|
||||
default_gateway=kea.default_gateway(sysops) if sysops else "",
|
||||
service=kea.service_state(sysops) if sysops else {},
|
||||
@@ -105,8 +113,8 @@ def overview():
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
@bp.post("/einstellungen")
|
||||
@permission_required("dhcp.edit")
|
||||
@bp.post("/settings")
|
||||
@permission_required("dhcp_subnets.write")
|
||||
def save_settings():
|
||||
extension = core()
|
||||
conn = db()
|
||||
@@ -118,12 +126,12 @@ def save_settings():
|
||||
with transaction():
|
||||
changed = extension.settings.set_many(conn, values, actor=current_user.username)
|
||||
_audit("dhcp.settings_saved", detail=", ".join(changed), severity="notice")
|
||||
flash("Einstellungen gespeichert." if changed else "Keine Aenderungen.", "success" if changed else "info")
|
||||
flash("Einstellungen gespeichert." if changed else "Keine Änderungen.", "success" if changed else "info")
|
||||
return _write_and_redirect()
|
||||
|
||||
|
||||
@bp.post("/subnetz")
|
||||
@permission_required("dhcp.edit")
|
||||
@bp.post("/subnet")
|
||||
@permission_required("dhcp_subnets.create")
|
||||
def add_subnet():
|
||||
extension = core()
|
||||
conn = db()
|
||||
@@ -158,8 +166,8 @@ def add_subnet():
|
||||
return _write_and_redirect()
|
||||
|
||||
|
||||
@bp.post("/subnetz/<int:subnet_id>")
|
||||
@permission_required("dhcp.edit")
|
||||
@bp.post("/subnet/<int:subnet_id>")
|
||||
@permission_required("dhcp_subnets.write")
|
||||
def update_subnet(subnet_id: int):
|
||||
extension = core()
|
||||
conn = db()
|
||||
@@ -173,6 +181,9 @@ def update_subnet(subnet_id: int):
|
||||
flash(problem, "danger")
|
||||
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():
|
||||
conn.execute(
|
||||
"UPDATE dhcp_subnets SET interface=?, range_start=?, range_end=?, gateway=?, dns=?, "
|
||||
@@ -189,13 +200,32 @@ def update_subnet(subnet_id: int):
|
||||
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")
|
||||
return _write_and_redirect()
|
||||
|
||||
|
||||
@bp.post("/subnetz/<int:subnet_id>/loeschen")
|
||||
@permission_required("dhcp.edit")
|
||||
@bp.post("/subnet/<int:subnet_id>/delete")
|
||||
@permission_required("dhcp_subnets.delete")
|
||||
def delete_subnet(subnet_id: int):
|
||||
with transaction() as conn:
|
||||
conn.execute("DELETE FROM dhcp_subnets WHERE id=?", (subnet_id,))
|
||||
@@ -209,8 +239,8 @@ def delete_subnet(subnet_id: int):
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
@bp.post("/option/wert")
|
||||
@permission_required("dhcp.edit")
|
||||
@bp.post("/option/value")
|
||||
@permission_required("dhcp_options.write")
|
||||
def set_option_value():
|
||||
conn = db()
|
||||
option_id = int(request.form.get("option_id") or 0)
|
||||
@@ -248,8 +278,8 @@ def _device_option_values(conn) -> dict[int, list[dict[str, Any]]]: # type: ign
|
||||
return gruppen
|
||||
|
||||
|
||||
@bp.post("/option/geraet")
|
||||
@permission_required("dhcp.edit")
|
||||
@bp.post("/option/device")
|
||||
@permission_required("dhcp_options.write")
|
||||
def set_device_option_value():
|
||||
"""Setzt eine Option fuer ein einzelnes Geraet.
|
||||
|
||||
@@ -289,15 +319,15 @@ def set_device_option_value():
|
||||
value or "(geleert)",
|
||||
)
|
||||
flash(
|
||||
f"Option {option['name']} fuer {device['name']} "
|
||||
f"Option {option['name']} für {device['name']} "
|
||||
f"{'gespeichert' if value else 'entfernt'}.",
|
||||
"success",
|
||||
)
|
||||
return _write_and_redirect()
|
||||
|
||||
|
||||
@bp.post("/option/neu")
|
||||
@permission_required("dhcp.edit")
|
||||
@bp.post("/option/new")
|
||||
@permission_required("dhcp_options.write")
|
||||
def add_custom_option():
|
||||
conn = db()
|
||||
try:
|
||||
@@ -327,8 +357,8 @@ def add_custom_option():
|
||||
return redirect(url_for("dhcp.overview"))
|
||||
|
||||
|
||||
@bp.post("/option/<int:option_id>/loeschen")
|
||||
@permission_required("dhcp.edit")
|
||||
@bp.post("/option/<int:option_id>/delete")
|
||||
@permission_required("dhcp_options.write")
|
||||
def delete_custom_option(option_id: int):
|
||||
conn = db()
|
||||
option = Database.one(conn, "SELECT * FROM dhcp_options WHERE id=?", (option_id,))
|
||||
@@ -336,7 +366,7 @@ def delete_custom_option(option_id: int):
|
||||
abort(404)
|
||||
if int(option["is_standard"] or 0):
|
||||
flash(
|
||||
"Standardoptionen koennen nicht geloescht werden -- nur ihr Wert lasst sich leeren.",
|
||||
"Standardoptionen können nicht gelöscht werden – nur ihr Wert lässt sich leeren.",
|
||||
"danger",
|
||||
)
|
||||
return redirect(url_for("dhcp.overview"))
|
||||
@@ -352,14 +382,14 @@ def delete_custom_option(option_id: int):
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
@bp.post("/reservierung")
|
||||
@permission_required("dhcp.edit")
|
||||
@bp.post("/reservation")
|
||||
@permission_required("dhcp_reservations.create")
|
||||
def add_reservation():
|
||||
conn = db()
|
||||
mac = kea.normalize_mac(request.form.get("mac", ""))
|
||||
ip = (request.form.get("ip") or "").strip()
|
||||
if not mac:
|
||||
flash("Die MAC-Adresse ist ungueltig.", "danger")
|
||||
flash("Die MAC-Adresse ist ungültig.", "danger")
|
||||
return redirect(url_for("dhcp.overview"))
|
||||
|
||||
# Eine Reservierung ausserhalb aller eingerichteten Bereiche liefert Kea nie
|
||||
@@ -367,7 +397,7 @@ def add_reservation():
|
||||
# schlechteste der drei Moeglichkeiten -- deshalb hier ablehnen.
|
||||
if not Database.value(conn, "SELECT COUNT(*) FROM dhcp_subnets WHERE enabled=1"):
|
||||
flash(
|
||||
"Es ist noch kein Bereich eingerichtet. Legen Sie zuerst ein Subnetz an -- "
|
||||
"Es ist noch kein Bereich eingerichtet. Legen Sie zuerst ein Subnetz an – "
|
||||
"ohne eines wird keine Reservierung ausgeliefert.",
|
||||
"danger",
|
||||
)
|
||||
@@ -380,7 +410,7 @@ def add_reservation():
|
||||
# keine Reservierung mehr moeglich. Stattdessen benennen, was ungeprueft
|
||||
# bleibt.
|
||||
flash(
|
||||
"Der Bereich konnte nicht geprueft werden -- ohne Systemhelfer ist das Netz "
|
||||
"Der Bereich konnte nicht geprüft werden – ohne Systemhelfer ist das Netz "
|
||||
"dieses Servers nicht feststellbar.",
|
||||
"warning",
|
||||
)
|
||||
@@ -390,11 +420,12 @@ def add_reservation():
|
||||
bereiche = ", ".join(str(netz) for netz in netze)
|
||||
flash(
|
||||
f"{ip or 'Diese Adresse'} liegt in keinem eingerichteten Bereich "
|
||||
f"({bereiche}). Die Reservierung waere ohne Wirkung.",
|
||||
f"({bereiche}). Die Reservierung wäre ohne Wirkung.",
|
||||
"danger",
|
||||
)
|
||||
return redirect(url_for("dhcp.overview"))
|
||||
|
||||
vorher = Database.one(conn, "SELECT * FROM dhcp_reservations WHERE mac=?", (mac,))
|
||||
with transaction():
|
||||
conn.execute(
|
||||
"INSERT INTO dhcp_reservations (mac, ip, hostname, comment, created_at) "
|
||||
@@ -407,13 +438,109 @@ def add_reservation():
|
||||
(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")
|
||||
return _write_and_redirect()
|
||||
|
||||
|
||||
@bp.post("/reservierung/<int:reservation_id>/loeschen")
|
||||
@permission_required("dhcp.edit")
|
||||
@bp.post("/lease/adopt")
|
||||
# Zwei Rechte, mit Absicht: die Uebernahme ist eine eigene Funktion, das
|
||||
# Ergebnis aber ein Client. Wer keine Clients anlegen darf, legt auch hier
|
||||
# keine an -- sonst waere diese Schaltflaeche eine Hintertuer an der
|
||||
# Geraeteverwaltung vorbei.
|
||||
@permission_required("dhcp_lease_adopt.execute", "devices.create")
|
||||
def adopt_lease():
|
||||
"""Macht aus einer dynamisch vergebenen Adresse einen ueberwachten Client.
|
||||
|
||||
Die Angaben kommen **nicht** aus dem Formular, sondern aus der Lease-Datei:
|
||||
geschickt wird nur die MAC, alles Weitere liest TESM frisch nach. Damit
|
||||
heisst "Uebernahme" auch Uebernahme -- und eine Lease, die zwischen
|
||||
Seitenaufbau und Klick abgelaufen oder umgezogen ist, faellt auf, statt
|
||||
stillschweigend einen falschen Datensatz anzulegen.
|
||||
"""
|
||||
extension = core()
|
||||
conn = db()
|
||||
sysops = extension.sysops
|
||||
gesucht = inventory.normalize_mac(request.form.get("mac") or "")
|
||||
leases = kea.read_leases(sysops) if sysops else []
|
||||
lease = next((eintrag for eintrag in leases if eintrag.mac == gesucht), None)
|
||||
if lease is None:
|
||||
flash(
|
||||
"Zu dieser MAC liegt keine Lease mehr vor. Die Liste war nicht mehr aktuell.",
|
||||
"warning",
|
||||
)
|
||||
return redirect(url_for("dhcp.overview"))
|
||||
|
||||
# Nur dynamische. Eine Adresse, die schon reserviert ist, gehoert bereits
|
||||
# zu einem gepflegten Datensatz -- da gibt es nichts zu uebernehmen.
|
||||
networks = kea.detect_networks(sysops) if sysops else []
|
||||
reserviert = {
|
||||
str(eintrag["ip"])
|
||||
for eintrag in kea.collect_reservations(
|
||||
conn, only_networks=kea.configured_networks(conn, networks)
|
||||
)
|
||||
}
|
||||
if lease.address in reserviert:
|
||||
flash(
|
||||
f"{lease.address} ist bereits reserviert – diese Lease ist nicht dynamisch.",
|
||||
"warning",
|
||||
)
|
||||
return redirect(url_for("dhcp.overview"))
|
||||
|
||||
# Der Hostname aus der Lease ist der Name, den das Geraet selbst nennt.
|
||||
# Fehlt er, steht die Adresse da: ein Name wie "Gerät 7" sagt weniger.
|
||||
name = (lease.hostname or "").strip() or f"Client {lease.address}"
|
||||
try:
|
||||
with transaction():
|
||||
device_id = inventory.create_device(
|
||||
conn,
|
||||
name=name,
|
||||
mac=lease.mac,
|
||||
ip=lease.address,
|
||||
switch_id=None,
|
||||
port="",
|
||||
credential_id=None,
|
||||
ssh_port=None,
|
||||
# Ohne Switch und Port gibt es keinen PoE-Neustart. Mit
|
||||
# eingeschaltetem Automatismus schriebe die Ueberwachung bei
|
||||
# jedem Ausfall einen Fehlschlag ins Protokoll, den niemand
|
||||
# abstellen kann. Einschalten kann man ihn, sobald der Port
|
||||
# eingetragen ist.
|
||||
auto_restart=False,
|
||||
actor=current_user.username,
|
||||
)
|
||||
except inventory.ValidationError as exc:
|
||||
flash(str(exc), "danger")
|
||||
return redirect(url_for("dhcp.overview"))
|
||||
|
||||
_audit(
|
||||
"device.created",
|
||||
name,
|
||||
f"aus dynamischer Lease {lease.address} ({lease.mac}) übernommen",
|
||||
severity="notice",
|
||||
)
|
||||
flash(
|
||||
f"{name} wurde als Client übernommen. Switch, Port und Zugangsdaten fehlen noch – "
|
||||
"ohne sie gibt es keinen PoE-Neustart und keine Wartung. Die feste Adresse entsteht "
|
||||
"beim nächsten Schreiben der DHCP-Konfiguration.",
|
||||
"success",
|
||||
)
|
||||
return redirect(url_for("devices.detail", device_id=device_id))
|
||||
|
||||
|
||||
@bp.post("/reservation/<int:reservation_id>/delete")
|
||||
@permission_required("dhcp_reservations.delete")
|
||||
def delete_reservation(reservation_id: int):
|
||||
with transaction() as conn:
|
||||
conn.execute("DELETE FROM dhcp_reservations WHERE id=?", (reservation_id,))
|
||||
@@ -437,14 +564,14 @@ def _write_config() -> dict[str, Any]:
|
||||
sysops = extension.sysops
|
||||
conn = db()
|
||||
if sysops is None or not sysops.available:
|
||||
return {"ok": False, "message": "Systemhelfer nicht verfuegbar."}
|
||||
return {"ok": False, "message": "Systemhelfer nicht verfügbar."}
|
||||
|
||||
networks = kea.detect_networks(sysops)
|
||||
config, warnings = kea.render_config(conn, networks=networks, settings=_settings_values())
|
||||
if not config["Dhcp4"]["subnet4"]:
|
||||
return {
|
||||
"ok": False,
|
||||
"message": "Es ist kein gueltiges Subnetz konfiguriert -- die Datei wurde nicht geschrieben.",
|
||||
"message": "Es ist kein gültiges Subnetz konfiguriert – die Datei wurde nicht geschrieben.",
|
||||
"warnings": warnings,
|
||||
}
|
||||
result = sysops.write_kea_config(kea.config_to_text(config))
|
||||
@@ -480,14 +607,14 @@ def _write_and_redirect():
|
||||
return redirect(url_for("dhcp.overview"))
|
||||
|
||||
|
||||
@bp.post("/schreiben")
|
||||
@permission_required("dhcp.edit")
|
||||
@bp.post("/write")
|
||||
@permission_required("dhcp_config.execute")
|
||||
def write_config():
|
||||
return _write_and_redirect()
|
||||
|
||||
|
||||
@bp.post("/dienst/<action>")
|
||||
@permission_required("dhcp.execute")
|
||||
@bp.post("/service/<action>")
|
||||
@permission_required("dhcp_service.execute")
|
||||
def service_action(action: str):
|
||||
allowed = {"install", "start", "stop"}
|
||||
if action not in allowed:
|
||||
@@ -495,7 +622,7 @@ def service_action(action: str):
|
||||
extension = core()
|
||||
sysops = extension.sysops
|
||||
if sysops is None or not sysops.available:
|
||||
flash("Systemaktionen sind in dieser Umgebung nicht verfuegbar.", "warning")
|
||||
flash("Systemaktionen sind in dieser Umgebung nicht verfügbar.", "warning")
|
||||
return redirect(url_for("dhcp.overview"))
|
||||
|
||||
if action == "install":
|
||||
|
||||
@@ -2,7 +2,10 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import io
|
||||
import json
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from flask import (
|
||||
Blueprint,
|
||||
@@ -18,11 +21,12 @@ from flask import (
|
||||
from tesm_core import audit
|
||||
from tesm_core.auth.models import current_user, permission_required
|
||||
from tesm_core.auth.service import client_ip
|
||||
from tesm_core import uploads
|
||||
from tesm_core.extension import core, db, transaction
|
||||
|
||||
from ..services import fileshare as service
|
||||
|
||||
bp = Blueprint("fileshare", __name__, url_prefix="/dateifreigaben")
|
||||
bp = Blueprint("fileshare", __name__, url_prefix="/fileshares")
|
||||
|
||||
|
||||
@bp.before_request
|
||||
@@ -46,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:
|
||||
mounts = service.active_mounts(db(), current_user.id)
|
||||
conn = db()
|
||||
mounts = service.doppelte_zusammenfassen(
|
||||
conn, service.active_mounts(conn, current_user.id), service.aktuelle_sitzung()
|
||||
)
|
||||
for mount in mounts:
|
||||
if mount.token == token:
|
||||
return mount
|
||||
@@ -55,12 +84,14 @@ def _mount_or_404(token: str) -> service.Mount:
|
||||
|
||||
|
||||
@bp.get("/")
|
||||
@permission_required("fileshare.view")
|
||||
@permission_required("fileshare.read")
|
||||
def browse():
|
||||
conn = db()
|
||||
mounts = service.active_mounts(conn, current_user.id)
|
||||
token = request.args.get("freigabe") or (mounts[0].token if mounts else "")
|
||||
relative = request.args.get("pfad") or ""
|
||||
mounts = service.doppelte_zusammenfassen(
|
||||
conn, service.active_mounts(conn, current_user.id), service.aktuelle_sitzung()
|
||||
)
|
||||
token = request.args.get("share") or (mounts[0].token if mounts else "")
|
||||
relative = request.args.get("path") or ""
|
||||
listing = None
|
||||
error = ""
|
||||
if token:
|
||||
@@ -75,15 +106,27 @@ def browse():
|
||||
token=token,
|
||||
listing=listing,
|
||||
error=error,
|
||||
max_upload_mb=int(core().settings.get(conn, "fileshare_upload_max_mb") or 512),
|
||||
# Dieselbe Zahl, die auch die Anfrage begrenzt und in nginx steht --
|
||||
# vorher versprach diese Seite 512 MB, waehrend bei 64 MB abgebrochen
|
||||
# wurde.
|
||||
max_upload_mb=uploads.limit_mb(conn),
|
||||
# Vorschlagsliste fuer "Ausgefuellt speichern": wohin darf es gehen?
|
||||
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")
|
||||
@permission_required("fileshare.view")
|
||||
@bp.get("/<token>/download")
|
||||
# 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):
|
||||
mount = _mount_or_404(token)
|
||||
relative = request.args.get("pfad") or ""
|
||||
relative = request.args.get("path") or ""
|
||||
try:
|
||||
path = service.resolve_path(mount, relative)
|
||||
except service.FileshareError as exc:
|
||||
@@ -95,15 +138,20 @@ def download(token: str):
|
||||
path,
|
||||
as_attachment=True,
|
||||
download_name=path.name,
|
||||
mimetype=service.download_mime(path.name),
|
||||
# Bewusst der neutrale Typ und nicht der erkannte: iOS Safari
|
||||
# entscheidet nach dem MIME-Typ und zeigt eine "application/pdf" im
|
||||
# Browser an, auch wenn sie als Anhang ausgeliefert wird. Wer hier
|
||||
# klickt, will die Datei aber ablegen -- angesehen wird sie in der
|
||||
# Vorschau. Fuer alle anderen Browser aendert sich damit nichts.
|
||||
mimetype="application/octet-stream",
|
||||
)
|
||||
|
||||
|
||||
@bp.get("/<token>/vorschau")
|
||||
@permission_required("fileshare.view")
|
||||
@bp.get("/<token>/preview")
|
||||
@permission_required("fileshare.read")
|
||||
def preview(token: str):
|
||||
mount = _mount_or_404(token)
|
||||
relative = request.args.get("pfad") or ""
|
||||
relative = request.args.get("path") or ""
|
||||
try:
|
||||
path = service.resolve_path(mount, relative)
|
||||
mimetype = service.preview_mime(path.name)
|
||||
@@ -138,17 +186,17 @@ def preview(token: str):
|
||||
)
|
||||
|
||||
|
||||
@bp.post("/<token>/hochladen")
|
||||
@bp.post("/<token>/upload")
|
||||
@permission_required("fileshare.create")
|
||||
def upload(token: str):
|
||||
mount = _mount_or_404(token)
|
||||
relative = request.form.get("pfad") or ""
|
||||
relative = request.form.get("path") or ""
|
||||
files = request.files.getlist("files")
|
||||
if not files:
|
||||
flash("Keine Datei ausgewaehlt.", "danger")
|
||||
return redirect(url_for("fileshare.browse", freigabe=token, pfad=relative))
|
||||
flash("Keine Datei ausgewählt.", "danger")
|
||||
return redirect(url_for("fileshare.browse", share=token, path=relative))
|
||||
|
||||
limit = int(core().settings.get(db(), "fileshare_upload_max_mb") or 512) * 1024 * 1024
|
||||
limit = uploads.limit_bytes(db())
|
||||
stored = 0
|
||||
try:
|
||||
target_dir = service.resolve_path(mount, relative)
|
||||
@@ -158,58 +206,239 @@ def upload(token: str):
|
||||
name = service.safe_filename(upload_file.filename)
|
||||
data = upload_file.stream.read(limit + 1)
|
||||
if len(data) > limit:
|
||||
flash(f"{name}: zu gross (Grenze {limit // 1024 // 1024} MB).", "danger")
|
||||
flash(f"{name}: zu groß (Grenze {limit // 1024 // 1024} MB).", "danger")
|
||||
continue
|
||||
(target_dir / name).write_bytes(data)
|
||||
stored += 1
|
||||
except service.FileshareError as exc:
|
||||
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:
|
||||
_audit("fileshare.uploaded", f"{mount.label}:{relative}", f"{stored} Datei(en)")
|
||||
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>/ordner")
|
||||
@bp.get("/<token>/signature")
|
||||
@permission_required("fileshare.read")
|
||||
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.read")
|
||||
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):
|
||||
"""Nimmt ein in der Vorschau ausgefuelltes PDF und legt es daneben ab.
|
||||
|
||||
Als **neue** Datei: auf einer Freigabe liegt in der Regel die leere
|
||||
Vorlage, und die soll leer bleiben. Wer das Original ersetzen will, kann
|
||||
die alte Datei danach loeschen -- das ist eine bewusste Handlung.
|
||||
"""
|
||||
mount = _mount_or_404(token)
|
||||
# Das Ziel darf eine andere Freigabe sein: die Vorlage liegt oft in einer
|
||||
# Vorlagenfreigabe, die ausgefuellte Fassung gehoert in die Ablage des
|
||||
# Bereichs. Geprueft wird es wie jede andere Einbindung -- nur eine, die
|
||||
# diesem Benutzer gerade gehoert, kommt in Frage.
|
||||
ziel_token = (request.form.get("ziel") or "").strip()
|
||||
ziel_mount = _mount_or_404(ziel_token) if ziel_token else mount
|
||||
relative = request.form.get("path") or ""
|
||||
upload_file = request.files.get("datei")
|
||||
if upload_file is None or not upload_file.filename:
|
||||
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())
|
||||
daten = upload_file.stream.read(grenze + 1)
|
||||
if len(daten) > grenze:
|
||||
return {"error": f"Zu groß (Grenze {grenze // 1024 // 1024} MB)."}, 413
|
||||
if not daten.startswith(b"%PDF-"):
|
||||
# Diese Route nimmt nur, was pdf.js hier ablegt. Ein anderer Inhalt
|
||||
# waere ein Umweg, um beliebige Dateien in die Freigabe zu bekommen.
|
||||
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.write"):
|
||||
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.
|
||||
ordner = (request.form.get("ordner") or "").strip().strip("/")
|
||||
wunschname = (request.form.get("name") or "").strip()
|
||||
ersetzen = request.form.get("ersetzen") == "1"
|
||||
|
||||
try:
|
||||
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,
|
||||
# so wie es im Feld steht. Ohne Namen (Aufruf ohne Oberflaeche) bleibt
|
||||
# es beim Ordner des Originals.
|
||||
if wunschname:
|
||||
zielordner = service.resolve_path(ziel_mount, ordner)
|
||||
else:
|
||||
zielordner = service.resolve_path(mount, ordner) if ordner else original.parent
|
||||
if not zielordner.is_dir():
|
||||
return {
|
||||
"error": f"Den Ordner '{ordner}' gibt es in der Freigabe "
|
||||
f"'{ziel_mount.label}' nicht."
|
||||
}, 400
|
||||
if wunschname:
|
||||
# Eingegeben wird der Name ohne Endung; die kommt von hier. Wer
|
||||
# sie mittippt, bekommt sie nicht doppelt -- und wer eine falsche
|
||||
# tippt, bekommt trotzdem eine Datei, die sich oeffnen laesst.
|
||||
name = service.safe_filename(wunschname)
|
||||
if not name.lower().endswith(".pdf"):
|
||||
name += ".pdf"
|
||||
ziel = zielordner / name
|
||||
if ziel.exists() and not ersetzen:
|
||||
return {
|
||||
"error": f"In '{ziel_mount.label}' gibt es '{name}' schon. Anderen "
|
||||
"Namen wählen oder 'Vorhandene ersetzen' ankreuzen."
|
||||
}, 409
|
||||
else:
|
||||
# Ohne Angabe wie bisher: neben das Original, ohne etwas zu ueberschreiben.
|
||||
ziel = service.free_name(original.parent, original.stem, "-ausgefuellt", ".pdf")
|
||||
ziel.write_bytes(daten)
|
||||
except service.FileshareError as exc:
|
||||
return {"error": str(exc)}, 400
|
||||
|
||||
wohin = ziel.relative_to(ziel_mount.path.resolve()).as_posix()
|
||||
pruefsumme = hashlib.sha256(daten).hexdigest()
|
||||
unterschriften = _unterschriften(request.form.get("unterschriften") or "")
|
||||
# Unterschreiben ist kein Aendern: am Ende steht eine Fassung, die niemand
|
||||
# mehr bearbeiten kann, mit Namen im Dokument und Pruefsumme im Protokoll.
|
||||
# Wer Dateien ablegen darf, darf das nicht automatisch.
|
||||
if unterschriften and not current_user.has("fileshare_signature.execute"):
|
||||
return {"error": "Zum Unterschreiben fehlt die Berechtigung."}, 403
|
||||
|
||||
# Zwei Angaben, die man nicht verwechseln darf, und deshalb stehen beide
|
||||
# da: der Name **in** der Unterschrift ist eine Angabe des
|
||||
# Unterschreibenden -- wie auf Papier. Wer die Datei abgelegt hat, steht
|
||||
# ohnehin als Handelnder in der Zeile und ist belegt.
|
||||
# Zwei Zeilen: oben, was ein Mensch liest; unten die Pruefsumme, die
|
||||
# niemand liest, aber jeder vergleichen koennen muss. In einer einzigen
|
||||
# Zeile drueckte der Hash den Rest aus der Spalte.
|
||||
kopf = f"{'Signiert' if unterschriften else 'Gespeichert'}: {wohin}"
|
||||
if unterschriften:
|
||||
kopf += f" von: {', '.join(unterschriften)}"
|
||||
beschreibung = f"{kopf}{chr(10)}sha256={pruefsumme}"
|
||||
_audit(
|
||||
"fileshare.form_signed" if unterschriften else "fileshare.form_saved",
|
||||
f"{mount.label}:{relative}",
|
||||
beschreibung,
|
||||
severity="notice",
|
||||
)
|
||||
if unterschriften:
|
||||
# Zusaetzlich ins Signaturverzeichnis: das Protokoll wird irgendwann
|
||||
# archiviert, die Frage "ist diese Datei noch die unterschriebene?"
|
||||
# stellt sich aber noch Jahre spaeter. Und mit fsync -- die Datei liegt
|
||||
# schon auf der Freigabe; ein Stromausfall soll nicht den Nachweis
|
||||
# dazu verschlucken.
|
||||
verbindung = db()
|
||||
with core().database.dauerhaft(verbindung), transaction():
|
||||
service.signatur_merken(
|
||||
verbindung,
|
||||
sha256=pruefsumme,
|
||||
label=ziel_mount.label,
|
||||
pfad=wohin,
|
||||
actor=current_user.username,
|
||||
namen=unterschriften,
|
||||
)
|
||||
# Zweites Exemplar ausserhalb der Datenbank.
|
||||
service.signatur_journalisieren(
|
||||
sha256=pruefsumme,
|
||||
label=ziel_mount.label,
|
||||
pfad=wohin,
|
||||
actor=current_user.username,
|
||||
namen=unterschriften,
|
||||
)
|
||||
return {"message": f"Gespeichert als {wohin}.", "path": wohin, "sha256": pruefsumme}
|
||||
|
||||
|
||||
@bp.post("/<token>/folder")
|
||||
@permission_required("fileshare.create")
|
||||
def make_directory(token: str):
|
||||
mount = _mount_or_404(token)
|
||||
relative = request.form.get("pfad") or ""
|
||||
relative = request.form.get("path") or ""
|
||||
try:
|
||||
service.make_directory(mount, relative, request.form.get("name", ""))
|
||||
except (service.FileshareError, OSError) as exc:
|
||||
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')}")
|
||||
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")
|
||||
@permission_required("fileshare.edit")
|
||||
@bp.post("/<token>/rename")
|
||||
@permission_required("fileshare.write")
|
||||
def rename(token: str):
|
||||
mount = _mount_or_404(token)
|
||||
relative = request.form.get("pfad") or ""
|
||||
relative = request.form.get("path") or ""
|
||||
parent = request.form.get("parent") or ""
|
||||
try:
|
||||
service.rename_entry(mount, relative, request.form.get("name", ""))
|
||||
except (service.FileshareError, OSError) as exc:
|
||||
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}")
|
||||
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")
|
||||
def delete(token: str):
|
||||
mount = _mount_or_404(token)
|
||||
parent = request.form.get("parent") or ""
|
||||
removed = 0
|
||||
for relative in request.form.getlist("pfade"):
|
||||
for relative in request.form.getlist("paths"):
|
||||
try:
|
||||
kind = service.delete_entry(mount, relative)
|
||||
_audit("fileshare.deleted", f"{mount.label}:{relative}", kind, severity="warning")
|
||||
@@ -217,18 +446,18 @@ def delete(token: str):
|
||||
except (service.FileshareError, OSError) as exc:
|
||||
flash(str(exc), "danger")
|
||||
if removed:
|
||||
flash(f"{removed} Eintrag/Eintraege geloescht.", "success")
|
||||
return redirect(url_for("fileshare.browse", freigabe=token, pfad=parent))
|
||||
flash(f"{removed} Eintrag/Einträge gelöscht.", "success")
|
||||
return redirect(url_for("fileshare.browse", share=token, path=parent))
|
||||
|
||||
|
||||
@bp.post("/<token>/freigeben")
|
||||
@permission_required("fileshare.view")
|
||||
@bp.post("/<token>/release")
|
||||
@permission_required("fileshare.read")
|
||||
def release(token: str):
|
||||
mount = _mount_or_404(token)
|
||||
with transaction() as conn:
|
||||
service.release_mount(conn, mount.id)
|
||||
_audit("fileshare.released", mount.label)
|
||||
flash("Einbindung geloest.", "success")
|
||||
flash("Einbindung gelöst.", "success")
|
||||
return redirect(url_for("fileshare.browse"))
|
||||
|
||||
|
||||
@@ -237,8 +466,8 @@ def release(token: str):
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
@bp.get("/zuordnungen")
|
||||
@permission_required("fileshare.edit")
|
||||
@bp.get("/mappings")
|
||||
@permission_required("fileshare_mappings.read")
|
||||
def mappings():
|
||||
from tesm_core.auth import directory
|
||||
|
||||
@@ -251,8 +480,8 @@ def mappings():
|
||||
)
|
||||
|
||||
|
||||
@bp.post("/zuordnungen")
|
||||
@permission_required("fileshare.edit")
|
||||
@bp.post("/mappings")
|
||||
@permission_required("fileshare_mappings.create")
|
||||
def mapping_add():
|
||||
try:
|
||||
with transaction() as conn:
|
||||
@@ -263,18 +492,33 @@ def mapping_add():
|
||||
label=request.form.get("label", ""),
|
||||
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")
|
||||
except service.FileshareError as exc:
|
||||
flash(str(exc), "danger")
|
||||
return redirect(url_for("fileshare.mappings"))
|
||||
|
||||
|
||||
@bp.post("/zuordnungen/<int:mapping_id>")
|
||||
@permission_required("fileshare.edit")
|
||||
@bp.post("/mappings/<int:mapping_id>")
|
||||
@permission_required("fileshare_mappings.write")
|
||||
def mapping_update(mapping_id: int):
|
||||
try:
|
||||
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(
|
||||
conn,
|
||||
mapping_id,
|
||||
@@ -283,18 +527,32 @@ def mapping_update(mapping_id: int):
|
||||
label=request.form.get("label", ""),
|
||||
unc=request.form.get("unc", ""),
|
||||
)
|
||||
_audit("fileshare.mapping_updated", request.form.get("label", ""), severity="notice")
|
||||
flash("Zuordnung geaendert.", "success")
|
||||
nachher = service.get_mapping(conn, mapping_id)
|
||||
_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:
|
||||
flash(str(exc), "danger")
|
||||
return redirect(url_for("fileshare.mappings"))
|
||||
|
||||
|
||||
@bp.post("/zuordnungen/<int:mapping_id>/loeschen")
|
||||
@permission_required("fileshare.edit")
|
||||
@bp.post("/mappings/<int:mapping_id>/delete")
|
||||
@permission_required("fileshare_mappings.delete")
|
||||
def mapping_delete(mapping_id: int):
|
||||
with transaction() as conn:
|
||||
entfernt = service.get_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")
|
||||
return redirect(url_for("fileshare.mappings"))
|
||||
@@ -31,13 +31,17 @@ from tesm_core.extension import core, db, transaction
|
||||
|
||||
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_SWITCH = "switch"
|
||||
|
||||
#: Welches Recht welche Art von Ziel freigibt.
|
||||
EDIT_RIGHT = {KIND_DEVICE: "devices.edit", KIND_SWITCH: "switches.edit"}
|
||||
#: Freigeben ist eine eigene Zeile: es war das Recht fuer Stammdaten
|
||||
#: ("Gerät ändern"), obwohl es der Vorgang ist, mit dem man bestaetigt, dass
|
||||
#: ein Geraet wirklich das ist, fuer das es sich ausgibt. Die Art des Ziels
|
||||
#: entscheidet nicht mehr mit -- Schluessel ist Schluessel.
|
||||
FREIGABE_RECHT = "hostkeys.execute"
|
||||
EDIT_RIGHT = {KIND_DEVICE: FREIGABE_RECHT, KIND_SWITCH: FREIGABE_RECHT}
|
||||
|
||||
|
||||
def _audit(action: str, target: str, detail: str = "", severity: str = "info") -> None:
|
||||
@@ -112,7 +116,7 @@ def _orphans(conn: Any, targets: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
||||
|
||||
|
||||
@bp.get("/")
|
||||
@permission_required("devices.view", "switches.view", mode="any")
|
||||
@permission_required("hostkeys.read")
|
||||
def overview():
|
||||
conn = db()
|
||||
targets = _targets(conn)
|
||||
@@ -142,8 +146,8 @@ def _require(kind: str) -> None:
|
||||
abort(403)
|
||||
|
||||
|
||||
@bp.post("/<kind>/<int:target_id>/pruefen")
|
||||
@permission_required("devices.view", "switches.view", mode="any")
|
||||
@bp.post("/<kind>/<int:target_id>/probe")
|
||||
@permission_required("hostkeys.read")
|
||||
def probe(kind: str, target_id: int):
|
||||
conn = db()
|
||||
_require(kind)
|
||||
@@ -169,8 +173,8 @@ def probe(kind: str, target_id: int):
|
||||
)
|
||||
|
||||
|
||||
@bp.post("/<kind>/<int:target_id>/freigeben")
|
||||
@permission_required("devices.view", "switches.view", mode="any")
|
||||
@bp.post("/<kind>/<int:target_id>/trust")
|
||||
@permission_required("hostkeys.read")
|
||||
@reauth_required
|
||||
def trust(kind: str, target_id: int):
|
||||
conn = db()
|
||||
@@ -181,7 +185,7 @@ def trust(kind: str, target_id: int):
|
||||
key_type = (request.form.get("key_type") or "").strip()
|
||||
fingerprint = (request.form.get("fingerprint") or "").strip()
|
||||
if not key_type or not ssh.valid_fingerprint(fingerprint):
|
||||
flash("Es wurde kein gueltiger Fingerprint uebergeben.", "danger")
|
||||
flash("Es wurde kein gültiger Fingerprint übergeben.", "danger")
|
||||
return redirect(url_for("hostkeys.overview"))
|
||||
|
||||
with transaction():
|
||||
@@ -199,12 +203,12 @@ def trust(kind: str, target_id: int):
|
||||
f"{key_type} {fingerprint}",
|
||||
severity="notice",
|
||||
)
|
||||
flash(f"Host-Schluessel von {target['label']} freigegeben.", "success")
|
||||
flash(f"Host-Schlüssel von {target['label']} freigegeben.", "success")
|
||||
return redirect(url_for("hostkeys.overview"))
|
||||
|
||||
|
||||
@bp.post("/<kind>/<int:target_id>/verwerfen")
|
||||
@permission_required("devices.view", "switches.view", mode="any")
|
||||
@bp.post("/<kind>/<int:target_id>/forget")
|
||||
@permission_required("hostkeys.read")
|
||||
def forget(kind: str, target_id: int):
|
||||
conn = db()
|
||||
_require(kind)
|
||||
@@ -218,12 +222,12 @@ def forget(kind: str, target_id: int):
|
||||
target["label"],
|
||||
severity="warning",
|
||||
)
|
||||
flash(f"Gespeicherte Schluessel von {target['label']} entfernt.", "info")
|
||||
flash(f"Gespeicherte Schlüssel von {target['label']} entfernt.", "info")
|
||||
return redirect(url_for("hostkeys.overview"))
|
||||
|
||||
|
||||
@bp.post("/verwaist/verwerfen")
|
||||
@permission_required("devices.edit", "switches.edit", mode="any")
|
||||
@bp.post("/orphaned/forget")
|
||||
@permission_required("hostkeys.execute")
|
||||
def forget_orphan():
|
||||
"""Entfernt eine Freigabe, zu der es kein Ziel mehr gibt."""
|
||||
host = (request.form.get("host") or "").strip()
|
||||
@@ -232,18 +236,18 @@ def forget_orphan():
|
||||
except ValueError:
|
||||
port = 0
|
||||
if not host or not 1 <= port <= 65535:
|
||||
flash("Ungueltiges Ziel.", "danger")
|
||||
flash("Ungültiges Ziel.", "danger")
|
||||
return redirect(url_for("hostkeys.overview"))
|
||||
|
||||
conn = db()
|
||||
if (host, port) in {(row["host"], row["port"]) for row in _targets(conn)}:
|
||||
# Ein Ziel, das es noch gibt, wird ueber seine eigene Zeile verworfen --
|
||||
# sonst umginge dieser Weg die Rechtepruefung je Objektart.
|
||||
flash("Zu diesem Eintrag gibt es ein Ziel -- bitte dort verwerfen.", "warning")
|
||||
flash("Zu diesem Eintrag gibt es ein Ziel – bitte dort verwerfen.", "warning")
|
||||
return redirect(url_for("hostkeys.overview"))
|
||||
|
||||
with transaction():
|
||||
ssh.forget_key(conn, host, port)
|
||||
_audit("host_key.orphan_forgotten", f"{host}:{port}", severity="warning")
|
||||
flash(f"Verwaiste Freigabe fuer {host}:{port} entfernt.", "info")
|
||||
flash(f"Verwaiste Freigabe für {host}:{port} entfernt.", "info")
|
||||
return redirect(url_for("hostkeys.overview"))
|
||||
@@ -23,7 +23,7 @@ from tesm_core.licensing_client import LICENSED_FEATURES, LicenseError
|
||||
|
||||
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
|
||||
|
||||
@@ -43,7 +43,7 @@ def _audit(action: str, target: str = "", detail: str = "", severity: str = "inf
|
||||
|
||||
|
||||
@bp.get("/")
|
||||
@permission_required("settings_license.view")
|
||||
@permission_required("settings_license.read")
|
||||
def overview():
|
||||
extension = core()
|
||||
conn = db()
|
||||
@@ -69,8 +69,8 @@ def overview():
|
||||
)
|
||||
|
||||
|
||||
@bp.post("/einspielen")
|
||||
@permission_required("settings_license.edit")
|
||||
@bp.post("/install")
|
||||
@permission_required("settings_license.write")
|
||||
def upload():
|
||||
extension = core()
|
||||
manager = extension.license
|
||||
@@ -80,11 +80,11 @@ def upload():
|
||||
|
||||
upload_file = request.files.get("license_file")
|
||||
if upload_file is None or not upload_file.filename:
|
||||
flash("Keine Datei ausgewaehlt.", "danger")
|
||||
flash("Keine Datei ausgewählt.", "danger")
|
||||
return redirect(url_for("license.overview"))
|
||||
raw = upload_file.stream.read(MAX_UPLOAD_BYTES + 1)
|
||||
if len(raw) > MAX_UPLOAD_BYTES:
|
||||
flash("Die Datei ist zu gross fuer eine Lizenzdatei.", "danger")
|
||||
flash("Die Datei ist zu groß für eine Lizenzdatei.", "danger")
|
||||
return redirect(url_for("license.overview"))
|
||||
|
||||
allow_new_issuer = request.form.get("allow_new_issuer") == "1" and current_user.is_admin
|
||||
@@ -104,20 +104,20 @@ def upload():
|
||||
if state.status and state.status.role != "customer":
|
||||
flash(
|
||||
f"Achtung: diese Lizenz hat die Rolle {state.status.role!r}. "
|
||||
"In einer TESM-Kundeninstanz ist das ungewoehnlich.",
|
||||
"In einer TESM-Kundeninstanz ist das ungewöhnlich.",
|
||||
"warning",
|
||||
)
|
||||
return redirect(url_for("license.overview"))
|
||||
|
||||
|
||||
@bp.post("/aktivieren")
|
||||
@permission_required("settings_license.edit")
|
||||
@bp.post("/activate")
|
||||
@permission_required("settings_license.write")
|
||||
def activate():
|
||||
return _handshake("activate")
|
||||
|
||||
|
||||
@bp.post("/deaktivieren")
|
||||
@permission_required("settings_license.edit")
|
||||
@bp.post("/deactivate")
|
||||
@permission_required("settings_license.write")
|
||||
@reauth_required
|
||||
def deactivate():
|
||||
"""Gibt die Lizenz frei, damit sie auf einer anderen Maschine genutzt werden kann.
|
||||
@@ -129,7 +129,7 @@ def deactivate():
|
||||
|
||||
|
||||
@bp.post("/heartbeat")
|
||||
@permission_required("settings_license.edit")
|
||||
@permission_required("settings_license.write")
|
||||
def heartbeat():
|
||||
return _handshake("heartbeat")
|
||||
|
||||
@@ -158,8 +158,8 @@ def _handshake(action: str):
|
||||
return redirect(url_for("license.overview"))
|
||||
|
||||
|
||||
@bp.post("/offline-antwort")
|
||||
@permission_required("settings_license.edit")
|
||||
@bp.post("/offline-response")
|
||||
@permission_required("settings_license.write")
|
||||
def offline_response():
|
||||
extension = core()
|
||||
manager = extension.license
|
||||
@@ -177,8 +177,8 @@ def offline_response():
|
||||
return redirect(url_for("license.overview"))
|
||||
|
||||
|
||||
@bp.post("/serverzertifikat")
|
||||
@permission_required("settings_license.edit")
|
||||
@bp.post("/server-certificate")
|
||||
@permission_required("settings_license.write")
|
||||
def server_certificate():
|
||||
"""Hinterlegt das Zertifikat des Lizenzservers.
|
||||
|
||||
@@ -214,15 +214,15 @@ def server_certificate():
|
||||
severity="notice",
|
||||
)
|
||||
flash(
|
||||
"Zertifikat hinterlegt. Der naechste Versuch laeuft wieder ueber den Online-Weg."
|
||||
"Zertifikat hinterlegt. Der nächste Versuch läuft wieder über den Online-Weg."
|
||||
if pem
|
||||
else "Zertifikat entfernt -- es gelten wieder die Systemanker.",
|
||||
else "Zertifikat entfernt – es gelten wieder die Systemanker.",
|
||||
"success",
|
||||
)
|
||||
return redirect(url_for("license.overview"))
|
||||
|
||||
|
||||
@bp.post("/aussteller/<key_id>/entfernen")
|
||||
@bp.post("/issuer/<key_id>/remove")
|
||||
@admin_required
|
||||
@reauth_required
|
||||
def remove_trusted_key(key_id: str):
|
||||
@@ -246,5 +246,5 @@ def remove_trusted_key(key_id: str):
|
||||
if manager is not None:
|
||||
with core().database.session() as conn:
|
||||
manager.reload(conn)
|
||||
flash("Aussteller-Schluessel entfernt.", "info")
|
||||
flash("Aussteller-Schlüssel entfernt.", "info")
|
||||
return redirect(url_for("license.overview"))
|
||||
@@ -13,13 +13,13 @@ from tesm_core.extension import core, db
|
||||
|
||||
from ..services import kea, monitor
|
||||
|
||||
bp = Blueprint("tesmlogs", __name__, url_prefix="/protokolle")
|
||||
bp = Blueprint("tesmlogs", __name__, url_prefix="/logs")
|
||||
|
||||
MAX_TAIL_LINES = 2000
|
||||
|
||||
|
||||
@bp.get("/kea")
|
||||
@permission_required("logs_dhcp.view")
|
||||
@bp.get("/dhcp")
|
||||
@permission_required("logs_dhcp.read")
|
||||
def dhcp():
|
||||
extension = core()
|
||||
if extension.license is not None and not extension.license.module_active("dhcp"):
|
||||
@@ -34,12 +34,12 @@ def dhcp():
|
||||
else:
|
||||
error = result.message
|
||||
else:
|
||||
error = "Systemhelfer nicht verfuegbar."
|
||||
error = "Systemhelfer nicht verfügbar."
|
||||
return render_template("tesm/logs_kea.html", lines=lines, path=kea.LOG_PATH, error=error)
|
||||
|
||||
|
||||
@bp.get("/neustarts")
|
||||
@permission_required("devices.view")
|
||||
@bp.get("/restarts")
|
||||
@permission_required("devices.read")
|
||||
def restarts():
|
||||
"""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 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:
|
||||
method = ""
|
||||
sql = (
|
||||
@@ -66,4 +66,5 @@ def restarts():
|
||||
method=method,
|
||||
methods=tuple(monitor.METHOD_LABELS.items()),
|
||||
method_labels=monitor.METHOD_LABELS,
|
||||
trigger_labels=monitor.TRIGGER_LABELS,
|
||||
)
|
||||
@@ -18,14 +18,15 @@ from __future__ import annotations
|
||||
from flask import Blueprint, abort, flash, jsonify, redirect, render_template, request, url_for
|
||||
from tesm_core.security.sessions import fragment
|
||||
from tesm_core import audit
|
||||
from tesm_core.auth.models import current_user, permission_required
|
||||
from tesm_core.auth.models import current_user, permission_required, reauth_required
|
||||
from tesm_core.auth.service import client_ip
|
||||
from tesm_core.db import Database
|
||||
from tesm_core.extension import core, db, transaction
|
||||
|
||||
from ..permissions import SERVICING_PAGE
|
||||
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
|
||||
@@ -75,19 +76,21 @@ def _query_devices(conn) -> list[dict]: # type: ignore[no-untyped-def]
|
||||
|
||||
|
||||
@bp.get("/")
|
||||
@permission_required("maintenance.view", "maintenance.execute", mode="any")
|
||||
def overview():
|
||||
@permission_required(*SERVICING_PAGE, mode="any")
|
||||
def overview(): # noqa: C901 - eine Seite, drei Aufgaben
|
||||
conn = db()
|
||||
return render_template(
|
||||
"tesm/maintenance.html",
|
||||
devices=_eligible_devices(conn),
|
||||
jobs=service.list_jobs(conn),
|
||||
auftragslage=service.letzter_auftrag_je_geraet(conn),
|
||||
category_labels=dict(inventory.CREDENTIAL_CATEGORIES),
|
||||
category_effects=inventory.CATEGORY_EFFECTS,
|
||||
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_package=windowsops.REQUIRED_PACKAGE,
|
||||
befehlsvorlagen=service.BEFEHLSVORLAGEN,
|
||||
)
|
||||
|
||||
|
||||
@@ -105,8 +108,8 @@ def _windows_tool_missing(conn) -> bool: # type: ignore[no-untyped-def]
|
||||
)
|
||||
|
||||
|
||||
@bp.post("/paket-nachinstallieren")
|
||||
@permission_required("maintenance.execute")
|
||||
@bp.post("/install-package")
|
||||
@permission_required("servicing_tool.execute")
|
||||
def install_windows_tool():
|
||||
"""Installiert das Paket, das der Fehlermeldung nach fehlt.
|
||||
|
||||
@@ -116,7 +119,7 @@ def install_windows_tool():
|
||||
"""
|
||||
sysops = core().sysops
|
||||
if sysops is None or not sysops.available:
|
||||
flash("Systemaktionen sind in dieser Umgebung nicht verfuegbar.", "warning")
|
||||
flash("Systemaktionen sind in dieser Umgebung nicht verfügbar.", "warning")
|
||||
return redirect(url_for("maintenance.overview"))
|
||||
|
||||
result = sysops.apt_install(windowsops.REQUIRED_PACKAGE)
|
||||
@@ -139,16 +142,23 @@ def install_windows_tool():
|
||||
|
||||
|
||||
@bp.get("/status")
|
||||
@permission_required("maintenance.view", "maintenance.execute", mode="any")
|
||||
@permission_required(*SERVICING_PAGE, mode="any")
|
||||
def status():
|
||||
"""Zustand der Auftraege -- aus der Datenbank, nicht aus dem Prozessspeicher."""
|
||||
conn = db()
|
||||
jobs = service.list_jobs(conn, limit=30)
|
||||
jobs = service.laufende_und_letzte(conn, limit=30)
|
||||
running = any(job["state"] in {"pending", "running"} for job in jobs)
|
||||
return jsonify(
|
||||
{
|
||||
"running": running,
|
||||
"poll_ms": 2000 if running else 10000,
|
||||
# Der Zustand der Geraete gehoert dazu: die Seite steht sonst mit
|
||||
# dem Stand ihres Aufbaus da, und wer nach einem Neustart auf die
|
||||
# Rueckkehr wartet, sieht "unbekannt", bis er von Hand neu laedt.
|
||||
"devices": [
|
||||
{"id": int(row["id"]), "state": str(row["state"] or "unknown")}
|
||||
for row in _eligible_devices(conn)
|
||||
],
|
||||
"jobs": [
|
||||
{
|
||||
"id": job["id"],
|
||||
@@ -158,6 +168,16 @@ def status():
|
||||
"progress": job["progress"],
|
||||
"message": job["message"],
|
||||
"updated_at": job["updated_at"],
|
||||
# Die letzten Zeilen -- nur von dem, was gerade laeuft.
|
||||
# Mitlesen heisst mitlesen: wer nach der Wartung wissen
|
||||
# will, was war, oeffnet den Auftrag. Alles mitzuschicken
|
||||
# hiesse, alle zwei Sekunden das gesamte Protokoll aller
|
||||
# Geraete zu uebertragen.
|
||||
"output": (
|
||||
service.letzte_zeilen(str(job["output"] or ""))
|
||||
if service.eben_fertig(dict(job))
|
||||
else ""
|
||||
),
|
||||
}
|
||||
for job in jobs
|
||||
],
|
||||
@@ -165,8 +185,8 @@ def status():
|
||||
)
|
||||
|
||||
|
||||
@bp.get("/auftrag/<job_id>")
|
||||
@permission_required("maintenance.view", "maintenance.execute", mode="any")
|
||||
@bp.get("/job/<job_id>")
|
||||
@permission_required(*SERVICING_PAGE, mode="any")
|
||||
def job_detail(job_id: str):
|
||||
conn = db()
|
||||
job = service.get_job(conn, job_id)
|
||||
@@ -175,8 +195,8 @@ def job_detail(job_id: str):
|
||||
return render_template("tesm/maintenance_job.html", job=job)
|
||||
|
||||
|
||||
@bp.get("/auftrag/<job_id>/ausgabe")
|
||||
@permission_required("maintenance.view", "maintenance.execute", mode="any")
|
||||
@bp.get("/job/<job_id>/output")
|
||||
@permission_required(*SERVICING_PAGE, mode="any")
|
||||
@fragment
|
||||
def job_output(job_id: str):
|
||||
conn = db()
|
||||
@@ -186,12 +206,12 @@ def job_output(job_id: str):
|
||||
return render_template("tesm/_maintenance_output.html", job=job)
|
||||
|
||||
|
||||
def _start(kind: str) -> None:
|
||||
def _start(kind: str, befehl: str = "") -> None:
|
||||
extension = core()
|
||||
conn = db()
|
||||
device_ids = [int(value) for value in request.form.getlist("device_ids") if value.isdigit()]
|
||||
if not device_ids:
|
||||
flash("Es wurde kein Geraet ausgewaehlt.", "warning")
|
||||
flash("Es wurde kein Gerät ausgewählt.", "warning")
|
||||
return
|
||||
|
||||
eligible = {int(row["id"]): row for row in _eligible_devices(conn)}
|
||||
@@ -204,8 +224,8 @@ def _start(kind: str) -> None:
|
||||
# zurueck, der nie gestartet wird und ewig auf "wartet" steht.
|
||||
if kind == service.JOB_KIND_UPDATE and str(device["category"]) != "linux":
|
||||
flash(
|
||||
f"{device['name']}: Paketaktualisierung gibt es nur fuer Linux-Clients. "
|
||||
"Windows-Geraete lassen sich hier nur neu starten.",
|
||||
f"{device['name']}: Paketaktualisierung gibt es nur für Linux-Clients. "
|
||||
"Windows-Geräte lassen sich hier nur neu starten.",
|
||||
"warning",
|
||||
)
|
||||
continue
|
||||
@@ -232,12 +252,27 @@ def _start(kind: str) -> None:
|
||||
actor_id=current_user.id,
|
||||
action=kind,
|
||||
target=str(device["name"]),
|
||||
detail=f"Auftrag {handle.id}",
|
||||
# Beim Befehl gehoert sein Wortlaut hierher und nicht nur die
|
||||
# Auftragsnummer: was gelaufen ist, muss aus dem Protokoll
|
||||
# hervorgehen, auch wenn der Auftrag laengst aus dem Verlauf
|
||||
# gerollt ist.
|
||||
detail=f"Auftrag {handle.id}" + (f": {befehl}" if befehl else ""),
|
||||
ip=client_ip(),
|
||||
severity="notice",
|
||||
severity="warning" if befehl else "notice",
|
||||
)
|
||||
|
||||
if kind == service.JOB_KIND_UPDATE:
|
||||
if kind == service.JOB_KIND_COMMAND:
|
||||
service.start_background(
|
||||
service.run_command,
|
||||
database=extension.database,
|
||||
job_id=handle.id,
|
||||
device=dict(device),
|
||||
username=username,
|
||||
password=password,
|
||||
befehl=befehl,
|
||||
timeout=int(extension.settings.get(conn, "maintenance_command_timeout") or 900),
|
||||
)
|
||||
elif kind == service.JOB_KIND_UPDATE:
|
||||
service.start_background(
|
||||
service.run_update,
|
||||
database=extension.database,
|
||||
@@ -248,43 +283,70 @@ def _start(kind: str) -> None:
|
||||
timeout=int(extension.settings.get(conn, "maintenance_command_timeout") or 900),
|
||||
)
|
||||
else:
|
||||
weg = (
|
||||
service.METHOD_RPC
|
||||
if str(device["category"]) == inventory.NON_SSH_CATEGORY
|
||||
else service.METHOD_SSH
|
||||
)
|
||||
# Sofort sichtbar, nicht erst wenn das Geraet zurueck ist.
|
||||
with transaction() as verbindung:
|
||||
monitor.mark_restarting(verbindung, int(device["id"]))
|
||||
monitor.mark_restarting(
|
||||
verbindung,
|
||||
int(device["id"]),
|
||||
live=extension.registry.get("live_log"),
|
||||
trigger="maintenance",
|
||||
method=weg,
|
||||
)
|
||||
service.start_background(
|
||||
service.run_reboot,
|
||||
database=extension.database,
|
||||
live=extension.registry.get("live_log"),
|
||||
job_id=handle.id,
|
||||
device=dict(device),
|
||||
username=username,
|
||||
password=password,
|
||||
wait_seconds=int(extension.settings.get(conn, "maintenance_reboot_wait") or 300),
|
||||
actor=current_user.username,
|
||||
# Hier steht in ``category`` die Kategorie der *Zugangsdaten*
|
||||
# (siehe _query_devices) -- daraus folgt der Weg. Uebergeben
|
||||
# statt in run_reboot erraten: dort bedeutet derselbe
|
||||
# Schluessel je Herkunft etwas anderes.
|
||||
method=(
|
||||
service.METHOD_RPC
|
||||
if str(device["category"]) == inventory.NON_SSH_CATEGORY
|
||||
else service.METHOD_SSH
|
||||
),
|
||||
# Der Weg steht oben: in ``category`` liegt die Kategorie der
|
||||
# *Zugangsdaten* (siehe _query_devices), daraus folgt er.
|
||||
# Uebergeben statt in run_reboot erraten -- dort bedeutet
|
||||
# derselbe Schluessel je Herkunft etwas anderes.
|
||||
method=weg,
|
||||
)
|
||||
started += 1
|
||||
|
||||
if started:
|
||||
flash(f"{started} Auftrag/Auftraege gestartet.", "success")
|
||||
flash(f"{started} Auftrag/Aufträge gestartet.", "success")
|
||||
|
||||
|
||||
@bp.post("/aktualisieren")
|
||||
@permission_required("maintenance.execute")
|
||||
@bp.post("/update")
|
||||
@permission_required("servicing_update.execute")
|
||||
def start_update():
|
||||
_start(service.JOB_KIND_UPDATE)
|
||||
return redirect(url_for("maintenance.overview"))
|
||||
|
||||
|
||||
@bp.post("/neustarten")
|
||||
@permission_required("maintenance.execute")
|
||||
@bp.post("/restart")
|
||||
@permission_required("servicing_reboot.execute")
|
||||
def start_reboot():
|
||||
_start(service.JOB_KIND_REBOOT)
|
||||
return redirect(url_for("maintenance.overview"))
|
||||
|
||||
|
||||
@bp.post("/command")
|
||||
@permission_required("servicing_command.execute")
|
||||
@reauth_required
|
||||
def start_command():
|
||||
"""Eine Befehlszeile auf allen ausgewaehlten Clients.
|
||||
|
||||
Dieselbe Schwelle wie die Konsole: eigenes Recht **und** eine frische
|
||||
Passwortbestaetigung. Wer hier etwas eintippt, tut es auf einem Dutzend
|
||||
Maschinen gleichzeitig, und niemand sieht dabei zu.
|
||||
"""
|
||||
try:
|
||||
befehl = service.befehl_pruefen(request.form.get("befehl", ""))
|
||||
except ValueError as exc:
|
||||
flash(str(exc), "danger")
|
||||
return redirect(url_for("maintenance.overview"))
|
||||
_start(service.JOB_KIND_COMMAND, befehl=befehl)
|
||||
return redirect(url_for("maintenance.overview"))
|
||||
@@ -0,0 +1,484 @@
|
||||
"""Bildschirm eines Clients im Browser -- noVNC ueber eine WebSocket-Strecke.
|
||||
|
||||
Warum ueberhaupt im Browser und nicht mit dem Viewer auf dem Arbeitsplatz: der
|
||||
Viewer muss installiert, gepflegt und mit Zugangsdaten versorgt werden, und vom
|
||||
Telefon aus gibt es ihn gar nicht. Hier genuegt die Anmeldung an TESM -- die
|
||||
Rechte, die Protokollierung und die Frage "darf dieser Mensch auf diesen
|
||||
Bildschirm?" haengen ohnehin schon dort.
|
||||
|
||||
**Die Strecke reicht durch, sie versteht nichts.** Der Server oeffnet eine
|
||||
gewoehnliche TCP-Verbindung zum VNC-Server und schaufelt Bytes in beide
|
||||
Richtungen; das RFB-Protokoll samt Authentifizierung handelt der Browser
|
||||
unmittelbar mit dem Geraet aus. Das ist Absicht: eine Gegenstelle, die das
|
||||
Protokoll auspackt, muesste die Verschluesselung aufbrechen und wieder
|
||||
aufbauen -- mehr Code an der empfindlichsten Stelle, ohne dass jemand etwas
|
||||
davon haette.
|
||||
|
||||
**Was daraus folgt** und nicht verschwiegen werden soll: weil der Browser die
|
||||
Anmeldung fuehrt, muss er die Zugangsdaten kennen. Hinterlegte Zugangsdaten
|
||||
gibt TESM deshalb nur an einen Menschen heraus, der Geheimnisse ohnehin sehen
|
||||
darf (``credentials.secrets``) -- jede Herausgabe steht im Aenderungsprotokoll.
|
||||
Wer das Recht nicht hat, tippt sie in der Seite selbst ein; sie gehen dann
|
||||
durch den Browser, aber nie durch unsere Datenbank.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import socket
|
||||
import threading
|
||||
import time
|
||||
from typing import Any
|
||||
|
||||
from flask import (
|
||||
Blueprint,
|
||||
abort,
|
||||
flash,
|
||||
redirect,
|
||||
render_template,
|
||||
request,
|
||||
url_for,
|
||||
)
|
||||
from tesm_core import audit
|
||||
from tesm_core.auth.models import current_user, permission_required, reauth_required
|
||||
from tesm_core.auth.service import client_ip, reauth_is_fresh
|
||||
from tesm_core.extension import core, db
|
||||
|
||||
from ..services import inventory, vnc
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
bp = Blueprint("screen", __name__)
|
||||
|
||||
#: Wie lange auf den VNC-Server gewartet wird, bis die Strecke steht.
|
||||
VERBINDUNGSLIMIT = 6.0
|
||||
|
||||
#: Groesse der Haeppchen, in denen vom Geraet gelesen wird. Ein Bildschirm
|
||||
#: liefert in Schueben; kleiner hiesse mehr Runden, groesser mehr Wartezeit,
|
||||
#: bis ein Schub weitergereicht wird.
|
||||
BLOCK = 32 * 1024
|
||||
|
||||
#: Wie oft nachgesehen wird, ob die Sitzung noch etwas tut. Kurz genug, dass
|
||||
#: das Trennen puenktlich ist, lang genug, dass das Nachsehen nichts kostet.
|
||||
TAKT = 1.0
|
||||
|
||||
|
||||
def _geraet(conn: Any, device_id: int) -> dict[str, Any] | None:
|
||||
device = inventory.get_device(conn, device_id)
|
||||
if device is None or device.get("deleted_at"):
|
||||
return None
|
||||
return device
|
||||
|
||||
|
||||
def _erklaerung(fehler: OSError) -> str:
|
||||
"""Die Fehlernummer in einem Satz, der weiterhilft.
|
||||
|
||||
"No route to host" und "Connection refused" sehen im Protokoll gleich
|
||||
harmlos aus und bedeuten das Gegenteil voneinander: einmal kommt man gar
|
||||
nicht hin, einmal kommt man hin und niemand hoert zu. Und beides hat mit
|
||||
einem gelungenen Ping nichts zu tun -- der prueft eine andere Schicht.
|
||||
"""
|
||||
import errno as fehlernummern
|
||||
|
||||
nummer = getattr(fehler, "errno", None)
|
||||
if nummer == fehlernummern.EHOSTUNREACH:
|
||||
return (
|
||||
f"{fehler} – kein Weg dorthin. Eine Firewall oder eine Route steht "
|
||||
"dazwischen; ein gelungener Ping sagt darüber nichts, der prüft eine "
|
||||
"andere Schicht"
|
||||
)
|
||||
if nummer == fehlernummern.ECONNREFUSED:
|
||||
return f"{fehler} – das Gerät ist erreichbar, aber auf diesem Port hört niemand"
|
||||
if nummer == fehlernummern.ETIMEDOUT:
|
||||
return f"{fehler} – keine Antwort; Paketfilter verwerfen meist stillschweigend"
|
||||
return str(fehler)
|
||||
|
||||
|
||||
def _port(conn: Any) -> int:
|
||||
extension = core()
|
||||
if not extension.settings.get(conn, "vnc_enabled"):
|
||||
return 0
|
||||
return int(extension.settings.get(conn, "vnc_port") or vnc.STANDARD_PORT)
|
||||
|
||||
|
||||
@bp.get("/monitoring/clients/<int:device_id>/screen")
|
||||
@permission_required("device_vnc.execute")
|
||||
@reauth_required
|
||||
def screen(device_id: int):
|
||||
"""Die Seite mit der Leinwand.
|
||||
|
||||
Sie beruehrt das Geraet nicht: die Sitzung selbst ist der einzige Anlass,
|
||||
dort anzuklopfen. Was ueber die Anmeldeverfahren bekannt ist, steht in der
|
||||
Datenbank -- aus der letzten ausdruecklichen Pruefung (Knopf "Verfahren
|
||||
pruefen"). Dass diese Seite frueher selbst gemessen hat, war ein Fehler:
|
||||
jede Messung ist eine angefangene und abgebrochene Protokollverhandlung,
|
||||
und ein RealVNC Server zaehlt so etwas -- das Oeffnen der Seite hat der
|
||||
Sitzung also selbst im Weg gestanden.
|
||||
"""
|
||||
conn = db()
|
||||
device = _geraet(conn, device_id)
|
||||
if device is None:
|
||||
abort(404)
|
||||
port = _port(conn)
|
||||
if not port:
|
||||
abort(404)
|
||||
|
||||
zugang = vnc.zugang_gemerkt(device, port=port)
|
||||
return render_template(
|
||||
"tesm/screen.html",
|
||||
device=device,
|
||||
zugang=zugang,
|
||||
quelle=url_for("screen.session_json", device_id=device_id),
|
||||
darf_geheimnis=bool(current_user.has("credentials.secrets")),
|
||||
verfahren_name=vnc.verfahren_name,
|
||||
)
|
||||
|
||||
|
||||
def _zugangsdaten(conn: Any, device: dict[str, Any]) -> tuple[str, str]:
|
||||
"""Hinterlegte Zugangsdaten -- nur fuer den, der Geheimnisse sehen darf.
|
||||
|
||||
Weil der Browser die Anmeldung fuehrt, muessen sie in die Seite. Wer
|
||||
Geheimnisse nicht sehen darf, bekommt sie auch hier nicht: sonst waere die
|
||||
Bildschirmsitzung der Umweg, auf dem jeder das Passwort liest. Jede
|
||||
Herausgabe steht im Aenderungsprotokoll -- das ist kein Zusehen, sondern
|
||||
ein Zugriff auf etwas, das sonst niemand sieht.
|
||||
"""
|
||||
if not device.get("credential_id") or not current_user.has("credentials.secrets"):
|
||||
return "", ""
|
||||
try:
|
||||
benutzer, passwort = inventory.credential_secret(
|
||||
conn, core().keystore, int(device["credential_id"])
|
||||
)
|
||||
except inventory.ValidationError:
|
||||
return "", ""
|
||||
if passwort:
|
||||
with core().database.transaction(conn):
|
||||
audit.log(
|
||||
conn,
|
||||
actor=current_user.username,
|
||||
actor_id=current_user.id,
|
||||
action="device.vnc_credentials_used",
|
||||
target=str(device["name"]),
|
||||
detail=(
|
||||
f"Hinterlegte Zugangsdaten ({benutzer}) an die Bildschirmsitzung "
|
||||
"übergeben"
|
||||
),
|
||||
ip=client_ip(),
|
||||
severity="warning",
|
||||
)
|
||||
return benutzer, passwort
|
||||
|
||||
|
||||
@bp.post("/monitoring/clients/<int:device_id>/screen/probe")
|
||||
@permission_required("device_vnc.execute")
|
||||
@reauth_required
|
||||
def probe(device_id: int):
|
||||
"""Fragt das Geraet ausdruecklich, wie es sich authentifizieren will.
|
||||
|
||||
Ein Knopf und keine Nebenwirkung des Seitenaufbaus: die Messung bricht eine
|
||||
Protokollverhandlung ab, und RealVNC zaehlt das. Sie gehoert in die Hand
|
||||
dessen, der gerade sucht -- nicht in jeden Aufruf.
|
||||
"""
|
||||
conn = db()
|
||||
device = _geraet(conn, device_id)
|
||||
if device is None:
|
||||
abort(404)
|
||||
port = _port(conn)
|
||||
if not port:
|
||||
abort(404)
|
||||
|
||||
from ..services import monitor
|
||||
|
||||
zugang = vnc.zugang(device, port=port)
|
||||
with core().database.transaction(conn):
|
||||
monitor.merke_vnc(conn, int(device["id"]), zugang.erreichbar, zugang.ziel)
|
||||
monitor.merke_verfahren(conn, int(device["id"]), zugang.verfahren, zugang.absage)
|
||||
if zugang.absage:
|
||||
flash(f"Der Server antwortet: „{zugang.absage}“", "warning")
|
||||
elif zugang.verfahren:
|
||||
flash(
|
||||
"Angebotene Anmeldeverfahren: "
|
||||
+ ", ".join(vnc.verfahren_name(v) for v in zugang.verfahren),
|
||||
"info",
|
||||
)
|
||||
else:
|
||||
flash(f"{zugang.ziel}:{port} hat nicht geantwortet.", "danger")
|
||||
return redirect(url_for("screen.screen", device_id=device_id))
|
||||
|
||||
|
||||
@bp.get("/monitoring/clients/<int:device_id>/screen/session")
|
||||
@permission_required("device_vnc.execute")
|
||||
def session_json(device_id: int):
|
||||
"""Alles, was der Client fuer eine Sitzung braucht -- als Auskunft.
|
||||
|
||||
Warum nicht in die Seite geschrieben: dieselbe Sitzung laeuft auch im
|
||||
Fenster der Statusuebersicht, und dort stuenden sonst die Zugangsdaten
|
||||
**jedes** Geraets im Quelltext der Seite. So holt sie sich nur, wer eine
|
||||
Sitzung wirklich oeffnet -- und nur fuer das eine Geraet.
|
||||
|
||||
Die frische Passwortbestaetigung wird hier von Hand geprueft statt ueber
|
||||
den Dekorator: der wuerde auf eine Seite umleiten, und eine Umleitung ist
|
||||
fuer einen Abruf im Hintergrund keine Auskunft. 409 mit einem Hinweis ist
|
||||
eine.
|
||||
"""
|
||||
conn = db()
|
||||
device = _geraet(conn, device_id)
|
||||
if device is None:
|
||||
abort(404)
|
||||
port = _port(conn)
|
||||
if not port:
|
||||
abort(404)
|
||||
if not reauth_is_fresh():
|
||||
return {"reauth": True}, 409
|
||||
|
||||
zugang = vnc.zugang_gemerkt(device, port=port)
|
||||
benutzer, passwort = _zugangsdaten(conn, device)
|
||||
return {
|
||||
"name": str(device["name"]),
|
||||
"socket": f"/ws/screen/{device_id}",
|
||||
"ziel": f"{zugang.ziel}:{zugang.port}",
|
||||
"verfahren": list(zugang.verfahren),
|
||||
"bewaehrt": zugang.bewaehrt,
|
||||
"hinderungsgrund": zugang.hinderungsgrund,
|
||||
"meldeweg": url_for("screen.merke_verfahren", device_id=device_id),
|
||||
"vorgaben": {"username": benutzer, "password": passwort},
|
||||
}
|
||||
|
||||
|
||||
@bp.post("/monitoring/clients/<int:device_id>/screen/method")
|
||||
@permission_required("device_vnc.execute")
|
||||
def merke_verfahren(device_id: int):
|
||||
"""Der Browser meldet, womit die Sitzung zustande kam.
|
||||
|
||||
Nur er weiss es: die Strecke reicht Bytes durch und liest das Protokoll
|
||||
nicht. Beim naechsten Mal faengt die Seite damit an, statt wieder bei der
|
||||
ersten Wahl des Servers -- die hier schon einmal abgerissen ist und deren
|
||||
Absage sich ein sperrender RealVNC Server Minuten Zeit laesst.
|
||||
"""
|
||||
conn = db()
|
||||
device = _geraet(conn, device_id)
|
||||
if device is None:
|
||||
abort(404)
|
||||
try:
|
||||
verfahren = int(request.json.get("verfahren", 0)) if request.json else 0
|
||||
except (TypeError, ValueError):
|
||||
verfahren = 0
|
||||
if not 0 < verfahren < 256:
|
||||
return {"ok": False}, 400
|
||||
|
||||
from ..services import monitor
|
||||
|
||||
with core().database.transaction(conn):
|
||||
monitor.merke_verfahren_das_traegt(conn, int(device["id"]), verfahren)
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
def init_app(app) -> None: # type: ignore[no-untyped-def]
|
||||
"""Meldet Seite und -- falls verfuegbar -- die WebSocket-Strecke an."""
|
||||
app.register_blueprint(bp)
|
||||
|
||||
try:
|
||||
from flask_sock import Sock
|
||||
except ImportError: # pragma: no cover - optionale Abhaengigkeit
|
||||
logger.info("flask-sock fehlt – die Bildschirmsitzung bleibt abgeschaltet.")
|
||||
return
|
||||
|
||||
sock = Sock(app)
|
||||
|
||||
@sock.route("/ws/screen/<int:device_id>")
|
||||
def screen_socket(ws, device_id: int): # type: ignore[no-untyped-def]
|
||||
_strecke(ws, device_id)
|
||||
|
||||
@sock.route("/ws/probe")
|
||||
def probe_socket(ws): # type: ignore[no-untyped-def]
|
||||
"""Sagt "da", und mehr nicht.
|
||||
|
||||
Wozu: bricht eine Bildschirmsitzung ab, gibt es zwei sehr verschiedene
|
||||
Ursachen -- der VNC-Server mag nicht, oder die WebSocket-Verbindung
|
||||
kommt gar nicht erst durch (ein Proxy zwischen Browser und TESM, der
|
||||
"Upgrade" nicht weiterreicht, ist der haeufigste Fall). Von aussen
|
||||
sehen beide gleich aus: ein Abbruch ohne Grund. Diese Probe trennt
|
||||
sie, ohne dass jemand ein Protokoll lesen muss.
|
||||
"""
|
||||
if not current_user.is_authenticated:
|
||||
ws.close(1008, "Nicht angemeldet.")
|
||||
return
|
||||
try:
|
||||
ws.send("da")
|
||||
ws.receive(timeout=2)
|
||||
except Exception: # noqa: BLE001 - der Browser ist weg, das ist keine Stoerung
|
||||
pass
|
||||
|
||||
|
||||
def _strecke(ws, device_id: int) -> None: # type: ignore[no-untyped-def]
|
||||
"""Bytes hin, Bytes her -- und sonst nichts.
|
||||
|
||||
Die Pruefungen stehen hier ausdruecklich noch einmal: ein WebSocket geht an
|
||||
den Dekoratoren der HTTP-Route vorbei.
|
||||
"""
|
||||
extension = core()
|
||||
conn = db()
|
||||
|
||||
if not current_user.is_authenticated or not current_user.has("device_vnc.execute"):
|
||||
ws.close(1008, "Keine Berechtigung für den Bildschirm dieses Clients.")
|
||||
return
|
||||
if not reauth_is_fresh():
|
||||
ws.close(1008, "Die Passwortbestätigung ist abgelaufen.")
|
||||
return
|
||||
port = _port(conn)
|
||||
if not port:
|
||||
ws.close(1008, "Der Bildschirmzugriff ist in dieser Installation abgeschaltet.")
|
||||
return
|
||||
device = _geraet(conn, device_id)
|
||||
if device is None:
|
||||
ws.close(1008, "Client nicht gefunden.")
|
||||
return
|
||||
host = vnc.ziel(device)
|
||||
if not host:
|
||||
ws.close(1008, "Für diesen Client ist kein Ziel bekannt.")
|
||||
return
|
||||
|
||||
gegenstelle = None
|
||||
ausweich = str(device.get("ip") or "").strip()
|
||||
for versuch, ziel_host in enumerate([host] + ([ausweich] if ausweich != host else [])):
|
||||
try:
|
||||
gegenstelle = socket.create_connection((ziel_host, port), timeout=VERBINDUNGSLIMIT)
|
||||
if versuch:
|
||||
# Der Name fuehrte ins Leere, die Adresse nicht. Das ist eine
|
||||
# Auskunft ueber das Netz, keine Nebensaechlichkeit: irgendwo
|
||||
# zeigt ein Eintrag woandershin.
|
||||
if live_log := extension.registry.get("live_log"):
|
||||
live_log.warning(
|
||||
"vnc",
|
||||
f"{host} war nicht erreichbar – Sitzung läuft über {ziel_host}",
|
||||
)
|
||||
host = ziel_host
|
||||
break
|
||||
except OSError as exc:
|
||||
letzter = exc
|
||||
if gegenstelle is None:
|
||||
ws.close(1011, f"{host}:{port} antwortet nicht ({_erklaerung(letzter)}).")
|
||||
return
|
||||
# Das Zeitlimit galt dem *Aufbau* -- als Abbruchgrund taugt es nicht.
|
||||
#
|
||||
# ``create_connection`` setzt es an den Socket, und dort gilt es fuer jede
|
||||
# weitere Leseoperation. Ein Bildschirm, auf dem sich gerade nichts tut,
|
||||
# schickt sekundenlang nichts; nach sechs Sekunden Stille brach das Lesen
|
||||
# mit einer Zeitueberschreitung ab, und die Sitzung war weg. Genau so
|
||||
# beobachtet: Bild steht, nach fuenf bis zehn Sekunden Schluss.
|
||||
#
|
||||
# Stattdessen ein kurzer Takt: das Lesen laeuft immer wieder in ein
|
||||
# Zeitlimit, und *das* ist kein Abbruch, sondern der Anlass nachzusehen,
|
||||
# ob die Sitzung ueberhaupt noch etwas tut.
|
||||
# Ohne das sammelt der Kern kleine Pakete, bis genug beisammen ist
|
||||
# (Nagle) -- bei einem Bildschirm sind Tastendruck und Mausbewegung genau
|
||||
# solche kleinen Pakete, und das Warten darauf faellt als Ruckeln auf.
|
||||
gegenstelle.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1)
|
||||
gegenstelle.settimeout(TAKT)
|
||||
leerlauf = int(extension.settings.get(conn, "vnc_idle_timeout") or 0)
|
||||
|
||||
# Ins **laufende** Protokoll, nicht in die Aenderungskette: eine
|
||||
# Bildschirmsitzung aendert nichts an der Anlage. Das Aenderungsprotokoll
|
||||
# soll beantworten, wer was umgestellt hat -- wer wann zugesehen hat,
|
||||
# gehoert nicht in dieselbe Liste und macht sie in einem Betrieb mit
|
||||
# ein paar Dutzend Geraeten unlesbar. Sichtbar ist die Sitzung trotzdem,
|
||||
# solange sie laeuft.
|
||||
begonnen = time.monotonic()
|
||||
live = extension.registry.get("live_log")
|
||||
if live is not None:
|
||||
live.info("vnc", f"{current_user.username} öffnet den Bildschirm von {device['name']}")
|
||||
|
||||
laeuft = threading.Event()
|
||||
laeuft.set()
|
||||
#: Wann zuletzt etwas floss -- in *irgendeine* Richtung. Eine Liste, weil
|
||||
#: beide Richtungen daran schreiben und eine Zuweisung in Python nicht in
|
||||
#: die andere hineinlaufen kann.
|
||||
zuletzt = [time.monotonic()]
|
||||
#: Grund des Endes, falls wir selbst getrennt haben.
|
||||
schluss = [""]
|
||||
#: Kam ueberhaupt schon ein Byte vom Geraet?
|
||||
gehoert = [False]
|
||||
|
||||
def vom_geraet() -> None:
|
||||
"""Alles, was der VNC-Server schickt, geht als Binaerrahmen weiter."""
|
||||
try:
|
||||
while laeuft.is_set():
|
||||
try:
|
||||
daten = gegenstelle.recv(BLOCK)
|
||||
except socket.timeout:
|
||||
# Kein Fehler, nur Stille -- und die bedeutet zweierlei,
|
||||
# je nachdem, ob die Sitzung schon stand.
|
||||
#
|
||||
# **Vor dem ersten Byte** ist Stille ein Befund: der Port
|
||||
# nimmt an, aber es kommt nichts. Genau so verhaelt sich
|
||||
# ein RealVNC Server, der die Adresse gerade aussperrt. Wer
|
||||
# das nicht erfaehrt, sieht ewig "Verbindung wird
|
||||
# aufgebaut" -- und sucht den Fehler bei uns.
|
||||
if not gehoert[0] and time.monotonic() - begonnen > VERBINDUNGSLIMIT:
|
||||
schluss[0] = (
|
||||
f"{host}:{port} nimmt die Verbindung an, schickt aber nichts. "
|
||||
"So verhält sich ein VNC-Server, der die Adresse gerade "
|
||||
"aussperrt (RealVNC: BlacklistTimeout) – ein Neustart des "
|
||||
"VNC-Dienstes auf dem Gerät räumt das ab."
|
||||
)
|
||||
break
|
||||
# **Danach** ist Stille der Normalfall: ein Bildschirm, auf
|
||||
# dem sich nichts tut, schickt nichts.
|
||||
if gehoert[0] and leerlauf and time.monotonic() - zuletzt[0] > leerlauf:
|
||||
schluss[0] = (
|
||||
f"Sitzung nach {leerlauf} Sekunden ohne Bewegung getrennt"
|
||||
)
|
||||
break
|
||||
continue
|
||||
if not daten:
|
||||
if not gehoert[0]:
|
||||
schluss[0] = (
|
||||
f"{host}:{port} hat die Verbindung sofort wieder geschlossen."
|
||||
)
|
||||
break
|
||||
gehoert[0] = True
|
||||
zuletzt[0] = time.monotonic()
|
||||
ws.send(daten)
|
||||
except Exception: # noqa: BLE001 - Abbruch ist der Normalfall, nicht die Stoerung
|
||||
pass
|
||||
finally:
|
||||
laeuft.clear()
|
||||
try:
|
||||
if schluss[0]:
|
||||
ws.close(1000 if gehoert[0] else 1011, schluss[0])
|
||||
else:
|
||||
ws.close()
|
||||
except Exception: # noqa: BLE001
|
||||
pass
|
||||
|
||||
leser = threading.Thread(target=vom_geraet, name=f"vnc-{device_id}", daemon=True)
|
||||
leser.start()
|
||||
|
||||
try:
|
||||
while laeuft.is_set():
|
||||
nachricht = ws.receive()
|
||||
if nachricht is None:
|
||||
break
|
||||
if isinstance(nachricht, str):
|
||||
# Der Browser-Client schickt ausschliesslich Binaerrahmen. Text
|
||||
# waere entweder ein Irrtum oder ein Versuch -- beides endet
|
||||
# hier und nicht auf dem Geraet.
|
||||
continue
|
||||
zuletzt[0] = time.monotonic()
|
||||
gegenstelle.sendall(nachricht)
|
||||
except Exception: # noqa: BLE001
|
||||
logger.debug("Bildschirmsitzung beendet (%s)", device["name"], exc_info=True)
|
||||
finally:
|
||||
laeuft.clear()
|
||||
try:
|
||||
gegenstelle.shutdown(socket.SHUT_RDWR)
|
||||
except OSError:
|
||||
pass
|
||||
gegenstelle.close()
|
||||
dauer = int(time.monotonic() - begonnen)
|
||||
if live is not None:
|
||||
live.info(
|
||||
"vnc",
|
||||
f"Bildschirmsitzung {device['name']} beendet ({dauer}s)"
|
||||
+ (f" – {schluss[0]}" if schluss[0] else ""),
|
||||
)
|
||||
@@ -18,7 +18,9 @@ from tesm_core.auth.service import client_ip
|
||||
from tesm_core.extension import core, db, transaction
|
||||
from tesm_core.licensing_client import LicenseError
|
||||
|
||||
bp = Blueprint("settings", __name__, url_prefix="/einstellungen")
|
||||
import tesm_licensing as lic
|
||||
|
||||
bp = Blueprint("settings", __name__, url_prefix="/administration")
|
||||
|
||||
|
||||
def _audit(action: str, target: str = "", detail: str = "", severity: str = "info") -> None:
|
||||
@@ -46,6 +48,28 @@ def _license_hint(feature: str) -> str:
|
||||
return ""
|
||||
|
||||
|
||||
def _module_rows(conn: Any) -> list[dict[str, Any]]:
|
||||
"""Die drei Module mit ihrem Zustand -- lizenziert, benutzt.
|
||||
|
||||
Zwei verschiedene Fragen, die die Seite auseinanderhalten muss: was die
|
||||
Lizenz erlaubt (dagegen hilft kein Schalter) und was dieses Haus benutzen
|
||||
will (dafuer ist der Schalter da).
|
||||
"""
|
||||
manager = core().license
|
||||
if manager is None:
|
||||
return []
|
||||
state = manager.reload(conn)
|
||||
return [
|
||||
{
|
||||
"key": name,
|
||||
"label": lic.MODULE_LABELS.get(name, name),
|
||||
"licensed": name in state.modules,
|
||||
"enabled": name not in state.modules_off,
|
||||
}
|
||||
for name in lic.ALL_MODULES
|
||||
]
|
||||
|
||||
|
||||
def _current_timezone() -> str:
|
||||
sysops = core().sysops
|
||||
if sysops is None:
|
||||
@@ -65,7 +89,7 @@ def _timezone_choices() -> list[str]:
|
||||
|
||||
|
||||
@bp.get("/system")
|
||||
@permission_required("settings_system.view")
|
||||
@permission_required("settings_system.read")
|
||||
def system():
|
||||
extension = core()
|
||||
conn = db()
|
||||
@@ -79,9 +103,17 @@ def system():
|
||||
"monitor_failures_before_restart",
|
||||
"monitor_auto_restart",
|
||||
"dashboard_public",
|
||||
"terminal_enabled",
|
||||
"vnc_enabled",
|
||||
"vnc_port",
|
||||
"vnc_idle_timeout",
|
||||
"vnc_warn_unencrypted_off",
|
||||
"terminal_switches_enabled",
|
||||
"terminal_clients_enabled",
|
||||
"terminal_idle_timeout",
|
||||
"signature_witness_enabled",
|
||||
"trash_retention_days",
|
||||
"session_idle_minutes",
|
||||
"session_absolute_hours",
|
||||
"log_rotate_interval",
|
||||
"log_rotate_keep",
|
||||
],
|
||||
@@ -90,15 +122,21 @@ def system():
|
||||
hostname=(sysops.read_command(["hostname"]).stdout.strip() if sysops else ""),
|
||||
timezone=_current_timezone(),
|
||||
timezones=_timezone_choices(),
|
||||
nav_items=extension.nav.items,
|
||||
# Nicht ``nav_items``: dieser Name gehoert dem Seitenrahmen. Wer ihn hier
|
||||
# belegt, ueberschreibt die gefilterte Navigation aus dem Kontext --
|
||||
# und die Seitenleiste zeigte dann jeden Punkt, auch den, den dieser
|
||||
# Benutzer nicht darf und den die Lizenz nicht freischaltet.
|
||||
nav_baum=extension.nav.items,
|
||||
nav_order=extension.settings.get(conn, "nav_order") or extension.nav.default_order(),
|
||||
nav_child_order=extension.settings.get(conn, "nav_child_order") or {},
|
||||
license_hint=_license_hint("monitor_interval"),
|
||||
sysops_available=bool(sysops and sysops.available),
|
||||
module_rows=_module_rows(conn),
|
||||
)
|
||||
|
||||
|
||||
@bp.post("/system")
|
||||
@permission_required("settings_system.edit")
|
||||
@permission_required("settings_system.write")
|
||||
def system_save():
|
||||
extension = core()
|
||||
conn = db()
|
||||
@@ -109,9 +147,17 @@ def system_save():
|
||||
"monitor_failures_before_restart",
|
||||
"monitor_auto_restart",
|
||||
"dashboard_public",
|
||||
"terminal_enabled",
|
||||
"vnc_enabled",
|
||||
"vnc_port",
|
||||
"vnc_idle_timeout",
|
||||
"vnc_warn_unencrypted_off",
|
||||
"terminal_switches_enabled",
|
||||
"terminal_clients_enabled",
|
||||
"terminal_idle_timeout",
|
||||
"signature_witness_enabled",
|
||||
"trash_retention_days",
|
||||
"session_idle_minutes",
|
||||
"session_absolute_hours",
|
||||
"log_rotate_interval",
|
||||
"log_rotate_keep",
|
||||
):
|
||||
@@ -151,10 +197,60 @@ def system_save():
|
||||
if changed:
|
||||
message = "Einstellungen gespeichert."
|
||||
if interval_changed:
|
||||
message += " Das Pruefintervall greift beim naechsten Durchlauf."
|
||||
message += " Das Prüfintervall greift beim nächsten Durchlauf."
|
||||
flash(message, "success")
|
||||
else:
|
||||
flash("Keine Aenderungen.", "info")
|
||||
flash("Keine Änderungen.", "info")
|
||||
return redirect(url_for("settings.system"))
|
||||
|
||||
|
||||
@bp.post("/system/modules")
|
||||
@permission_required("settings_system.write")
|
||||
def modules_save():
|
||||
"""Module ein- und ausschalten.
|
||||
|
||||
Abgeschaltet heisst wirklich abgeschaltet: der Punkt verschwindet aus der
|
||||
Navigation, und die Routen antworten mit 404 -- dieselbe Sperre, die auch
|
||||
eine fehlende Lizenz zieht. Daten bleiben unangetastet; wer das Modul
|
||||
wieder einschaltet, findet alles vor wie zuvor.
|
||||
"""
|
||||
extension = core()
|
||||
manager = extension.license
|
||||
if manager is None:
|
||||
flash("Diese Installation nutzt keine Lizenzierung.", "info")
|
||||
return redirect(url_for("settings.system"))
|
||||
|
||||
aus = [name for name in lic.ALL_MODULES if request.form.get(f"module_{name}") != "1"]
|
||||
with transaction() as conn:
|
||||
eingeschaltet, abgeschaltet = manager.module_abschalten(
|
||||
conn, aus, actor=current_user.username
|
||||
)
|
||||
if eingeschaltet or abgeschaltet:
|
||||
teile = []
|
||||
if abgeschaltet:
|
||||
teile.append(
|
||||
"abgeschaltet: "
|
||||
+ ", ".join(lic.MODULE_LABELS.get(n, n) for n in sorted(abgeschaltet))
|
||||
)
|
||||
if eingeschaltet:
|
||||
teile.append(
|
||||
"eingeschaltet: "
|
||||
+ ", ".join(lic.MODULE_LABELS.get(n, n) for n in sorted(eingeschaltet))
|
||||
)
|
||||
audit.log(
|
||||
conn,
|
||||
actor=current_user.username,
|
||||
actor_id=current_user.id,
|
||||
action="settings.modules_changed",
|
||||
detail="; ".join(teile),
|
||||
ip=client_ip(),
|
||||
severity="notice",
|
||||
)
|
||||
|
||||
if eingeschaltet or abgeschaltet:
|
||||
flash("Module gespeichert.", "success")
|
||||
else:
|
||||
flash("Keine Änderungen.", "info")
|
||||
return redirect(url_for("settings.system"))
|
||||
|
||||
|
||||
@@ -165,10 +261,10 @@ def set_hostname():
|
||||
sysops = extension.sysops
|
||||
hostname = (request.form.get("hostname") or "").strip()
|
||||
if not hostname or len(hostname) > 63 or not hostname.replace("-", "").isalnum():
|
||||
flash("Der Hostname ist ungueltig.", "danger")
|
||||
flash("Der Hostname ist ungültig.", "danger")
|
||||
return redirect(url_for("settings.system"))
|
||||
if sysops is None or not sysops.available:
|
||||
flash("Systemaktionen sind in dieser Umgebung nicht verfuegbar.", "warning")
|
||||
flash("Systemaktionen sind in dieser Umgebung nicht verfügbar.", "warning")
|
||||
return redirect(url_for("settings.system"))
|
||||
result = sysops.set_hostname(hostname)
|
||||
if result.ok:
|
||||
@@ -181,7 +277,7 @@ def set_hostname():
|
||||
return redirect(url_for("settings.system"))
|
||||
|
||||
|
||||
@bp.post("/system/zeitzone")
|
||||
@bp.post("/system/timezone")
|
||||
@permission_required("settings_system.execute")
|
||||
def set_timezone():
|
||||
extension = core()
|
||||
@@ -191,7 +287,7 @@ def set_timezone():
|
||||
flash("Unbekannte Zeitzone.", "danger")
|
||||
return redirect(url_for("settings.system"))
|
||||
if sysops is None or not sysops.available:
|
||||
flash("Systemaktionen sind in dieser Umgebung nicht verfuegbar.", "warning")
|
||||
flash("Systemaktionen sind in dieser Umgebung nicht verfügbar.", "warning")
|
||||
return redirect(url_for("settings.system"))
|
||||
result = sysops.set_timezone(timezone)
|
||||
if result.ok:
|
||||
@@ -210,14 +306,14 @@ def set_timezone():
|
||||
return redirect(url_for("settings.system"))
|
||||
|
||||
|
||||
@bp.post("/system/pruefung-jetzt")
|
||||
@bp.post("/system/check-now")
|
||||
@permission_required("settings_system.execute")
|
||||
def run_check_now():
|
||||
"""Startet einen Ueberwachungsdurchlauf sofort, im Hintergrund."""
|
||||
extension = core()
|
||||
|
||||
def worker() -> None:
|
||||
from ..services import inventory, monitor
|
||||
from ..services import inventory, monitor, vnc
|
||||
|
||||
allowed = extension.license is None or extension.license.feature_allowed(
|
||||
"auto_poe_restart"
|
||||
@@ -227,6 +323,7 @@ def run_check_now():
|
||||
live=extension.registry["live_log"],
|
||||
keystore=extension.keystore,
|
||||
license_allows_auto_restart=allowed,
|
||||
vnc_port=vnc.port_aus_einstellungen(extension),
|
||||
)
|
||||
|
||||
threading.Thread(target=worker, name="manual-sweep", daemon=True).start()
|
||||
|
||||
@@ -17,7 +17,7 @@ from tesm_core.extension import core, db, transaction
|
||||
|
||||
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:
|
||||
@@ -47,18 +47,18 @@ def _form_values() -> dict[str, Any]:
|
||||
|
||||
|
||||
@bp.get("/")
|
||||
@permission_required("switches.view")
|
||||
@permission_required("switches.read")
|
||||
def overview():
|
||||
conn = db()
|
||||
return render_template(
|
||||
"tesm/switches.html",
|
||||
switches=inventory.list_switches(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")
|
||||
def create():
|
||||
try:
|
||||
@@ -71,7 +71,7 @@ def create():
|
||||
f"IP {request.form.get('ip')}",
|
||||
severity="notice",
|
||||
)
|
||||
flash("Switch angelegt. Der Host-Schluessel muss vor dem ersten Zugriff freigegeben werden.", "success")
|
||||
flash("Switch angelegt. Der Host-Schlüssel muss vor dem ersten Zugriff freigegeben werden.", "success")
|
||||
return redirect(url_for("switches.detail", switch_id=switch_id))
|
||||
except inventory.ValidationError as exc:
|
||||
flash(str(exc), "danger")
|
||||
@@ -79,7 +79,7 @@ def create():
|
||||
|
||||
|
||||
@bp.post("/<int:switch_id>")
|
||||
@permission_required("switches.edit")
|
||||
@permission_required("switches.write")
|
||||
def update(switch_id: int):
|
||||
try:
|
||||
with transaction() as conn:
|
||||
@@ -92,13 +92,13 @@ def update(switch_id: int):
|
||||
"; ".join(changes),
|
||||
severity="notice",
|
||||
)
|
||||
flash("Aenderungen gespeichert." if changes else "Keine Aenderungen.", "success" if changes else "info")
|
||||
flash("Änderungen gespeichert." if changes else "Keine Änderungen.", "success" if changes else "info")
|
||||
except inventory.ValidationError as exc:
|
||||
flash(str(exc), "danger")
|
||||
return redirect(request.referrer or url_for("switches.overview"))
|
||||
|
||||
|
||||
@bp.post("/<int:switch_id>/loeschen")
|
||||
@bp.post("/<int:switch_id>/delete")
|
||||
@permission_required("switches.delete")
|
||||
def delete(switch_id: int):
|
||||
try:
|
||||
@@ -115,7 +115,7 @@ def delete(switch_id: int):
|
||||
|
||||
|
||||
@bp.get("/<int:switch_id>")
|
||||
@permission_required("switches.view")
|
||||
@permission_required("switches.read")
|
||||
def detail(switch_id: int):
|
||||
conn = db()
|
||||
switch = inventory.get_switch(conn, switch_id)
|
||||
@@ -142,7 +142,7 @@ def detail(switch_id: int):
|
||||
devices=devices,
|
||||
credentials=inventory.list_credentials(conn),
|
||||
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,8 +151,8 @@ def detail(switch_id: int):
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
@bp.post("/<int:switch_id>/hostkey/pruefen")
|
||||
@permission_required("switches.edit")
|
||||
@bp.post("/<int:switch_id>/host-key/probe")
|
||||
@permission_required("switches.write")
|
||||
def probe_host_key(switch_id: int):
|
||||
conn = db()
|
||||
switch = inventory.get_switch(conn, switch_id)
|
||||
@@ -176,8 +176,8 @@ def probe_host_key(switch_id: int):
|
||||
)
|
||||
|
||||
|
||||
@bp.post("/<int:switch_id>/hostkey/freigeben")
|
||||
@permission_required("switches.edit")
|
||||
@bp.post("/<int:switch_id>/host-key/trust")
|
||||
@permission_required("switches.write")
|
||||
@reauth_required
|
||||
def trust_host_key(switch_id: int):
|
||||
"""Gibt einen Host-Schluessel ausdruecklich frei.
|
||||
@@ -192,7 +192,7 @@ def trust_host_key(switch_id: int):
|
||||
key_type = (request.form.get("key_type") or "").strip()
|
||||
fingerprint = (request.form.get("fingerprint") or "").strip()
|
||||
if not key_type or not ssh.valid_fingerprint(fingerprint):
|
||||
flash("Es wurde kein gueltiger Fingerprint uebergeben.", "danger")
|
||||
flash("Es wurde kein gültiger Fingerprint übergeben.", "danger")
|
||||
return redirect(url_for("switches.detail", switch_id=switch_id))
|
||||
|
||||
with transaction():
|
||||
@@ -211,12 +211,12 @@ def trust_host_key(switch_id: int):
|
||||
f"{key_type} {fingerprint}",
|
||||
severity="notice",
|
||||
)
|
||||
flash("Host-Schluessel freigegeben.", "success")
|
||||
flash("Host-Schlüssel freigegeben.", "success")
|
||||
return redirect(url_for("switches.detail", switch_id=switch_id))
|
||||
|
||||
|
||||
@bp.post("/<int:switch_id>/hostkey/verwerfen")
|
||||
@permission_required("switches.edit")
|
||||
@bp.post("/<int:switch_id>/host-key/forget")
|
||||
@permission_required("switches.write")
|
||||
def forget_host_key(switch_id: int):
|
||||
conn = db()
|
||||
switch = inventory.get_switch(conn, switch_id)
|
||||
@@ -225,7 +225,7 @@ def forget_host_key(switch_id: int):
|
||||
with transaction():
|
||||
ssh.forget_key(conn, str(switch["ip"]), int(switch["ssh_port"]))
|
||||
_audit(conn, "switch.host_key_forgotten", str(switch["hostname"]), severity="warning")
|
||||
flash("Gespeicherte Host-Schluessel entfernt.", "info")
|
||||
flash("Gespeicherte Host-Schlüssel entfernt.", "info")
|
||||
return redirect(url_for("switches.detail", switch_id=switch_id))
|
||||
|
||||
|
||||
@@ -234,8 +234,8 @@ def forget_host_key(switch_id: int):
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
@bp.post("/<int:switch_id>/kommando")
|
||||
@permission_required("switches.execute")
|
||||
@bp.post("/<int:switch_id>/command")
|
||||
@permission_required("switch_console.execute")
|
||||
@reauth_required
|
||||
def run_command(switch_id: int):
|
||||
"""Fuehrt ein Kommando aus der Positivliste aus.
|
||||
@@ -256,7 +256,7 @@ def run_command(switch_id: int):
|
||||
flash("Dieses Kommando steht nicht auf der Positivliste.", "danger")
|
||||
return redirect(url_for("switches.detail", switch_id=switch_id))
|
||||
if not switch["credential_id"]:
|
||||
flash("Fuer diesen Switch sind keine Zugangsdaten hinterlegt.", "danger")
|
||||
flash("Für diesen Switch sind keine Zugangsdaten hinterlegt.", "danger")
|
||||
return redirect(url_for("switches.detail", switch_id=switch_id))
|
||||
|
||||
try:
|
||||
|
||||
@@ -12,7 +12,7 @@ Zwei Ziele, ein Ablauf:
|
||||
|
||||
* **Switch** -- ``switches.execute``. Interaktive ArubaOS-Sitzung ueber
|
||||
:class:`~tesm.services.switchcli.SwitchCli`.
|
||||
* **Client** -- ``maintenance.execute`` und das lizenzpflichtige Modul
|
||||
* **Client** -- ``device_console.execute`` und das lizenzpflichtige Modul
|
||||
``maintenance``. Ein Kommando je ``exec``-Kanal ueber
|
||||
:class:`~tesm.services.shellcli.ShellSession`.
|
||||
|
||||
@@ -24,7 +24,10 @@ Rechteausweitung, die dieses Projekt an anderer Stelle ausschliesst.
|
||||
Gemeinsame Sicherheitsmerkmale:
|
||||
|
||||
* 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
|
||||
keine unbekannten Schluessel (anders als der Vorgaenger, der sie hier
|
||||
interaktiv uebernahm). Freigegeben wird auf der jeweiligen Detailseite.
|
||||
@@ -71,8 +74,18 @@ CLIENT_COMMANDS: tuple[tuple[str, str], ...] = (
|
||||
("ip -brief address", "Netzwerkadressen"),
|
||||
("systemctl --failed", "Fehlgeschlagene Dienste"),
|
||||
("journalctl -p err -n 50 --no-pager", "Letzte Fehler im Journal"),
|
||||
("apt list --upgradable 2>/dev/null", "Verfuegbare Aktualisierungen"),
|
||||
("apt list --upgradable 2>/dev/null", "Verfügbare Aktualisierungen"),
|
||||
("uname -a", "Kernel und Architektur"),
|
||||
# Die beiden schreiben -- deshalb stehen sie am Ende und sagen es im Namen.
|
||||
# Eine Vorlage fuellt nur das Eingabefeld; abgeschickt wird von Hand.
|
||||
(
|
||||
'echo "Encryption=AlwaysOff" | sudo tee -a /etc/vnc/config.d/common.custom',
|
||||
"VNC: Streckenverschlüsselung abschalten (schreibt)",
|
||||
),
|
||||
(
|
||||
"sudo systemctl restart vncserver-x11-serviced",
|
||||
"VNC-Dienst neu starten (schreibt)",
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@@ -128,6 +141,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:
|
||||
manager = core().license
|
||||
return manager is None or manager.module_active("maintenance")
|
||||
@@ -136,10 +161,10 @@ def _client_module_active() -> bool:
|
||||
def _render_console(target: Target): # type: ignore[no-untyped-def]
|
||||
extension = core()
|
||||
conn = db()
|
||||
if not extension.settings.get(conn, "terminal_enabled"):
|
||||
if not _konsole_erlaubt(conn, target.kind):
|
||||
abort(404)
|
||||
if not target.credential_id:
|
||||
abort(409, description="Fuer dieses Ziel sind keine Zugangsdaten hinterlegt.")
|
||||
abort(409, description="Für dieses Ziel sind keine Zugangsdaten hinterlegt.")
|
||||
|
||||
return render_template(
|
||||
"tesm/console.html",
|
||||
@@ -151,8 +176,8 @@ def _render_console(target: Target): # type: ignore[no-untyped-def]
|
||||
)
|
||||
|
||||
|
||||
@bp.get("/switche/<int:switch_id>/konsole")
|
||||
@permission_required("switches.execute")
|
||||
@bp.get("/monitoring/switches/<int:switch_id>/console")
|
||||
@permission_required("switch_console.execute")
|
||||
@reauth_required
|
||||
def switch_console(switch_id: int):
|
||||
target = _load_target(db(), KIND_SWITCH, switch_id)
|
||||
@@ -161,8 +186,8 @@ def switch_console(switch_id: int):
|
||||
return _render_console(target)
|
||||
|
||||
|
||||
@bp.get("/clients/<int:device_id>/konsole")
|
||||
@permission_required("maintenance.execute")
|
||||
@bp.get("/monitoring/clients/<int:device_id>/console")
|
||||
@permission_required("device_console.execute")
|
||||
@reauth_required
|
||||
def device_console(device_id: int):
|
||||
if not _client_module_active():
|
||||
@@ -180,7 +205,7 @@ def init_app(app) -> None: # type: ignore[no-untyped-def]
|
||||
try:
|
||||
from flask_sock import Sock
|
||||
except ImportError: # pragma: no cover - optionale Abhaengigkeit
|
||||
logger.info("flask-sock ist nicht installiert -- die Konsole bleibt deaktiviert.")
|
||||
logger.info("flask-sock ist nicht installiert – die Konsole bleibt deaktiviert.")
|
||||
return
|
||||
|
||||
sock = Sock(app)
|
||||
@@ -196,7 +221,17 @@ def init_app(app) -> None: # type: ignore[no-untyped-def]
|
||||
|
||||
|
||||
def _send(ws, kind: str, text: str) -> None: # type: ignore[no-untyped-def]
|
||||
ws.send(json.dumps({"type": kind, "data": text}))
|
||||
"""Schickt eine Nachricht. Ein geschlossener Socket ist kein Fehler.
|
||||
|
||||
Bricht der Browser die Sitzung ab, schlaegt das Senden fehl -- und die
|
||||
Ausnahme daraus verdeckte bisher den eigentlichen Grund, warum die
|
||||
Sitzung endete: im Protokoll stand ein Stapelverlauf aus ``ws.send``,
|
||||
nicht die Meldung des Switches.
|
||||
"""
|
||||
try:
|
||||
ws.send(json.dumps({"type": kind, "data": text}))
|
||||
except Exception: # noqa: BLE001 - der Browser ist weg, das ist keine Stoerung
|
||||
logger.debug("Konsolennachricht nicht mehr zustellbar (%s)", kind)
|
||||
|
||||
|
||||
def _transcript_name(label: str) -> str:
|
||||
@@ -205,23 +240,34 @@ def _transcript_name(label: str) -> str:
|
||||
|
||||
|
||||
def _handle_socket(ws, kind: str, target_id: int) -> None: # type: ignore[no-untyped-def]
|
||||
"""Eine Konsolensitzung, von der Berechtigung bis zum Abmelden.
|
||||
|
||||
Jede Stufe schickt eine Zeile in die Oberflaeche. Das ist keine Zierde:
|
||||
eine Konsole, die nichts sagt, laesst offen, ob die Berechtigung fehlt,
|
||||
die Zugangsdaten, die Verbindung oder die Antwort des Geraets -- und genau
|
||||
diese Frage stand im Betrieb im Raum. Sichtbar sind die Zeilen erst
|
||||
nuetzlich, wenn sie **vor** dem Warten kommen, nicht danach.
|
||||
"""
|
||||
extension = core()
|
||||
conn = db()
|
||||
_send(ws, "info", "Sitzung wird geöffnet …")
|
||||
|
||||
# -- Berechtigungen: dieselben Pruefungen wie in der HTTP-Route. Ein
|
||||
# WebSocket umgeht Dekoratoren, also stehen sie hier ausdruecklich.
|
||||
required = "switches.execute" if kind == KIND_SWITCH else "maintenance.execute"
|
||||
required = (
|
||||
"switch_console.execute" if kind == KIND_SWITCH else "device_console.execute"
|
||||
)
|
||||
if not current_user.is_authenticated or not current_user.has(required):
|
||||
_send(ws, "error", "Keine Berechtigung fuer diese Konsole.")
|
||||
_send(ws, "error", "Keine Berechtigung für diese Konsole.")
|
||||
return
|
||||
if not reauth_is_fresh():
|
||||
_send(ws, "error", "Die Passwortbestaetigung ist abgelaufen. Bitte Seite neu laden.")
|
||||
_send(ws, "error", "Die Passwortbestätigung ist abgelaufen. Bitte Seite neu laden.")
|
||||
return
|
||||
if not extension.settings.get(conn, "terminal_enabled"):
|
||||
if not _konsole_erlaubt(conn, kind):
|
||||
_send(ws, "error", "Die Konsole ist in dieser Installation abgeschaltet.")
|
||||
return
|
||||
if kind == KIND_DEVICE and not _client_module_active():
|
||||
_send(ws, "error", "Die Client-Konsole gehoert zum Modul Wartung -- es ist nicht aktiv.")
|
||||
_send(ws, "error", "Die Client-Konsole gehört zum Modul Wartung – es ist nicht aktiv.")
|
||||
return
|
||||
|
||||
target = _load_target(conn, kind, target_id)
|
||||
@@ -238,6 +284,7 @@ def _handle_socket(ws, kind: str, target_id: int) -> None: # type: ignore[no-un
|
||||
/ f"{stamp}-{_transcript_name(current_user.username)}-{_transcript_name(target.label)}.log"
|
||||
)
|
||||
|
||||
_send(ws, "info", f"Ziel: {target.label} ({target.host}:{target.port})")
|
||||
try:
|
||||
username, password = inventory.credential_secret(
|
||||
conn, extension.keystore, target.credential_id
|
||||
@@ -245,6 +292,7 @@ def _handle_socket(ws, kind: str, target_id: int) -> None: # type: ignore[no-un
|
||||
except inventory.ValidationError as exc:
|
||||
_send(ws, "error", str(exc))
|
||||
return
|
||||
_send(ws, "info", f"Anmeldung als {username} …")
|
||||
|
||||
with extension.database.transaction(conn):
|
||||
audit.log(
|
||||
@@ -278,8 +326,8 @@ def _handle_socket(ws, kind: str, target_id: int) -> None: # type: ignore[no-un
|
||||
_send(
|
||||
ws,
|
||||
"error",
|
||||
f"{exc} Die Konsole akzeptiert keine unbekannten Schluessel -- bitte auf der "
|
||||
f"{page} ausdruecklich freigeben.",
|
||||
f"{exc} Die Konsole akzeptiert keine unbekannten Schlüssel – bitte auf der "
|
||||
f"{page} ausdrücklich freigeben.",
|
||||
)
|
||||
except ssh.SshError as exc:
|
||||
_send(ws, "error", str(exc))
|
||||
@@ -304,7 +352,7 @@ def _read_command(ws, idle_timeout: int) -> str | None: # type: ignore[no-untyp
|
||||
"""Naechstes Kommando, oder ``None`` wenn die Sitzung enden soll."""
|
||||
raw = ws.receive(timeout=idle_timeout)
|
||||
if raw is None:
|
||||
_send(ws, "info", "Verbindung wegen Untaetigkeit getrennt.")
|
||||
_send(ws, "info", "Verbindung wegen Untätigkeit getrennt.")
|
||||
return None
|
||||
try:
|
||||
message = json.loads(raw)
|
||||
@@ -326,6 +374,7 @@ def _run_switch_session( # type: ignore[no-untyped-def]
|
||||
ws, conn, target: Target, username: str, password: str, transcript, idle_timeout: int
|
||||
) -> int:
|
||||
commands = 0
|
||||
_send(ws, "info", "Warte auf die Eingabeaufforderung des Switches …")
|
||||
with switchcli.SwitchCli(
|
||||
conn, host=target.host, port=target.port, username=username, password=password
|
||||
) as cli:
|
||||
@@ -358,6 +407,7 @@ def _run_client_session( # type: ignore[no-untyped-def]
|
||||
ws, conn, target: Target, username: str, password: str, transcript, idle_timeout: int
|
||||
) -> int:
|
||||
commands = 0
|
||||
_send(ws, "info", "Verbindung wird aufgebaut …")
|
||||
with shellcli.ShellSession(
|
||||
conn, host=target.host, port=target.port, username=username, password=password
|
||||
) as session:
|
||||
@@ -392,6 +442,6 @@ def _run_client_session( # type: ignore[no-untyped-def]
|
||||
_send(
|
||||
ws,
|
||||
"error",
|
||||
f"Rueckgabewert {result['status']} nach {result['duration_ms']} ms.",
|
||||
f"Rückgabewert {result['status']} nach {result['duration_ms']} ms.",
|
||||
)
|
||||
return commands
|
||||
+215
-25
@@ -39,7 +39,7 @@ def cmd_create_admin(args: argparse.Namespace) -> int:
|
||||
if not password:
|
||||
password = getpass.getpass("Passwort: ")
|
||||
if password != getpass.getpass("Passwort wiederholen: "):
|
||||
print("Die Passwoerter stimmen nicht ueberein.", file=sys.stderr)
|
||||
print("Die Passwörter stimmen nicht überein.", file=sys.stderr)
|
||||
return 2
|
||||
|
||||
problems = extension.password_policy.check(password, username=username)
|
||||
@@ -69,13 +69,41 @@ def cmd_create_admin(args: argparse.Namespace) -> int:
|
||||
)
|
||||
user_id = int(cursor.lastrowid or 0)
|
||||
action = "angelegt"
|
||||
# Der Vollzugriff gehoert dazu. Seit 2.8.0 gibt das
|
||||
# Administratorkennzeichen den Verwaltungsbereich und sonst
|
||||
# nichts; Funktionsrechte kommen ueber Gruppen. Ein Konto, das
|
||||
# jemand an der Kommandozeile anlegt, ist aber der Notausgang --
|
||||
# es entsteht, wenn sonst niemand mehr hineinkommt, und dann
|
||||
# nuetzt eine Verwaltung ohne Geraete, Freigaben und Protokolle
|
||||
# wenig. Wer das nicht will, nimmt die Gruppe in einem Klick
|
||||
# wieder weg.
|
||||
from .permissions import FUNKTIONEN_VOLLZUGRIFF, PERMISSIONS
|
||||
|
||||
gruppe = rbac.funktionsgruppe_anlegen(
|
||||
conn, PERMISSIONS, FUNKTIONEN_VOLLZUGRIFF
|
||||
)
|
||||
# Liegt sie im Papierkorb, kommt sie zurueck. Sonst haette dieser
|
||||
# Aufruf nichts bewirkt: das Konto waere in einer geloeschten
|
||||
# Gruppe -- und das faellt genau dann auf, wenn man es am
|
||||
# wenigsten gebrauchen kann.
|
||||
conn.execute(
|
||||
"UPDATE groups SET deleted_at=NULL WHERE id=? AND deleted_at IS NOT NULL",
|
||||
(gruppe,),
|
||||
)
|
||||
conn.execute(
|
||||
"INSERT OR IGNORE INTO user_groups (user_id, group_id) VALUES (?,?)",
|
||||
(user_id, gruppe),
|
||||
)
|
||||
audit.log(
|
||||
conn,
|
||||
actor="cli",
|
||||
actor_id=user_id,
|
||||
action="user.admin_created",
|
||||
target=username,
|
||||
detail=f"ueber die Kommandozeile {action}",
|
||||
detail=(
|
||||
f"über die Kommandozeile {action}, Gruppe "
|
||||
f"{FUNKTIONEN_VOLLZUGRIFF.name} zugewiesen"
|
||||
),
|
||||
severity="warning",
|
||||
)
|
||||
print(f"Administrator {username!r} {action}.")
|
||||
@@ -133,7 +161,7 @@ def cmd_status(args: argparse.Namespace) -> int:
|
||||
print(
|
||||
"Audit-Kette : "
|
||||
+ ("unversehrt" if chain["ok"] else f"GEBROCHEN bei Eintrag {chain['broken_at']}")
|
||||
+ f" ({chain['checked']} geprueft)"
|
||||
+ f" ({chain['checked']} geprüft)"
|
||||
)
|
||||
for key, value in counts.items():
|
||||
print(f"{key.capitalize():<15}: {value}")
|
||||
@@ -168,9 +196,30 @@ def cmd_housekeeping(_args: argparse.Namespace) -> int:
|
||||
return 0
|
||||
|
||||
|
||||
def cmd_restore_signatures(_args: argparse.Namespace) -> int:
|
||||
"""Traegt Unterschriften aus dem Journal wieder in die Datenbank ein.
|
||||
|
||||
Gebraucht nach einer Wiederherstellung: die Freigabe traegt die Dateien
|
||||
von gestern, die zurueckgespielte Datenbank ist von vorgestern -- die
|
||||
Unterschriften dazwischen stehen dann nur noch im Journal neben dem
|
||||
Protokollarchiv.
|
||||
"""
|
||||
app, extension = _app_and_core()
|
||||
from .services import fileshare
|
||||
|
||||
with app.app_context(), extension.database.session() as conn:
|
||||
with extension.database.transaction(conn):
|
||||
uebernommen = fileshare.signaturen_aus_journal(conn)
|
||||
gesamt = int(
|
||||
conn.execute("SELECT COUNT(*) FROM document_signatures").fetchone()[0]
|
||||
)
|
||||
print(f"{uebernommen} Unterschrift(en) übernommen, {gesamt} insgesamt.")
|
||||
return 0
|
||||
|
||||
|
||||
def cmd_check_now(_args: argparse.Namespace) -> int:
|
||||
app, extension = _app_and_core()
|
||||
from .services import inventory, monitor
|
||||
from .services import inventory, monitor, vnc
|
||||
|
||||
with app.app_context():
|
||||
allowed = extension.license is None or extension.license.feature_allowed(
|
||||
@@ -181,9 +230,10 @@ def cmd_check_now(_args: argparse.Namespace) -> int:
|
||||
live=extension.registry["live_log"],
|
||||
keystore=extension.keystore,
|
||||
license_allows_auto_restart=allowed,
|
||||
vnc_port=vnc.port_aus_einstellungen(extension),
|
||||
)
|
||||
print(
|
||||
f"{report.checked} geprueft, {report.online} online, {report.offline} offline, "
|
||||
f"{report.checked} geprüft, {report.online} online, {report.offline} offline, "
|
||||
f"{report.restarts_triggered} Neustarts ({report.restarts_ok} erfolgreich), "
|
||||
f"{report.duration_ms} ms"
|
||||
)
|
||||
@@ -195,6 +245,123 @@ def cmd_web_setup(args: argparse.Namespace) -> int:
|
||||
return run_web_setup(app, extension, args)
|
||||
|
||||
|
||||
def cmd_switch_test(args: argparse.Namespace) -> int:
|
||||
"""Prueft Stufe fuer Stufe, wo eine Switch-Sitzung haengt.
|
||||
|
||||
Warum ein eigener Befehl und nicht die Konsole im Browser: dort liegen
|
||||
zwischen dem Switch und dem Auge ein WebSocket, ein Reverse Proxy und
|
||||
JavaScript. Wenn nichts ankommt, ist die erste Frage, *welche* dieser
|
||||
Schichten schweigt. Hier gibt es keine davon -- und jede Stufe sagt, was
|
||||
sie bekommen hat, notfalls als rohe Bytes.
|
||||
|
||||
Liest nur, ausser mit ``--zyklus``: dann wird der Port wirklich
|
||||
geschaltet.
|
||||
"""
|
||||
from .services import inventory, switchcli
|
||||
from .services.ssh import SshError
|
||||
from tesm_core.db import Database
|
||||
from tesm_core.extension import core
|
||||
|
||||
app = create_app()
|
||||
erweiterung = core(app)
|
||||
|
||||
def sagen(stufe: str, text: str = "") -> None:
|
||||
print(f" {stufe:<28} {text}".rstrip())
|
||||
|
||||
with app.app_context():
|
||||
with erweiterung.database.session() as conn:
|
||||
zeile = Database.one(
|
||||
conn,
|
||||
"SELECT id, hostname, ip, ssh_port, credential_id FROM switches "
|
||||
"WHERE (hostname=? COLLATE NOCASE OR ip=?) AND deleted_at IS NULL",
|
||||
(args.switch, args.switch),
|
||||
)
|
||||
if zeile is None:
|
||||
print(f"Kein Switch mit Name oder Adresse {args.switch!r}.")
|
||||
print("Vorhanden:")
|
||||
for andere in Database.all(
|
||||
conn, "SELECT hostname, ip FROM switches WHERE deleted_at IS NULL"
|
||||
):
|
||||
print(f" {andere['hostname']} ({andere['ip']})")
|
||||
return 2
|
||||
|
||||
host = str(zeile["ip"])
|
||||
ssh_port = int(zeile["ssh_port"] or 22)
|
||||
print(f"Switch {zeile['hostname']} ({host}:{ssh_port})")
|
||||
print()
|
||||
|
||||
if not zeile["credential_id"]:
|
||||
sagen("Zugangsdaten", "FEHLT -- dem Switch sind keine zugeordnet")
|
||||
return 1
|
||||
try:
|
||||
benutzer, passwort = inventory.credential_secret(
|
||||
conn, erweiterung.keystore, int(zeile["credential_id"])
|
||||
)
|
||||
except Exception as fehler: # noqa: BLE001
|
||||
sagen("Zugangsdaten", f"nicht lesbar: {fehler}")
|
||||
return 1
|
||||
sagen("Zugangsdaten", f"{benutzer} (Passwort {len(passwort)} Zeichen)")
|
||||
|
||||
from .services import ssh as ssh_modul
|
||||
|
||||
for record in ssh_modul.all_known_keys(conn, host, ssh_port):
|
||||
sagen("Freigegebener Schluessel", f"{record.key_type} {record.fingerprint}")
|
||||
|
||||
try:
|
||||
sitzung = switchcli.SwitchCli(
|
||||
conn,
|
||||
host=host,
|
||||
port=ssh_port,
|
||||
username=benutzer,
|
||||
password=passwort,
|
||||
)
|
||||
with sitzung as cli:
|
||||
sagen("Anmeldung", "erfolgreich")
|
||||
erstes = cli.full_transcript
|
||||
sagen("Erste Antwort", f"{len(erstes)} Zeichen")
|
||||
print()
|
||||
print("--- was der Switch nach der Anmeldung geschickt hat ---")
|
||||
print(erstes if erstes.strip() else "(nichts)")
|
||||
print("--- Ende ---")
|
||||
print()
|
||||
|
||||
if args.port:
|
||||
ausgabe = cli.send(
|
||||
f"show power-over-ethernet {args.port}", tolerate_error=True
|
||||
)
|
||||
sagen("Zustandsabfrage", f"{len(ausgabe)} Zeichen")
|
||||
print("--- show power-over-ethernet ---")
|
||||
print(ausgabe)
|
||||
print("--- Ende ---")
|
||||
sagen("Erkannter Zustand", cli.poe_state(args.port))
|
||||
|
||||
if args.zyklus:
|
||||
if not args.port:
|
||||
print("Fuer --zyklus braucht es --port.")
|
||||
return 2
|
||||
ergebnis = cli.cycle_poe(args.port)
|
||||
sagen("Portzyklus", "erfolgreich" if ergebnis.ok else ergebnis.error)
|
||||
for schritt in ergebnis.steps:
|
||||
sagen("", schritt)
|
||||
if not ergebnis.ok:
|
||||
return 1
|
||||
except SshError as fehler:
|
||||
sagen("FEHLGESCHLAGEN", str(fehler))
|
||||
# Gerade dann sind die Bytes interessant: "kein Prompt" kann
|
||||
# heissen, dass nichts kam -- oder dass etwas kam, das nicht
|
||||
# erkannt wurde. Das sieht man nur im Rohtext, und
|
||||
# Steuerzeichen sieht man nur als repr().
|
||||
roh = "".join(sitzung.transcript)
|
||||
print()
|
||||
print("--- rohe Antwort des Switches (mit Steuerzeichen) ---")
|
||||
print(repr(roh) if roh else "(nichts)")
|
||||
print("--- Ende ---")
|
||||
return 1
|
||||
print()
|
||||
print("Fertig.")
|
||||
return 0
|
||||
|
||||
|
||||
def cmd_convert_legacy(args: argparse.Namespace) -> int:
|
||||
"""Uebersetzt einen Export des alten TESM in das neue Format.
|
||||
|
||||
@@ -214,7 +381,7 @@ def cmd_convert_legacy(args: argparse.Namespace) -> int:
|
||||
return 2
|
||||
ziel = Path(args.ausgabe) if args.ausgabe else quelle.with_name(f"{quelle.stem}-neu.json")
|
||||
if ziel.exists() and not args.force:
|
||||
print(f"{ziel} existiert bereits. Mit --force ueberschreiben.")
|
||||
print(f"{ziel} existiert bereits. Mit --force überschreiben.")
|
||||
return 2
|
||||
|
||||
alt_pass = args.passphrase or getpass("Passphrase der alten Datei: ")
|
||||
@@ -231,7 +398,7 @@ def cmd_convert_legacy(args: argparse.Namespace) -> int:
|
||||
print("--dry-run: keine Datei geschrieben.")
|
||||
return 0
|
||||
|
||||
neu_pass = args.neue_passphrase or getpass("Passphrase fuer die neue Datei: ")
|
||||
neu_pass = args.neue_passphrase or getpass("Passphrase für die neue Datei: ")
|
||||
try:
|
||||
ziel.write_text(transfer.seal(nutzlast, neu_pass), encoding="utf-8", newline="\n")
|
||||
except Exception as exc: # noqa: BLE001 - Passphrase zu kurz, Pfad nicht schreibbar
|
||||
@@ -265,10 +432,10 @@ def cmd_legacy_export(args: argparse.Namespace) -> int:
|
||||
quelle = Path(args.source)
|
||||
ziel = Path(args.out)
|
||||
if ziel.exists() and not args.force:
|
||||
print(f"{ziel} existiert bereits. Mit --force ueberschreiben.")
|
||||
print(f"{ziel} existiert bereits. Mit --force überschreiben.")
|
||||
return 2
|
||||
|
||||
passphrase = args.passphrase or getpass("Passphrase fuer die Exportdatei: ")
|
||||
passphrase = args.passphrase or getpass("Passphrase für die Exportdatei: ")
|
||||
try:
|
||||
nutzlast, bericht = legacy.read_installation(quelle)
|
||||
inhalt = legacy.seal_legacy(nutzlast, passphrase)
|
||||
@@ -354,7 +521,7 @@ def cmd_legacy_import(args: argparse.Namespace) -> int:
|
||||
action="backup.imported",
|
||||
detail="; ".join(
|
||||
f"{key}: {zahlen['created']} neu, {zahlen['updated']} aktualisiert, "
|
||||
f"{zahlen['skipped']} uebersprungen"
|
||||
f"{zahlen['skipped']} übersprungen"
|
||||
for key, zahlen in bericht.items()
|
||||
)
|
||||
+ " (Kommandozeile)",
|
||||
@@ -364,19 +531,20 @@ def cmd_legacy_import(args: argparse.Namespace) -> int:
|
||||
for key, zahlen in sorted(bericht.items()):
|
||||
print(
|
||||
f" {key}: {zahlen['created']} neu, {zahlen['updated']} aktualisiert, "
|
||||
f"{zahlen['skipped']} uebersprungen"
|
||||
f"{zahlen['skipped']} übersprungen"
|
||||
)
|
||||
return 0
|
||||
|
||||
|
||||
def build_parser() -> argparse.ArgumentParser:
|
||||
parser = argparse.ArgumentParser(
|
||||
prog="tesm-admin", description="Verwaltungswerkzeuge fuer TESM"
|
||||
prog="tesm-admin", description="Verwaltungswerkzeuge für TESM"
|
||||
)
|
||||
sub = parser.add_subparsers(dest="command", required=True)
|
||||
|
||||
admin = sub.add_parser("create-admin", help="Administratorkonto anlegen oder zuruecksetzen")
|
||||
admin = sub.add_parser("create-admin", help="Administratorkonto anlegen oder zurücksetzen")
|
||||
admin.add_argument("--username")
|
||||
admin.add_argument("--password", help="Nur fuer automatisierte Installationen")
|
||||
admin.add_argument("--password", help="Nur für automatisierte Installationen")
|
||||
admin.add_argument("--force", action="store_true", help="Passwortregeln ignorieren")
|
||||
admin.set_defaults(func=cmd_create_admin)
|
||||
|
||||
@@ -384,18 +552,40 @@ def build_parser() -> argparse.ArgumentParser:
|
||||
status.add_argument("--json", action="store_true")
|
||||
status.set_defaults(func=cmd_status)
|
||||
|
||||
house = sub.add_parser("housekeeping", help="Sitzungen, Ratenereignisse, Protokolle aufraeumen")
|
||||
house = sub.add_parser("housekeeping", help="Sitzungen, Ratenereignisse, Protokolle aufräumen")
|
||||
house.set_defaults(func=cmd_housekeeping)
|
||||
|
||||
check = sub.add_parser("check-now", help="Einen Ueberwachungsdurchlauf sofort ausfuehren")
|
||||
restore = sub.add_parser(
|
||||
"restore-signatures",
|
||||
help="Unterschriften aus dem Journal neben dem Protokollarchiv übernehmen",
|
||||
)
|
||||
restore.set_defaults(func=cmd_restore_signatures)
|
||||
|
||||
check = sub.add_parser("check-now", help="Einen Überwachungsdurchlauf sofort ausführen")
|
||||
check.set_defaults(func=cmd_check_now)
|
||||
|
||||
pruefung = sub.add_parser(
|
||||
"switch-test",
|
||||
help="Eine Switch-Sitzung Stufe fuer Stufe pruefen",
|
||||
description=(
|
||||
"Meldet sich am Switch an und zeigt, was er schickt -- ohne Browser, "
|
||||
"ohne WebSocket, ohne Reverse Proxy. Liest nur; mit --zyklus wird der "
|
||||
"Port wirklich geschaltet."
|
||||
),
|
||||
)
|
||||
pruefung.add_argument("switch", help="Name oder IP des Switches, wie in TESM hinterlegt")
|
||||
pruefung.add_argument("--port", help="Portbezeichnung, z. B. 1/8 -- fragt den Zustand ab")
|
||||
pruefung.add_argument(
|
||||
"--zyklus", action="store_true", help="PoE wirklich aus- und wieder einschalten"
|
||||
)
|
||||
pruefung.set_defaults(func=cmd_switch_test)
|
||||
|
||||
legacy = sub.add_parser(
|
||||
"convert-legacy",
|
||||
help="Export des alten TESM in das neue Format uebersetzen",
|
||||
help="Export des alten TESM in das neue Format übersetzen",
|
||||
description=(
|
||||
"Liest eine Exportdatei des Vorgaengers und schreibt eine Datei im "
|
||||
"Format dieser Anwendung. Uebersetzt werden Clients, Switche, "
|
||||
"Liest eine Exportdatei des Vorgängers und schreibt eine Datei im "
|
||||
"Format dieser Anwendung. Übersetzt werden Clients, Switche, "
|
||||
"Zugangsdaten und die DHCP-Konfiguration."
|
||||
),
|
||||
)
|
||||
@@ -410,7 +600,7 @@ def build_parser() -> argparse.ArgumentParser:
|
||||
legacy.add_argument(
|
||||
"--dry-run", action="store_true", help="Nur den Bericht zeigen, nichts schreiben"
|
||||
)
|
||||
legacy.add_argument("--force", action="store_true", help="Zieldatei ueberschreiben")
|
||||
legacy.add_argument("--force", action="store_true", help="Zieldatei überschreiben")
|
||||
legacy.set_defaults(func=cmd_convert_legacy)
|
||||
|
||||
|
||||
@@ -418,10 +608,10 @@ def build_parser() -> argparse.ArgumentParser:
|
||||
"legacy-export",
|
||||
help="Daten einer alten Installation als Exportdatei sichern",
|
||||
description=(
|
||||
"Liest die Datenbank einer Installation der Vorgaengerfassung und schreibt "
|
||||
"Liest die Datenbank einer Installation der Vorgängerfassung und schreibt "
|
||||
"Clients, Switche, Zugangsdaten und DHCP in eine Datei im alten Format. "
|
||||
"Die alte Installation wird nur gelesen. Ohne Browser und auch dann noch "
|
||||
"moeglich, wenn die alte Anwendung nicht mehr startet."
|
||||
"möglich, wenn die alte Anwendung nicht mehr startet."
|
||||
),
|
||||
)
|
||||
export_alt.add_argument(
|
||||
@@ -430,16 +620,16 @@ def build_parser() -> argparse.ArgumentParser:
|
||||
export_alt.add_argument("--out", required=True, help="Zieldatei")
|
||||
export_alt.add_argument("--passphrase", help="Passphrase der Zieldatei (sonst wird gefragt)")
|
||||
export_alt.add_argument("--dry-run", action="store_true", help="Nur den Bericht zeigen")
|
||||
export_alt.add_argument("--force", action="store_true", help="Zieldatei ueberschreiben")
|
||||
export_alt.add_argument("--force", action="store_true", help="Zieldatei überschreiben")
|
||||
export_alt.set_defaults(func=cmd_legacy_export)
|
||||
|
||||
import_neu = sub.add_parser(
|
||||
"legacy-import",
|
||||
help="Datei im Format dieser Anwendung einlesen",
|
||||
description=(
|
||||
"Liest eine Sicherungs- oder uebersetzte Datei ein -- derselbe Weg wie unter "
|
||||
"Liest eine Sicherungs- oder übersetzte Datei ein – derselbe Weg wie unter "
|
||||
"Verwaltung -> Sicherung, nur ohne Browser. Ohne --categories werden alle "
|
||||
"Kategorien uebernommen, die in der Datei stehen."
|
||||
"Kategorien übernommen, die in der Datei stehen."
|
||||
),
|
||||
)
|
||||
import_neu.add_argument("datei", help="Datei im Format dieser Anwendung")
|
||||
|
||||
@@ -13,6 +13,8 @@ from typing import Any
|
||||
from tesm_core.extension import core
|
||||
from tesm_core.nav import NavItem, NavTree
|
||||
|
||||
from .permissions import DHCP_PAGE, SERVICING_PAGE
|
||||
|
||||
|
||||
def _module(name: str):
|
||||
"""Bedingung: Vollmodul lizenziert?"""
|
||||
@@ -36,7 +38,7 @@ def _has_fileshare_mounts(user: Any) -> bool:
|
||||
return False
|
||||
if not _module("fileshare")(user):
|
||||
return False
|
||||
if user.has("fileshare.edit"):
|
||||
if user.has("fileshare.write"):
|
||||
return True
|
||||
from .services import fileshare
|
||||
|
||||
@@ -45,10 +47,10 @@ def _has_fileshare_mounts(user: Any) -> bool:
|
||||
|
||||
NAVIGATION = NavTree(
|
||||
[
|
||||
NavItem(key="index", label="Statusuebersicht", icon="grid", endpoint="dashboard.index"),
|
||||
NavItem(key="index", label="Statusübersicht", icon="grid", endpoint="dashboard.index"),
|
||||
NavItem(
|
||||
key="monitoring",
|
||||
label="Ueberwachung",
|
||||
label="Überwachung",
|
||||
icon="cpu",
|
||||
children=(
|
||||
NavItem(
|
||||
@@ -56,45 +58,45 @@ NAVIGATION = NavTree(
|
||||
label="Clients",
|
||||
icon="cpu",
|
||||
endpoint="devices.overview",
|
||||
permissions=("devices.view",),
|
||||
permissions=("devices.read",),
|
||||
),
|
||||
NavItem(
|
||||
key="switches",
|
||||
label="Switche",
|
||||
icon="switch",
|
||||
endpoint="switches.overview",
|
||||
permissions=("switches.view",),
|
||||
permissions=("switches.read",),
|
||||
),
|
||||
NavItem(
|
||||
key="credentials",
|
||||
label="Zugangsdaten",
|
||||
icon="key",
|
||||
endpoint="credentials.overview",
|
||||
permissions=("credentials.view",),
|
||||
permissions=("credentials.read",),
|
||||
),
|
||||
NavItem(
|
||||
key="hostkeys",
|
||||
label="Host-Schluessel",
|
||||
label="Host-Schlüssel",
|
||||
icon="shield",
|
||||
endpoint="hostkeys.overview",
|
||||
permissions=("devices.view", "switches.view"),
|
||||
),
|
||||
NavItem(
|
||||
key="maintenance",
|
||||
label="Wartung",
|
||||
icon="wrench",
|
||||
endpoint="maintenance.overview",
|
||||
permissions=("maintenance.view", "maintenance.execute"),
|
||||
condition=_module("maintenance"),
|
||||
permissions=("devices.read", "switches.read"),
|
||||
),
|
||||
),
|
||||
),
|
||||
NavItem(
|
||||
key="maintenance",
|
||||
label="Wartung",
|
||||
icon="wrench",
|
||||
endpoint="maintenance.overview",
|
||||
permissions=SERVICING_PAGE,
|
||||
condition=_module("maintenance"),
|
||||
),
|
||||
NavItem(
|
||||
key="dhcp",
|
||||
label="DHCP-Server",
|
||||
icon="network",
|
||||
endpoint="dhcp.overview",
|
||||
permissions=("dhcp.view", "dhcp.edit", "dhcp.execute"),
|
||||
permissions=DHCP_PAGE,
|
||||
condition=_module("dhcp"),
|
||||
),
|
||||
NavItem(
|
||||
@@ -108,14 +110,14 @@ NAVIGATION = NavTree(
|
||||
label="Meine Freigaben",
|
||||
icon="folder",
|
||||
endpoint="fileshare.browse",
|
||||
permissions=("fileshare.view",),
|
||||
permissions=("fileshare.read",),
|
||||
),
|
||||
NavItem(
|
||||
key="fileshare_mappings",
|
||||
label="Zuordnungen",
|
||||
icon="link",
|
||||
endpoint="fileshare.mappings",
|
||||
permissions=("fileshare.edit",),
|
||||
permissions=("fileshare.write",),
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -126,31 +128,31 @@ NAVIGATION = NavTree(
|
||||
children=(
|
||||
NavItem(
|
||||
key="logs_live",
|
||||
label="Laufend",
|
||||
label="Live",
|
||||
icon="terminal",
|
||||
endpoint="logs.live",
|
||||
permissions=("logs_live.view",),
|
||||
permissions=("logs_live.read",),
|
||||
),
|
||||
NavItem(
|
||||
key="logs_history",
|
||||
label="Verlauf",
|
||||
icon="clock",
|
||||
endpoint="logs.history",
|
||||
permissions=("logs_history.view", "logs_history.export"),
|
||||
permissions=("logs_history.read", "logs_history.export"),
|
||||
),
|
||||
NavItem(
|
||||
key="logs_audit",
|
||||
label="Audit",
|
||||
icon="history",
|
||||
endpoint="logs.audit_view",
|
||||
permissions=("logs_audit.view", "logs_audit.export"),
|
||||
permissions=("logs_audit.read", "logs_audit.export"),
|
||||
),
|
||||
NavItem(
|
||||
key="logs_dhcp",
|
||||
label="Kea-DHCP",
|
||||
label="DHCP",
|
||||
icon="network",
|
||||
endpoint="tesmlogs.dhcp",
|
||||
permissions=("logs_dhcp.view",),
|
||||
permissions=("logs_dhcp.read",),
|
||||
condition=_module("dhcp"),
|
||||
),
|
||||
NavItem(
|
||||
@@ -158,7 +160,7 @@ NAVIGATION = NavTree(
|
||||
label="Neustarts",
|
||||
icon="power",
|
||||
endpoint="tesmlogs.restarts",
|
||||
permissions=("devices.view",),
|
||||
permissions=("devices.read",),
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -172,42 +174,42 @@ NAVIGATION = NavTree(
|
||||
label="Benutzer",
|
||||
icon="users",
|
||||
endpoint="admin.users",
|
||||
permissions=("users.view",),
|
||||
permissions=("users.read",),
|
||||
),
|
||||
NavItem(
|
||||
key="groups",
|
||||
label="Gruppen und Rechte",
|
||||
icon="shield-users",
|
||||
endpoint="admin.groups",
|
||||
permissions=("groups.view",),
|
||||
permissions=("groups.read",),
|
||||
),
|
||||
NavItem(
|
||||
key="settings_system",
|
||||
label="System",
|
||||
icon="sliders",
|
||||
endpoint="settings.system",
|
||||
permissions=("settings_system.view",),
|
||||
permissions=("settings_system.read",),
|
||||
),
|
||||
NavItem(
|
||||
key="settings_network",
|
||||
label="Netzwerk",
|
||||
icon="network",
|
||||
endpoint="hostsettings.network",
|
||||
permissions=("settings_network.view",),
|
||||
permissions=("settings_network.read",),
|
||||
),
|
||||
NavItem(
|
||||
key="settings_web",
|
||||
label="Webserver und TLS",
|
||||
icon="server",
|
||||
endpoint="hostsettings.web",
|
||||
permissions=("settings_web.view",),
|
||||
permissions=("settings_web.read",),
|
||||
),
|
||||
NavItem(
|
||||
key="settings_directory",
|
||||
label="Verzeichnisdienst",
|
||||
icon="users",
|
||||
endpoint="hostsettings.directory_settings",
|
||||
permissions=("settings_directory.view",),
|
||||
permissions=("settings_directory.read",),
|
||||
),
|
||||
NavItem(
|
||||
key="settings_license",
|
||||
@@ -216,28 +218,28 @@ NAVIGATION = NavTree(
|
||||
endpoint="license.overview",
|
||||
# Bewusst immer erreichbar, auch ohne gueltige Lizenz --
|
||||
# sonst liesse sich eine neue Lizenz nie einspielen.
|
||||
permissions=("settings_license.view",),
|
||||
permissions=("settings_license.read",),
|
||||
),
|
||||
NavItem(
|
||||
key="backup",
|
||||
label="Sicherung",
|
||||
icon="transfer",
|
||||
endpoint="backup.overview",
|
||||
permissions=("backup.view", "backup.export", "backup.execute"),
|
||||
permissions=("backup_export.read", "backup_import.read"),
|
||||
),
|
||||
NavItem(
|
||||
key="trash",
|
||||
label="Papierkorb",
|
||||
icon="trash",
|
||||
endpoint="trash.overview",
|
||||
permissions=("trash.view",),
|
||||
permissions=("trash.read",),
|
||||
),
|
||||
NavItem(
|
||||
key="diagnostics",
|
||||
label="Diagnose",
|
||||
icon="info",
|
||||
endpoint="diagnostics.overview",
|
||||
permissions=("diagnostics.view",),
|
||||
permissions=("diagnostics.read",),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -30,101 +30,402 @@ A = Action
|
||||
DEVICES = Resource(
|
||||
key="devices",
|
||||
label="Clients",
|
||||
actions=(A.VIEW, A.CREATE, A.EDIT, A.DELETE, A.EXECUTE),
|
||||
hint="Ueberwachte PoE-Endgeraete",
|
||||
actions=(A.READ, A.CREATE, A.WRITE, A.DELETE),
|
||||
hint="Stammdaten überwachter PoE-Endgeräte: Name, MAC, IP, Switch und Port",
|
||||
action_hints={
|
||||
A.EXECUTE: "PoE-Neustart eines Geraets manuell ausloesen. Der automatische "
|
||||
"Neustart bei Ausfall ist davon unabhaengig und laeuft immer.",
|
||||
A.DELETE: "Geraet in den Papierkorb legen.",
|
||||
A.DELETE: "Gerät in den Papierkorb legen.",
|
||||
},
|
||||
)
|
||||
|
||||
#: Drei Wege, drei Rechte -- bisher hingen alle an ``devices.execute``.
|
||||
#:
|
||||
#: Wer jemandem den PoE-Neustart erlauben wollte (der Weg, der auch bei einem
|
||||
#: haengenden Geraet noch wirkt und nichts Laufendes unterbricht), erlaubte ihm
|
||||
#: damit zugleich, ein arbeitendes System ueber SSH herunterzufahren. Das sind
|
||||
#: drei verschiedene Eingriffe mit drei verschiedenen Folgen -- also drei
|
||||
#: Zeilen. Die Reihenfolge ist die der Rangfolge in ``services.restart``.
|
||||
DEVICE_RESTART_POE = Resource(
|
||||
key="device_restart_poe",
|
||||
parent="devices",
|
||||
label="Clients: Neustart über PoE",
|
||||
actions=(A.READ, A.EXECUTE),
|
||||
hint="Portzyklus am Switch – wirkt in Sekunden, auch wenn das Gerät hängt",
|
||||
action_hints={
|
||||
A.READ: "Den Knopf sehen.",
|
||||
A.EXECUTE: "Neustart auslösen. Bewusst nicht lizenzpflichtig: ein hängendes "
|
||||
"Gerät soll man auch ohne gültige Lizenz von Hand neu starten können.",
|
||||
},
|
||||
)
|
||||
|
||||
DEVICE_RESTART_SSH = Resource(
|
||||
key="device_restart_ssh",
|
||||
parent="devices",
|
||||
label="Clients: Neustart über SSH",
|
||||
actions=(A.READ, A.EXECUTE),
|
||||
hint="Sauberes Herunterfahren eines Linux-Geräts (Modul Wartung)",
|
||||
action_hints={
|
||||
A.EXECUTE: "Neustartauftrag anlegen. Dauert Minuten und setzt ein "
|
||||
"antwortendes Gerät samt freigegebenem Host-Schlüssel voraus.",
|
||||
},
|
||||
)
|
||||
|
||||
DEVICE_RESTART_RPC = Resource(
|
||||
key="device_restart_rpc",
|
||||
parent="devices",
|
||||
label="Clients: Neustart über RPC",
|
||||
actions=(A.READ, A.EXECUTE),
|
||||
hint="Windows-Gerät ohne SSH und ohne Host-Schlüssel (Modul Wartung)",
|
||||
action_hints={A.EXECUTE: "Neustartauftrag anlegen (wie 'shutdown /m')."},
|
||||
)
|
||||
|
||||
#: Bildschirmzugriff ist **nicht** dasselbe wie die Konsole, auch wenn beides
|
||||
#: "auf das Geraet" fuehrt: hier sieht jemand den Bildschirm, wie er ist --
|
||||
#: samt dem, was ein Mensch dort gerade offen hat. Das ist eine eigene
|
||||
#: Entscheidung, also eine eigene Zeile. Sie wird **niemandem** mitgegeben:
|
||||
#: keine Migration setzt sie, auch nicht bei denen, die schon eine Konsole
|
||||
#: oeffnen duerfen. Wer sie haben soll, bekommt sie in der Matrix -- einmal,
|
||||
#: sichtbar, mit Zeile im Aenderungsprotokoll.
|
||||
DEVICE_VNC = Resource(
|
||||
key="device_vnc",
|
||||
parent="devices",
|
||||
label="Clients: Bildschirm (VNC)",
|
||||
actions=(A.READ, A.EXECUTE),
|
||||
hint="Verbindungsdatei bzw. vnc://-Adresse für den Bildschirm des Clients",
|
||||
action_hints={
|
||||
A.READ: "Den Knopf sehen, wo ein VNC-Server antwortet.",
|
||||
A.EXECUTE: "Verbindung öffnen. Die Datei enthält nur Ziel und Namen – "
|
||||
"was beim Verbinden verlangt wird, entscheidet der VNC-Server auf dem Gerät.",
|
||||
},
|
||||
)
|
||||
|
||||
#: Die Client-Konsole lief unter ``maintenance.execute`` mit -- demselben Recht
|
||||
#: wie die Paketaktualisierung. Eine Sitzung auf einem Geraet ist aber etwas
|
||||
#: anderes als ein Auftrag, den der Server abarbeitet: hier tippt ein Mensch.
|
||||
DEVICE_CONSOLE = Resource(
|
||||
key="device_console",
|
||||
parent="devices",
|
||||
label="Clients: Konsole",
|
||||
actions=(A.READ, A.EXECUTE),
|
||||
hint="Browser-Terminal auf einem Client (Modul Wartung)",
|
||||
action_hints={
|
||||
A.EXECUTE: "Sitzung öffnen. Verlangt eine frische Passwortbestätigung; "
|
||||
"jede Sitzung wird vollständig mitgeschrieben.",
|
||||
},
|
||||
)
|
||||
|
||||
SWITCHES = Resource(
|
||||
key="switches",
|
||||
label="Switche",
|
||||
actions=(A.VIEW, A.CREATE, A.EDIT, A.DELETE, A.EXECUTE),
|
||||
hint="Aruba-Switche und ihre Erreichbarkeit",
|
||||
actions=(A.READ, A.CREATE, A.WRITE, A.DELETE),
|
||||
hint="Aruba-Switche, ihre Erreichbarkeit und Ports",
|
||||
)
|
||||
|
||||
SWITCH_CONSOLE = Resource(
|
||||
key="switch_console",
|
||||
parent="switches",
|
||||
label="Switche: Konsole",
|
||||
actions=(A.READ, A.EXECUTE),
|
||||
hint="Kommandos auf dem Switch ausführen",
|
||||
action_hints={
|
||||
A.EXECUTE: "Browser-Terminal oeffnen und Kommandos auf dem Switch ausfuehren. "
|
||||
"Verlangt zusaetzlich eine frische Passwortbestaetigung; jede Sitzung wird "
|
||||
"vollstaendig protokolliert.",
|
||||
A.EXECUTE: "Sitzung öffnen. Verlangt eine frische Passwortbestätigung; "
|
||||
"jede Sitzung wird vollständig mitgeschrieben.",
|
||||
},
|
||||
)
|
||||
|
||||
#: Einen geaenderten Host-Schluessel freizugeben hing an "Gerät ändern" bzw.
|
||||
#: "Switch ändern" -- also an einem Recht fuer Stammdaten. Es ist aber der
|
||||
#: Vorgang, mit dem man sagt: "dieses Geraet ist wirklich das, fuer das es sich
|
||||
#: ausgibt". Wer Namen und Standort pflegt, muss das nicht entscheiden duerfen.
|
||||
HOSTKEYS = Resource(
|
||||
key="hostkeys",
|
||||
label="Host-Schlüssel",
|
||||
actions=(A.READ, A.EXECUTE),
|
||||
hint="Ein Gerät meldet sich mit einem anderen SSH-Schlüssel als beim letzten Mal",
|
||||
action_hints={
|
||||
A.READ: "Offene und freigegebene Schlüssel sehen.",
|
||||
A.EXECUTE: "Einen geänderten Schlüssel freigeben.",
|
||||
},
|
||||
)
|
||||
|
||||
CREDENTIALS = Resource(
|
||||
key="credentials",
|
||||
label="Zugangsdaten",
|
||||
actions=(A.VIEW, A.CREATE, A.EDIT, A.DELETE, A.SECRETS),
|
||||
actions=(A.READ, A.CREATE, A.WRITE, A.DELETE, A.SECRETS),
|
||||
hint="Wiederverwendbare SSH-Logins",
|
||||
action_hints={
|
||||
A.VIEW: "Namen und Benutzer sehen -- nicht das Passwort.",
|
||||
A.READ: "Namen und Benutzer sehen – nicht das Passwort.",
|
||||
A.SECRETS: "Passwort im Klartext anzeigen. Verlangt eine frische "
|
||||
"Passwortbestaetigung und wird protokolliert.",
|
||||
"Passwortbestätigung und wird protokolliert.",
|
||||
},
|
||||
)
|
||||
|
||||
MAINTENANCE = Resource(
|
||||
key="maintenance",
|
||||
label="Wartung",
|
||||
actions=(A.VIEW, A.EXECUTE),
|
||||
hint="Paketaktualisierung und Neustart per SSH (lizenzpflichtiges Modul)",
|
||||
action_hints={A.EXECUTE: "Aktualisierung oder Neustart auf ausgewaehlten Geraeten starten."},
|
||||
#: Aus einer Zeile ("Aktualisierung und Neustart") wurden drei. Sie deckte
|
||||
#: vier Funktionen ab: Pakete aktualisieren, reihenweise neu starten, das
|
||||
#: Windows-Werkzeug installieren -- und die Client-Konsole, die jetzt bei den
|
||||
#: Clients steht, wo sie hingehoert.
|
||||
SERVICING_UPDATE = Resource(
|
||||
key="servicing_update",
|
||||
label="Paketaktualisierung",
|
||||
actions=(A.READ, A.EXECUTE),
|
||||
hint="Pakete auf ausgewählten Geräten aktualisieren (lizenzpflichtiges Modul)",
|
||||
action_hints={A.EXECUTE: "Auftrag starten."},
|
||||
)
|
||||
|
||||
DHCP = Resource(
|
||||
key="dhcp",
|
||||
label="DHCP-Server",
|
||||
actions=(A.VIEW, A.EDIT, A.EXECUTE),
|
||||
hint="Kea-DHCP: Subnetze, Optionen, Reservierungen (lizenzpflichtiges Modul)",
|
||||
SERVICING_REBOOT = Resource(
|
||||
key="servicing_reboot",
|
||||
label="Neustart als Auftrag",
|
||||
actions=(A.READ, A.EXECUTE),
|
||||
hint="Reihenweise Neustarts mit Wartezeit und Rückmeldung",
|
||||
action_hints={A.EXECUTE: "Auftrag starten."},
|
||||
)
|
||||
|
||||
SERVICING_TOOL = Resource(
|
||||
key="servicing_tool",
|
||||
label="Windows-Werkzeug installieren",
|
||||
actions=(A.READ, A.EXECUTE),
|
||||
hint="Voraussetzung für den RPC-Weg auf einem Windows-Gerät",
|
||||
action_hints={A.EXECUTE: "Installation auslösen."},
|
||||
)
|
||||
|
||||
#: Ein freier Befehl auf vielen Geraeten ist das Maechtigste, was diese
|
||||
#: Anwendung kann -- und das Einzige, dessen Wirkung sie nicht kennt. Deshalb
|
||||
#: ein eigenes Recht, das niemand nebenbei bekommt: wer Pakete aktualisieren
|
||||
#: oder neu starten darf, tut etwas Umrissenes; wer hier etwas eintippt, tut
|
||||
#: alles, was ein Konto auf dem Geraet tun kann. Die Vorlagen nehmen dem den
|
||||
#: Alltag ab, nicht die Verantwortung.
|
||||
SERVICING_COMMAND = Resource(
|
||||
key="servicing_command",
|
||||
label="Befehl auf mehreren Geräten",
|
||||
actions=(A.READ, A.EXECUTE),
|
||||
hint="Eine Befehlszeile über SSH, auf allen ausgewählten Clients",
|
||||
action_hints={
|
||||
A.EDIT: "Konfiguration aendern und schreiben.",
|
||||
A.EXECUTE: "Dienst installieren, starten oder stoppen.",
|
||||
A.READ: "Die Karte sehen – und damit, was zuletzt lief.",
|
||||
A.EXECUTE: "Befehl absenden. Verlangt eine frische Passwortbestätigung; "
|
||||
"jeder Auftrag steht mit seinem Wortlaut im Änderungsprotokoll.",
|
||||
},
|
||||
)
|
||||
|
||||
#: Die Wartungsseite zeigt alle drei Aufgaben. Sie oeffnet, wer **eine** davon
|
||||
#: sehen darf -- und zeigt dann nur die. Als Konstante, damit die vier Routen
|
||||
#: dieselbe Antwort geben; einzeln getippt liefen sie irgendwann auseinander.
|
||||
SERVICING_PAGE: tuple[str, ...] = (
|
||||
"servicing_update.read",
|
||||
"servicing_reboot.read",
|
||||
"servicing_tool.read",
|
||||
"servicing_command.read",
|
||||
)
|
||||
|
||||
#: Elf Funktionen hingen an ``dhcp.edit``: Grundeinstellungen, Subnetze,
|
||||
#: Optionen, Geraeteoptionen, eigene Optionen, Reservierungen und das
|
||||
#: Schreiben der Dienstkonfiguration. Wer eine Reservierung eintragen sollte,
|
||||
#: durfte damit auch Subnetze loeschen und die Konfiguration ueberschreiben.
|
||||
DHCP_SUBNETS = Resource(
|
||||
key="dhcp_subnets",
|
||||
label="DHCP: Subnetze",
|
||||
actions=(A.READ, A.CREATE, A.WRITE, A.DELETE),
|
||||
hint="Bereiche, Gateway, DNS, Reihenfolge (lizenzpflichtiges Modul)",
|
||||
)
|
||||
|
||||
DHCP_OPTIONS = Resource(
|
||||
key="dhcp_options",
|
||||
parent="dhcp_subnets",
|
||||
label="DHCP: Optionen",
|
||||
actions=(A.READ, A.WRITE),
|
||||
hint="Globale Optionen, Optionen je Gerät und eigene Optionen",
|
||||
action_hints={A.WRITE: "Werte setzen, eigene Optionen anlegen und entfernen."},
|
||||
)
|
||||
|
||||
DHCP_RESERVATIONS = Resource(
|
||||
key="dhcp_reservations",
|
||||
parent="dhcp_subnets",
|
||||
label="DHCP: Reservierungen",
|
||||
actions=(A.READ, A.CREATE, A.WRITE, A.DELETE),
|
||||
hint="Feste Adressen zu einer MAC",
|
||||
)
|
||||
|
||||
DHCP_CONFIG = Resource(
|
||||
key="dhcp_config",
|
||||
parent="dhcp_subnets",
|
||||
label="DHCP: Konfiguration schreiben",
|
||||
actions=(A.READ, A.EXECUTE),
|
||||
hint="Den bearbeiteten Stand in die Dienstkonfiguration übernehmen",
|
||||
action_hints={
|
||||
A.EXECUTE: "Schreiben und neu laden. Bis dahin ändert sich am laufenden "
|
||||
"Dienst nichts – Bearbeiten und Wirksamwerden sind zwei Schritte.",
|
||||
},
|
||||
)
|
||||
|
||||
DHCP_SERVICE = Resource(
|
||||
key="dhcp_service",
|
||||
parent="dhcp_subnets",
|
||||
label="DHCP: Dienst",
|
||||
actions=(A.READ, A.EXECUTE),
|
||||
hint="Kea installieren, starten, stoppen",
|
||||
action_hints={A.EXECUTE: "Dienstzustand ändern."},
|
||||
)
|
||||
|
||||
#: Eine dynamische Lease in einen Client verwandeln.
|
||||
#:
|
||||
#: Eigene Zeile, weil es eine eigene Entscheidung ist: hier wird aus einer
|
||||
#: Beobachtung ("dieses Geraet hat sich eine Adresse geholt") ein gepflegter
|
||||
#: Datensatz, den die Ueberwachung ab sofort anpingt und der beim naechsten
|
||||
#: Schreiben der Konfiguration eine feste Adresse bekommt. Wer Leases sehen
|
||||
#: darf, soll deshalb nicht automatisch welche uebernehmen duerfen.
|
||||
#:
|
||||
#: **Und es reicht allein nicht.** Die Uebernahme legt einen Client an, also
|
||||
#: verlangt die Route zusaetzlich "Clients: Anlegen". Sonst waere dies eine
|
||||
#: Hintertuer an der Geraeteverwaltung vorbei -- dieselbe Wirkung, anderes
|
||||
#: Formular. Beide Haken zusammen, oder die Schaltflaeche erscheint nicht.
|
||||
DHCP_LEASE_ADOPT = Resource(
|
||||
key="dhcp_lease_adopt",
|
||||
parent="dhcp_subnets",
|
||||
label="DHCP: Lease als Client übernehmen",
|
||||
actions=(A.READ, A.EXECUTE),
|
||||
hint="Aus einer dynamisch vergebenen Adresse einen überwachten Client machen",
|
||||
action_hints={
|
||||
A.READ: "Die Schaltfläche an dynamischen Leases sehen.",
|
||||
A.EXECUTE: "Client aus der Lease anlegen. Verlangt zusätzlich "
|
||||
"'Clients: Anlegen' – es entsteht ein Gerätedatensatz.",
|
||||
},
|
||||
)
|
||||
|
||||
#: Die DHCP-Seite zeigt alle Abschnitte; jeder Abschnitt haengt an seiner
|
||||
#: eigenen Zeile. Geoeffnet wird sie, wer einen davon sehen darf.
|
||||
DHCP_PAGE: tuple[str, ...] = (
|
||||
"dhcp_subnets.read",
|
||||
"dhcp_options.read",
|
||||
"dhcp_reservations.read",
|
||||
"dhcp_config.read",
|
||||
"dhcp_service.read",
|
||||
)
|
||||
|
||||
FILESHARE = Resource(
|
||||
key="fileshare",
|
||||
label="Dateifreigaben",
|
||||
actions=(A.VIEW, A.CREATE, A.EDIT, A.DELETE),
|
||||
actions=(A.READ, A.CREATE, A.WRITE, A.DELETE, A.EXPORT),
|
||||
hint="SMB-Freigaben aus dem Verzeichnisdienst (lizenzpflichtiges Modul)",
|
||||
action_hints={
|
||||
A.CREATE: "Ordner anlegen und Dateien hochladen.",
|
||||
A.EDIT: "Umbenennen und verschieben.",
|
||||
A.WRITE: "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",
|
||||
parent="fileshare",
|
||||
label="PDF-Formulare",
|
||||
actions=(A.READ, A.CREATE, A.WRITE),
|
||||
hint="Formulare in der Vorschau ausfüllen und in der Freigabe ablegen",
|
||||
action_hints={
|
||||
A.READ: "In der Vorschau ausfüllen (ohne Speichern).",
|
||||
A.CREATE: "Ausgefüllt speichern – abgeschlossen, Felder gesperrt.",
|
||||
A.WRITE: "Ausgefüllt speichern – weiter ausfüllbar, inklusive Bild.",
|
||||
},
|
||||
)
|
||||
|
||||
#: Unterschreiben ist kein Aendern: am Ende steht eine Fassung, die niemand
|
||||
#: mehr bearbeiten kann, mit Namen und Zeitpunkt im Dokument und einer
|
||||
#: Pruefsumme im Aenderungsprotokoll. Wer Dateien umbenennen darf, soll das
|
||||
#: nicht automatisch duerfen.
|
||||
FILESHARE_SIGNATURE = Resource(
|
||||
key="fileshare_signature",
|
||||
parent="fileshare",
|
||||
label="Unterschriften",
|
||||
actions=(A.READ, A.EXECUTE),
|
||||
hint="Mit dem Finger unterschreiben; das Ergebnis ist unveränderbar",
|
||||
action_hints={
|
||||
A.READ: "Unterschriebene Dateien öffnen und die Prüfung sehen.",
|
||||
A.EXECUTE: "Unterschreiben und abgeschlossen ablegen.",
|
||||
},
|
||||
)
|
||||
|
||||
#: Welche Verzeichnisfreigabe unter welchem Namen erscheint, ist Administration
|
||||
#: und kein Dateibetrieb -- es lief unter demselben Recht wie "umbenennen".
|
||||
FILESHARE_MAPPINGS = Resource(
|
||||
key="fileshare_mappings",
|
||||
parent="fileshare",
|
||||
label="Freigabe-Zuordnungen",
|
||||
actions=(A.READ, A.CREATE, A.WRITE, A.DELETE),
|
||||
hint="Verzeichnisgruppe, UNC-Pfad und Anzeigename einer Freigabe",
|
||||
)
|
||||
|
||||
LOGS_DHCP = Resource(
|
||||
key="logs_dhcp",
|
||||
label="Kea-DHCP-Protokoll",
|
||||
actions=(A.VIEW,),
|
||||
label="DHCP-Protokoll",
|
||||
actions=(A.READ,),
|
||||
)
|
||||
|
||||
MONITORING_AREA = Area(
|
||||
key="monitoring",
|
||||
label="Ueberwachung",
|
||||
label="Überwachung",
|
||||
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.",
|
||||
resources=(DEVICES, SWITCHES, CREDENTIALS, MAINTENANCE),
|
||||
resources=(
|
||||
DEVICES,
|
||||
DEVICE_RESTART_POE,
|
||||
DEVICE_RESTART_SSH,
|
||||
DEVICE_RESTART_RPC,
|
||||
DEVICE_CONSOLE,
|
||||
DEVICE_VNC,
|
||||
SWITCHES,
|
||||
SWITCH_CONSOLE,
|
||||
HOSTKEYS,
|
||||
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=(SERVICING_UPDATE, SERVICING_REBOOT, SERVICING_COMMAND, SERVICING_TOOL),
|
||||
)
|
||||
|
||||
NETWORK_AREA = Area(
|
||||
key="network",
|
||||
label="Netzwerkdienste",
|
||||
icon="network",
|
||||
resources=(DHCP,),
|
||||
resources=(
|
||||
DHCP_SUBNETS,
|
||||
DHCP_OPTIONS,
|
||||
DHCP_RESERVATIONS,
|
||||
DHCP_LEASE_ADOPT,
|
||||
DHCP_CONFIG,
|
||||
DHCP_SERVICE,
|
||||
),
|
||||
)
|
||||
|
||||
FILES_AREA = Area(
|
||||
key="files",
|
||||
label="Dateifreigaben",
|
||||
icon="folder",
|
||||
resources=(FILESHARE,),
|
||||
resources=(FILESHARE, FILESHARE_PDF, FILESHARE_SIGNATURE, FILESHARE_MAPPINGS),
|
||||
)
|
||||
|
||||
PERMISSIONS = PermissionTree(
|
||||
[
|
||||
MONITORING_AREA,
|
||||
MAINTENANCE_AREA,
|
||||
NETWORK_AREA,
|
||||
FILES_AREA,
|
||||
observability_area(LOGS_DHCP),
|
||||
@@ -133,90 +434,80 @@ PERMISSIONS = PermissionTree(
|
||||
)
|
||||
|
||||
|
||||
#: Alle Funktionsrechte ausserhalb der Verwaltung, in einer Gruppe.
|
||||
#:
|
||||
#: Seit 2.8.0 heisst das Administratorkennzeichen "Verwaltung" und nicht
|
||||
#: "alles": Geraete, Neustarts, DHCP und Freigaben kommen ueber Gruppen. Diese
|
||||
#: Vorlage ist die Gruppe, die man sich dafuer zuweist -- und die Migration
|
||||
#: gibt sie bestehenden Administratoren, damit nach dem Update nichts fehlt,
|
||||
#: was vorher ging. Entfernen kann man sie mit einem Klick.
|
||||
#:
|
||||
#: Die Liste entsteht aus dem Baum und nicht aus Tippen: eine neue Zeile in der
|
||||
#: Matrix ist sonst genau die, die hier vergessen wird.
|
||||
FUNKTIONEN_VOLLZUGRIFF = RolePreset(
|
||||
key="functions",
|
||||
name="Funktionen (Vollzugriff)",
|
||||
description=(
|
||||
"Alle Rechte ausserhalb der Verwaltung. Gedacht fuer Administratoren, "
|
||||
"die nicht nur einstellen, sondern auch bedienen."
|
||||
),
|
||||
grants=tuple(
|
||||
(resource.key, "*")
|
||||
for area in PERMISSIONS.areas
|
||||
if area.key != PermissionTree.VERWALTUNGSBEREICH
|
||||
for resource in area.resources
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
#: Die Gruppen, die eine frische Installation mitbringt -- drei, nicht sieben.
|
||||
#:
|
||||
#: Vorher standen hier zusaetzlich Betrieb, Support, Netzwerk und Revision.
|
||||
#: Gut gemeint, aber sie beantworten eine Frage, die jeder Betrieb anders
|
||||
#: beantwortet: welche Rolle es gibt, weiss der Kunde, nicht die Anwendung.
|
||||
#: Was bleibt, sind die drei, ohne die man nicht anfangen kann -- eine
|
||||
#: Standardgruppe fuer jeden neuen Nutzer, eine fuer die Verwaltung und eine,
|
||||
#: die alle Funktionen buendelt. Weitere legt man in der Matrix an, und die
|
||||
#: Matrix zeigt seit 2.8.0 je Funktion eine Zeile.
|
||||
ROLE_PRESETS: tuple[RolePreset, ...] = (
|
||||
RolePreset(
|
||||
key="viewer",
|
||||
name="Benutzer",
|
||||
description="Standardgruppe: Geraete und Switche ansehen, laufendes Protokoll lesen.",
|
||||
description="Standardgruppe: Geräte und Switche ansehen, laufendes Protokoll lesen.",
|
||||
grants=(
|
||||
("devices", (A.VIEW,)),
|
||||
("switches", (A.VIEW,)),
|
||||
("logs_live", (A.VIEW,)),
|
||||
("devices", (A.READ,)),
|
||||
("switches", (A.READ,)),
|
||||
("logs_live", (A.READ,)),
|
||||
),
|
||||
is_default=True,
|
||||
),
|
||||
RolePreset(
|
||||
key="operations",
|
||||
name="Betrieb",
|
||||
description="Geraete und Switche pflegen, Neustarts ausloesen, Wartung fahren.",
|
||||
grants=(
|
||||
("devices", "*"),
|
||||
("switches", (A.VIEW, A.CREATE, A.EDIT, A.EXECUTE)),
|
||||
("credentials", (A.VIEW,)),
|
||||
("maintenance", "*"),
|
||||
("logs_live", (A.VIEW,)),
|
||||
("logs_history", (A.VIEW,)),
|
||||
("trash", (A.VIEW, A.EDIT)),
|
||||
),
|
||||
),
|
||||
RolePreset(
|
||||
key="support",
|
||||
name="Support",
|
||||
description="Erste Hilfe am Telefon: Status sehen, PoE-Neustart ausloesen.",
|
||||
grants=(
|
||||
("devices", (A.VIEW, A.EXECUTE)),
|
||||
("switches", (A.VIEW,)),
|
||||
("logs_live", (A.VIEW,)),
|
||||
),
|
||||
),
|
||||
RolePreset(
|
||||
key="network",
|
||||
name="Netzwerk",
|
||||
description="DHCP und Host-Netzwerkeinstellungen.",
|
||||
grants=(
|
||||
("dhcp", "*"),
|
||||
("settings_network", "*"),
|
||||
("logs_dhcp", (A.VIEW,)),
|
||||
("devices", (A.VIEW,)),
|
||||
("switches", (A.VIEW,)),
|
||||
),
|
||||
),
|
||||
RolePreset(
|
||||
key="auditor",
|
||||
name="Revision",
|
||||
description="Nur lesen -- inklusive Aenderungsprotokoll und Diagnose.",
|
||||
grants=(
|
||||
("devices", (A.VIEW,)),
|
||||
("switches", (A.VIEW,)),
|
||||
("credentials", (A.VIEW,)),
|
||||
("dhcp", (A.VIEW,)),
|
||||
("users", (A.VIEW,)),
|
||||
("groups", (A.VIEW,)),
|
||||
("logs_live", (A.VIEW,)),
|
||||
("logs_history", (A.VIEW, A.EXPORT)),
|
||||
("logs_audit", (A.VIEW, A.EXPORT)),
|
||||
("diagnostics", (A.VIEW,)),
|
||||
("trash", (A.VIEW,)),
|
||||
),
|
||||
),
|
||||
RolePreset(
|
||||
key="administration",
|
||||
name="Administration",
|
||||
description="Benutzer, Gruppen und Einstellungen -- ohne Admin-Kennzeichen.",
|
||||
description="Benutzer, Gruppen und Einstellungen – ohne Admin-Kennzeichen.",
|
||||
grants=(
|
||||
("users", "*"),
|
||||
("groups", "*"),
|
||||
("settings_system", "*"),
|
||||
("settings_nav", "*"),
|
||||
("settings_license", "*"),
|
||||
("settings_directory", (A.VIEW, A.EDIT, A.EXECUTE)),
|
||||
("settings_directory", (A.READ, A.WRITE, A.EXECUTE)),
|
||||
("settings_web", "*"),
|
||||
("settings_network", "*"),
|
||||
("backup", (A.VIEW, A.EXECUTE, A.EXPORT)),
|
||||
("backup_export", (A.READ, A.EXPORT)),
|
||||
("backup_import", "*"),
|
||||
("trash", "*"),
|
||||
("diagnostics", (A.VIEW,)),
|
||||
("logs_audit", (A.VIEW,)),
|
||||
("diagnostics", (A.READ,)),
|
||||
("logs_audit", (A.READ,)),
|
||||
),
|
||||
),
|
||||
FUNKTIONEN_VOLLZUGRIFF,
|
||||
)
|
||||
|
||||
__all__ = ["PERMISSIONS", "ROLE_PRESETS", "LOGS_DHCP"]
|
||||
__all__ = [
|
||||
"PERMISSIONS",
|
||||
"ROLE_PRESETS",
|
||||
"LOGS_DHCP",
|
||||
"SERVICING_PAGE",
|
||||
"DHCP_PAGE",
|
||||
]
|
||||
@@ -23,6 +23,8 @@ Frische Zeitlinie ab Version 1 -- kein Nachbau der 35 gewachsenen
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from tesm_core.migrations import Migration
|
||||
|
||||
V1 = """
|
||||
@@ -250,8 +252,351 @@ 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.read';
|
||||
|
||||
INSERT OR IGNORE INTO group_permissions (group_id, permission)
|
||||
SELECT group_id, 'fileshare_pdf.read' FROM group_permissions
|
||||
WHERE permission = 'fileshare.read';
|
||||
|
||||
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.write' FROM group_permissions
|
||||
WHERE permission = 'fileshare.write';
|
||||
"""
|
||||
|
||||
|
||||
# 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.
|
||||
#
|
||||
# Die Bedingung nennt absichtlich keine festen Schluessel, sondern Praefixe:
|
||||
# Wer von einer Fassung vor diesem Schritt direkt auf 2.8 geht, hat vorher die
|
||||
# Migrationen des Kerns durchlaufen (core laeuft vor der Anwendung) -- die
|
||||
# Rechte heissen dann schon "read"/"write" und die Wartungszeilen "servicing_".
|
||||
# Mit ausgeschriebenen Schluesseln fand dieser Schritt in genau diesem Fall
|
||||
# nichts und der Bereich waere doch weg. Ein Ueberrest ist die Zeile nicht:
|
||||
# eine angewendete Migration laeuft nie wieder.
|
||||
V6 = """
|
||||
INSERT OR IGNORE INTO group_permissions (group_id, permission)
|
||||
SELECT DISTINCT group_id, 'servicing.read'
|
||||
FROM group_permissions
|
||||
WHERE (permission LIKE 'maintenance.%' OR permission LIKE 'servicing%')
|
||||
AND group_id IN (
|
||||
SELECT group_id FROM group_permissions WHERE permission = 'monitoring.read'
|
||||
);
|
||||
"""
|
||||
|
||||
|
||||
# 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"]),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def uebergang_funktionsgruppe(conn) -> None: # type: ignore[no-untyped-def]
|
||||
"""Bestehende Administratoren behalten ihre Funktionen -- ueber eine Gruppe.
|
||||
|
||||
Steht hier und nicht im Kern, weil erst die Anwendung weiss, welche Zeilen
|
||||
ausserhalb der Verwaltung es gibt. Laeuft nach den Migrationen des Kerns
|
||||
(die Rechte heissen dann schon read/write und sind aufgeteilt).
|
||||
"""
|
||||
from tesm_core.rbac import service as rbac
|
||||
|
||||
from .permissions import FUNKTIONEN_VOLLZUGRIFF, PERMISSIONS
|
||||
|
||||
rbac.funktionsgruppe_fuer_administratoren(conn, PERMISSIONS, FUNKTIONEN_VOLLZUGRIFF)
|
||||
|
||||
|
||||
#: Die vier Vorlagen, die es bis 2.8.6 gab und ab 2.8.7 nicht mehr.
|
||||
#:
|
||||
#: Stehen hier als eingefrorene Kopie, weil eine Migration ihre eigenen Daten
|
||||
#: mitbringen muss: in ``permissions.py`` sind sie weg, und trotzdem muss
|
||||
#: dieser Schritt noch erkennen koennen, ob eine vorgefundene Gruppe wirklich
|
||||
#: unveraendert die ausgelieferte ist.
|
||||
ABGELEGTE_VORLAGEN: tuple[tuple[str, str, tuple[tuple[str, Any], ...]], ...] = (
|
||||
(
|
||||
"operations",
|
||||
"Betrieb",
|
||||
(
|
||||
("devices", "*"),
|
||||
("device_restart_poe", "*"),
|
||||
("device_restart_ssh", "*"),
|
||||
("device_restart_rpc", "*"),
|
||||
("device_console", "*"),
|
||||
("switches", ("read", "create", "write")),
|
||||
("switch_console", "*"),
|
||||
("hostkeys", "*"),
|
||||
("credentials", ("read",)),
|
||||
("servicing_update", "*"),
|
||||
("servicing_reboot", "*"),
|
||||
("servicing_tool", "*"),
|
||||
("logs_live", ("read",)),
|
||||
("logs_history", ("read",)),
|
||||
("trash", ("read", "write")),
|
||||
),
|
||||
),
|
||||
(
|
||||
"support",
|
||||
"Support",
|
||||
(
|
||||
("devices", ("read",)),
|
||||
("device_restart_poe", "*"),
|
||||
("switches", ("read",)),
|
||||
("logs_live", ("read",)),
|
||||
),
|
||||
),
|
||||
(
|
||||
"network",
|
||||
"Netzwerk",
|
||||
(
|
||||
("dhcp_subnets", "*"),
|
||||
("dhcp_options", "*"),
|
||||
("dhcp_reservations", "*"),
|
||||
("dhcp_config", "*"),
|
||||
("dhcp_service", "*"),
|
||||
("settings_network", "*"),
|
||||
("logs_dhcp", ("read",)),
|
||||
("devices", ("read",)),
|
||||
("switches", ("read",)),
|
||||
),
|
||||
),
|
||||
(
|
||||
"auditor",
|
||||
"Revision",
|
||||
(
|
||||
("devices", ("read",)),
|
||||
("switches", ("read",)),
|
||||
("credentials", ("read",)),
|
||||
("dhcp_subnets", ("read",)),
|
||||
("dhcp_options", ("read",)),
|
||||
("dhcp_reservations", ("read",)),
|
||||
("users", ("read",)),
|
||||
("groups", ("read",)),
|
||||
("logs_live", ("read",)),
|
||||
("logs_history", ("read", "export")),
|
||||
("logs_audit", ("read", "export")),
|
||||
("diagnostics", ("read",)),
|
||||
("trash", ("read",)),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def eingefrorene_vorlagen(
|
||||
beschreibung: tuple[tuple[str, str, tuple[tuple[str, Any], ...]], ...],
|
||||
) -> list[Any]:
|
||||
"""Baut aus der Kopie oben wieder ``RolePreset``-Objekte.
|
||||
|
||||
Die Aktionen stehen dort als Text und nicht als ``Action``, damit diese
|
||||
Migration auch dann noch lesbar bleibt, wenn das Enum sich weiterentwickelt.
|
||||
"""
|
||||
from tesm_core.rbac.model import Action, RolePreset
|
||||
|
||||
vorlagen = []
|
||||
for key, name, grants in beschreibung:
|
||||
gewandelt = tuple(
|
||||
(ressource, aktionen if aktionen == "*" else tuple(Action(a) for a in aktionen))
|
||||
for ressource, aktionen in grants
|
||||
)
|
||||
vorlagen.append(RolePreset(key=key, name=name, description="", grants=gewandelt))
|
||||
return vorlagen
|
||||
|
||||
|
||||
# Version 10 ----------------------------------------------------------------
|
||||
#
|
||||
# Wo antwortet ein VNC-Server? Die Frage beantwortet der Ueberwachungs-
|
||||
# durchlauf mit, der ohnehin jedes Geraet anfasst -- einmal je Intervall statt
|
||||
# einmal je Seitenaufruf. Die Kacheln der Statusuebersicht koennen den Knopf
|
||||
# damit zeigen, ohne dass das Rendern der Seite ein Dutzend Verbindungen
|
||||
# aufbaut.
|
||||
V10 = """
|
||||
ALTER TABLE device_status ADD COLUMN vnc_open INTEGER NOT NULL DEFAULT 0;
|
||||
ALTER TABLE device_status ADD COLUMN vnc_checked_at TEXT;
|
||||
"""
|
||||
|
||||
|
||||
# Version 11 ----------------------------------------------------------------
|
||||
#
|
||||
# Nicht nur *ob* dort ein VNC-Server antwortet, sondern *wohin* gefragt wurde.
|
||||
# Die Kacheln der Statusuebersicht verlinken die Verbindung unmittelbar
|
||||
# (vnc://hap02); das Ziel dafuer aus dem Namen neu herzuleiten hiesse, bei
|
||||
# jedem Seitenaufbau je Kachel eine Namensaufloesung zu versuchen. Der
|
||||
# Durchlauf hat sie ohnehin schon gemacht -- also schreibt er sie hin.
|
||||
V11 = """
|
||||
ALTER TABLE device_status ADD COLUMN vnc_target TEXT NOT NULL DEFAULT '';
|
||||
"""
|
||||
|
||||
|
||||
# Version 12 ----------------------------------------------------------------
|
||||
#
|
||||
# Welche Sicherheitsverfahren der VNC-Server angeboten hat. Daran entscheidet
|
||||
# sich, ob eine Sitzung im Browser ueberhaupt zustande kommen kann -- und wer
|
||||
# das erst erfaehrt, wenn er vor einer schwarzen Flaeche sitzt, sucht den
|
||||
# Fehler bei uns.
|
||||
V12 = """
|
||||
ALTER TABLE device_status ADD COLUMN vnc_types TEXT NOT NULL DEFAULT '';
|
||||
"""
|
||||
|
||||
|
||||
# Version 13 ----------------------------------------------------------------
|
||||
#
|
||||
# Die Absage eines VNC-Servers und der Zeitpunkt der Verfahrensmessung. Beide
|
||||
# gehoeren getrennt vom Ueberwachungsdurchlauf gefuehrt: der fragt nur noch,
|
||||
# ob der Port annimmt (eine abgebrochene Protokollverhandlung sperrt bei
|
||||
# RealVNC die eigene Adresse aus), die Verfahren misst die Seite -- und auch
|
||||
# die nur, wenn die letzte Messung alt ist.
|
||||
V13 = """
|
||||
ALTER TABLE device_status ADD COLUMN vnc_reason TEXT NOT NULL DEFAULT '';
|
||||
ALTER TABLE device_status ADD COLUMN vnc_types_at TEXT;
|
||||
"""
|
||||
|
||||
|
||||
# Version 14 ----------------------------------------------------------------
|
||||
#
|
||||
# Welches Anmeldeverfahren zuletzt wirklich getragen hat. Der Server nennt
|
||||
# seine Reihenfolge, und die gilt -- aber wenn seine erste Wahl schon einmal
|
||||
# abgerissen ist und eine andere zum Bild gefuehrt hat, ist das ein Befund.
|
||||
# Ihn zu vergessen kostet bei jedem Oeffnen einen Fehlversuch, und RealVNC
|
||||
# laesst sich mit der Absage Zeit: gemessen zwei bis drei Minuten, bis das
|
||||
# Bild stand.
|
||||
V14 = """
|
||||
ALTER TABLE device_status ADD COLUMN vnc_method INTEGER NOT NULL DEFAULT 0;
|
||||
"""
|
||||
|
||||
|
||||
def uebergang_standardgruppen(conn) -> None: # type: ignore[no-untyped-def]
|
||||
"""Aus sieben Standardgruppen werden drei -- ohne jemandem etwas wegzunehmen.
|
||||
|
||||
Was eine Anwendung an Rollen mitbringt, ist eine Vermutung darueber, wie
|
||||
ein Betrieb organisiert ist. Sieben davon sind sechs zu viele: wer sie
|
||||
nicht braucht, muss sie trotzdem jedes Mal ueberlesen, und in der
|
||||
Rechtematrix stand fuer jede eine Spalte. Es bleiben "Benutzer",
|
||||
"Administration" und "Funktionen (Vollzugriff)".
|
||||
|
||||
Weggeraeumt wird nur, was unberuehrt ist -- keine Mitglieder, Rechte
|
||||
unveraendert. Alles andere bleibt stehen, wo es steht. Und auch das
|
||||
Weggeraeumte liegt nur im Papierkorb.
|
||||
"""
|
||||
from tesm_core.rbac import service as rbac
|
||||
|
||||
from .permissions import PERMISSIONS
|
||||
|
||||
rbac.unberuehrte_vorlagen_entfernen(
|
||||
conn, PERMISSIONS, eingefrorene_vorlagen(ABGELEGTE_VORLAGEN)
|
||||
)
|
||||
|
||||
|
||||
TESM_MIGRATIONS: tuple[Migration, ...] = (
|
||||
Migration(version=1, name="baseline", sql=V1),
|
||||
Migration(version=2, name="seed-dhcp-options", fn=seed_dhcp_options),
|
||||
Migration(version=3, name="restart-events", sql=V3),
|
||||
Migration(
|
||||
version=4,
|
||||
name="fileshare-pdf-rechte",
|
||||
sql=V4,
|
||||
geaendert_weil="Die Rechteschluessel heissen seit 2.8.0 read/write. Auf einer frischen Installation laufen die Migrationen des Kerns zuerst, also muss dieser Schritt die neuen Namen setzen -- mit den alten blieben sie ungueltig.",
|
||||
),
|
||||
Migration(version=5, name="terminal-getrennt", sql=V5),
|
||||
Migration(
|
||||
version=6,
|
||||
name="wartung-eigener-bereich",
|
||||
sql=V6,
|
||||
geaendert_weil="Die Rechteschluessel heissen seit 2.8.0 read/write. Auf einer frischen Installation laufen die Migrationen des Kerns zuerst, also muss dieser Schritt die neuen Namen setzen -- mit den alten blieben sie ungueltig.",
|
||||
),
|
||||
Migration(version=7, name="signaturverzeichnis", sql=V7, fn=uebernehme_signaturen),
|
||||
Migration(version=8, name="funktionsgruppe-fuer-admins", fn=uebergang_funktionsgruppe),
|
||||
Migration(version=9, name="standardgruppen-aufraeumen", fn=uebergang_standardgruppen),
|
||||
Migration(version=10, name="vnc-erreichbarkeit", sql=V10),
|
||||
Migration(version=11, name="vnc-ziel", sql=V11),
|
||||
Migration(version=12, name="vnc-verfahren", sql=V12),
|
||||
Migration(version=13, name="vnc-absage", sql=V13),
|
||||
Migration(version=14, name="vnc-verfahren-das-traegt", sql=V14),
|
||||
)
|
||||
@@ -21,6 +21,9 @@ Sicherheitsrelevante Aenderungen gegenueber dem Vorgaenger:
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import zlib
|
||||
import json
|
||||
import logging
|
||||
import mimetypes
|
||||
import os
|
||||
@@ -32,7 +35,7 @@ import sqlite3
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timedelta, timezone
|
||||
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.extension import core
|
||||
@@ -40,7 +43,7 @@ from tesm_core.extension import core
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_UNC_HINT = (
|
||||
"Der Freigabepfad muss die Form \\\\server\\freigabe haben. Schraegstriche "
|
||||
"Der Freigabepfad muss die Form \\\\server\\freigabe haben. Schrägstriche "
|
||||
"gehen auch: //server/freigabe."
|
||||
)
|
||||
|
||||
@@ -57,6 +60,10 @@ PREVIEW_MIME: dict[str, str] = {
|
||||
".gif": "image/gif",
|
||||
".webp": "image/webp",
|
||||
".bmp": "image/bmp",
|
||||
# Das Format, in dem ein iPad fotografiert. Safari zeigt es an, andere
|
||||
# Browser nicht -- die Vorschau sagt es dann und bietet den Download an.
|
||||
".heic": "image/heic",
|
||||
".heif": "image/heif",
|
||||
".txt": "text/plain; charset=utf-8",
|
||||
".csv": "text/plain; charset=utf-8",
|
||||
".log": "text/plain; charset=utf-8",
|
||||
@@ -68,6 +75,21 @@ PREVIEW_MIME: dict[str, str] = {
|
||||
#: im Ursprung der Anwendung ausgefuehrt. Download bleibt moeglich.
|
||||
|
||||
|
||||
def preview_kind(name: str) -> str:
|
||||
"""Welche Anzeige passt: ``bild``, ``pdf`` oder ``text``?
|
||||
|
||||
Die Unterscheidung gehoert hierher und nicht in die Vorlage: die
|
||||
Zuordnung Endung -> MIME-Typ steht schon hier, und eine zweite Liste in
|
||||
Jinja liefe frueher oder spaeter auseinander.
|
||||
"""
|
||||
mime = PREVIEW_MIME.get(Path(name).suffix.lower(), "")
|
||||
if mime.startswith("image/"):
|
||||
return "bild"
|
||||
if mime == "application/pdf":
|
||||
return "pdf"
|
||||
return "text"
|
||||
|
||||
|
||||
class FileshareError(Exception):
|
||||
"""Fehler mit Klartextmeldung fuer die Oberflaeche."""
|
||||
|
||||
@@ -174,11 +196,11 @@ def resolve_unc_host(unc: str, suffixes: Iterable[str]) -> tuple[str, str]:
|
||||
kandidat = f"{host}.{suffix.strip('.')}"
|
||||
if _resolvable(kandidat):
|
||||
neu = chr(92) * 2 + kandidat + (chr(92) + rest if rest else "")
|
||||
return neu, f"Name {host} ueber {kandidat} aufgeloest"
|
||||
return neu, f"Name {host} über {kandidat} aufgelöst"
|
||||
return unc, (
|
||||
f"Der Name {host} ist auf diesem Server nicht aufloesbar. Bitte den "
|
||||
"vollqualifizierten Namen oder die IP-Adresse eintragen -- oder unter "
|
||||
"Netzwerk eine Suchdomaene setzen."
|
||||
f"Der Name {host} ist auf diesem Server nicht auflösbar. Bitte den "
|
||||
"vollqualifizierten Namen oder die IP-Adresse eintragen – oder unter "
|
||||
"Netzwerk eine Suchdomäne setzen."
|
||||
)
|
||||
|
||||
|
||||
@@ -188,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(
|
||||
conn: sqlite3.Connection, *, ad_group_dn: str, ad_group_name: str, label: str, unc: str
|
||||
) -> int:
|
||||
@@ -196,7 +262,7 @@ def add_mapping(
|
||||
if not clean_label:
|
||||
raise FileshareError("Der Anzeigename darf nicht leer sein.")
|
||||
if not (ad_group_dn or "").strip():
|
||||
raise FileshareError("Es ist keine Verzeichnisgruppe ausgewaehlt.")
|
||||
raise FileshareError("Es ist keine Verzeichnisgruppe ausgewählt.")
|
||||
cursor = conn.execute(
|
||||
"INSERT INTO fileshare_mappings (ad_group_dn, ad_group_name, label, unc, created_at) "
|
||||
"VALUES (?,?,?,?,?) ON CONFLICT(ad_group_dn, unc) DO NOTHING",
|
||||
@@ -228,7 +294,7 @@ def update_mapping(
|
||||
if not clean_label:
|
||||
raise FileshareError("Der Anzeigename darf nicht leer sein.")
|
||||
if not clean_dn:
|
||||
raise FileshareError("Es ist keine Verzeichnisgruppe ausgewaehlt.")
|
||||
raise FileshareError("Es ist keine Verzeichnisgruppe ausgewählt.")
|
||||
|
||||
vorhanden = Database.one(
|
||||
conn, "SELECT id FROM fileshare_mappings WHERE id=?", (mapping_id,)
|
||||
@@ -299,12 +365,32 @@ def mount_root() -> Path:
|
||||
return Path(str(core().settings.get(db(), "fileshare_mount_root") or "/mnt/tesm-shares"))
|
||||
|
||||
|
||||
def active_mounts(conn: sqlite3.Connection, user_id: int) -> list[Mount]:
|
||||
def aktuelle_sitzung() -> str:
|
||||
"""Die ID der laufenden Sitzung -- oder leer ausserhalb einer Anfrage."""
|
||||
from flask import session
|
||||
|
||||
return str(getattr(session, "sid", "") or "")
|
||||
|
||||
|
||||
def active_mounts(
|
||||
conn: sqlite3.Connection, user_id: int, session_id: str | None = None
|
||||
) -> list[Mount]:
|
||||
"""Die offenen Einbindungen dieses Kontos.
|
||||
|
||||
Mit ``session_id`` nur die dieser Sitzung. Das braucht das Abmelden: wer
|
||||
an zwei Geraeten angemeldet ist, hat je Geraet eine eigene Einbindung mit
|
||||
eigenem Einhaengepunkt, und das Abmelden am einen darf das andere nicht
|
||||
aushaengen.
|
||||
"""
|
||||
bedingung = "AND session_id = ? " if session_id else ""
|
||||
werte: list[Any] = [user_id, _iso(_now())]
|
||||
if session_id:
|
||||
werte.insert(1, session_id)
|
||||
rows = Database.all(
|
||||
conn,
|
||||
"SELECT * FROM fileshare_mounts WHERE user_id=? AND released_at IS NULL "
|
||||
"AND expires_at > ? ORDER BY label COLLATE NOCASE",
|
||||
(user_id, _iso(_now())),
|
||||
"SELECT * FROM fileshare_mounts WHERE user_id=? " + bedingung
|
||||
+ "AND released_at IS NULL AND expires_at > ? ORDER BY label COLLATE NOCASE, id DESC",
|
||||
tuple(werte),
|
||||
)
|
||||
return [
|
||||
Mount(
|
||||
@@ -325,6 +411,38 @@ def has_mounts(user_id: int) -> bool:
|
||||
return bool(active_mounts(db(), user_id))
|
||||
|
||||
|
||||
def doppelte_zusammenfassen(
|
||||
conn: sqlite3.Connection, mounts: list[Mount], session_id: str = ""
|
||||
) -> list[Mount]:
|
||||
"""Je Freigabe eine Karteikarte -- vorzugsweise die dieser Sitzung.
|
||||
|
||||
Wer an zwei Geraeten angemeldet ist, hat zwei Einbindungen derselben
|
||||
Freigabe: eine je Sitzung, jede mit eigenem Einhaengepunkt. In der Leiste
|
||||
stand sie deshalb doppelt, einmal "Share" und einmal wieder "Share" -- ohne
|
||||
jeden Unterschied fuer den Betrachter.
|
||||
|
||||
Genommen wird die Einbindung der eigenen Sitzung. Nur wenn es die nicht
|
||||
gibt (alte Zeile, Hintergrundprozess), bleibt eine fremde stehen.
|
||||
"""
|
||||
eigene: set[int] = set()
|
||||
if session_id:
|
||||
eigene = {
|
||||
int(zeile["id"])
|
||||
for zeile in Database.all(
|
||||
conn,
|
||||
"SELECT id FROM fileshare_mounts WHERE session_id=? AND released_at IS NULL",
|
||||
(session_id,),
|
||||
)
|
||||
}
|
||||
gewaehlt: dict[str, Mount] = {}
|
||||
for mount in mounts:
|
||||
schluessel = mount.unc.lower()
|
||||
vorhanden = gewaehlt.get(schluessel)
|
||||
if vorhanden is None or (mount.id in eigene and vorhanden.id not in eigene):
|
||||
gewaehlt[schluessel] = mount
|
||||
return sorted(gewaehlt.values(), key=lambda m: m.label.lower())
|
||||
|
||||
|
||||
def mount_for_login(
|
||||
conn: sqlite3.Connection,
|
||||
*,
|
||||
@@ -344,7 +462,7 @@ def mount_for_login(
|
||||
extension = core()
|
||||
sysops = extension.sysops
|
||||
if sysops is None or not sysops.available:
|
||||
return ["Systemhelfer nicht verfuegbar -- keine Einbindungen."]
|
||||
return ["Systemhelfer nicht verfügbar – keine Einbindungen."]
|
||||
|
||||
dns = [dn for dn in group_dns if dn and dn.strip()]
|
||||
shares = shares_for_groups(conn, dns)
|
||||
@@ -367,9 +485,41 @@ def mount_for_login(
|
||||
# eingebunden, aber leer. Das betrifft die Anmeldung mit zweitem Faktor, bei
|
||||
# der das Passwort im zweiten Schritt nicht mehr vorliegt.
|
||||
if not password:
|
||||
return ["Kein Passwort fuer den Fileserver verfuegbar -- keine Einbindungen."]
|
||||
return ["Kein Passwort für den Fileserver verfügbar – keine Einbindungen."]
|
||||
|
||||
# Was zuerst kommt: das Ende der Sitzung oder die eigene Obergrenze. Die
|
||||
# Sitzung ist die eigentliche Klammer -- mit ihr endet die Einbindung; die
|
||||
# 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)
|
||||
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"))
|
||||
messages: list[str] = []
|
||||
|
||||
@@ -399,7 +549,7 @@ def mount_for_login(
|
||||
str(share["unc"]),
|
||||
str(target),
|
||||
_iso(_now()),
|
||||
_iso(_now() + timedelta(hours=hours)),
|
||||
_iso(frist),
|
||||
),
|
||||
)
|
||||
messages.append(f"{share['label']}: eingebunden")
|
||||
@@ -436,14 +586,30 @@ def forget_all_mounts(conn: sqlite3.Connection) -> int:
|
||||
return int(cursor.rowcount or 0)
|
||||
|
||||
|
||||
def release_all_for_user(conn: sqlite3.Connection, user_id: int) -> int:
|
||||
def release_all_for_user(conn: sqlite3.Connection, user_id: int, session_id: str = "") -> int:
|
||||
"""Loest die Einbindungen -- ohne Sitzung die des ganzen Kontos.
|
||||
|
||||
Beim Abmelden wird die Sitzung uebergeben: das andere Geraet desselben
|
||||
Benutzers arbeitet weiter, seine Einbindung bleibt. Ohne Sitzung (etwa
|
||||
beim Loeschen eines Kontos) faellt alles.
|
||||
"""
|
||||
count = 0
|
||||
for mount in active_mounts(conn, user_id):
|
||||
for mount in active_mounts(conn, user_id, session_id):
|
||||
if session_id and not _gehoert_zur_sitzung(conn, mount.id, session_id):
|
||||
continue
|
||||
release_mount(conn, mount.id)
|
||||
count += 1
|
||||
return count
|
||||
|
||||
|
||||
def _gehoert_zur_sitzung(conn: sqlite3.Connection, mount_id: int, session_id: str) -> bool:
|
||||
"""Zeilen ohne Sitzung werden beim Abmelden nicht mitgerissen."""
|
||||
zeile = Database.one(
|
||||
conn, "SELECT session_id FROM fileshare_mounts WHERE id=?", (mount_id,)
|
||||
)
|
||||
return zeile is not None and str(zeile["session_id"] or "") == session_id
|
||||
|
||||
|
||||
def sweep_expired(database: Any) -> int:
|
||||
"""Loest abgelaufene Einbindungen -- laeuft im Hintergrundprozess.
|
||||
|
||||
@@ -454,7 +620,10 @@ def sweep_expired(database: Any) -> int:
|
||||
released = 0
|
||||
with database.session() as conn:
|
||||
# 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
|
||||
# bei Leerlauf -- blieb die Freigabe mit den Zugangsdaten des Benutzers
|
||||
# weiter eingebunden. Die eigene Frist bleibt als Obergrenze bestehen;
|
||||
@@ -467,10 +636,11 @@ def sweep_expired(database: Any) -> int:
|
||||
LEFT JOIN user_sessions s ON s.id = m.session_id
|
||||
WHERE m.released_at IS NULL
|
||||
AND (m.expires_at <= ?
|
||||
OR s.id IS NULL
|
||||
OR s.revoked_at IS NOT NULL
|
||||
OR s.idle_expires_at <= ?
|
||||
OR s.absolute_expires_at <= ?)
|
||||
OR (COALESCE(m.session_id, '') <> ''
|
||||
AND (s.id IS NULL
|
||||
OR s.revoked_at IS NOT NULL
|
||||
OR s.idle_expires_at <= ?
|
||||
OR s.absolute_expires_at <= ?)))
|
||||
""",
|
||||
(_iso(_now()), _iso(_now()), _iso(_now())),
|
||||
)
|
||||
@@ -496,14 +666,260 @@ def resolve_path(mount: Mount, relative: str) -> Path:
|
||||
"""
|
||||
candidate = PurePosixPath((relative or "").strip().lstrip("/"))
|
||||
if any(part == ".." for part in candidate.parts):
|
||||
raise FileshareError("Ungueltiger Pfad.")
|
||||
raise FileshareError("Ungültiger Pfad.")
|
||||
root = mount.path.resolve()
|
||||
target = (root / str(candidate)).resolve()
|
||||
if target != root and root not in target.parents:
|
||||
raise FileshareError("Der Pfad liegt ausserhalb der Freigabe.")
|
||||
raise FileshareError("Der Pfad liegt außerhalb der Freigabe.")
|
||||
return target
|
||||
|
||||
|
||||
#: 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]:
|
||||
path = resolve_path(mount, relative)
|
||||
if not path.is_dir():
|
||||
@@ -525,9 +941,15 @@ def list_directory(mount: Mount, relative: str = "") -> dict[str, Any]:
|
||||
),
|
||||
}
|
||||
if entry.is_dir():
|
||||
record["entries"] = _inhalt_zaehlen(entry)
|
||||
directories.append(record)
|
||||
else:
|
||||
record["previewable"] = entry.suffix.lower() in PREVIEW_MIME
|
||||
record["preview_kind"] = preview_kind(entry.name)
|
||||
# Getrennt, damit die Oberflaeche nur den Namen zum Bearbeiten
|
||||
# gibt und die Endung danebenstellt.
|
||||
record["stem"] = entry.stem
|
||||
record["suffix"] = entry.suffix
|
||||
files.append(record)
|
||||
except OSError as exc:
|
||||
raise FileshareError(f"Verzeichnis nicht lesbar ({exc}).") from exc
|
||||
@@ -550,7 +972,7 @@ def preview_mime(name: str) -> str:
|
||||
suffix = Path(name).suffix.lower()
|
||||
if suffix in PREVIEW_MIME:
|
||||
return PREVIEW_MIME[suffix]
|
||||
raise FileshareError("Fuer diesen Dateityp gibt es keine Vorschau -- bitte herunterladen.")
|
||||
raise FileshareError("Für diesen Dateityp gibt es keine Vorschau – bitte herunterladen.")
|
||||
|
||||
|
||||
def download_mime(name: str) -> str:
|
||||
@@ -562,16 +984,86 @@ def safe_filename(name: str) -> str:
|
||||
"""Verhindert Pfadanteile und versteckte Dateien in Uploads/Neuanlagen."""
|
||||
cleaned = os.path.basename((name or "").strip().replace("\\", "/"))
|
||||
if not cleaned or cleaned in {".", ".."} or cleaned.startswith("."):
|
||||
raise FileshareError("Ungueltiger Dateiname.")
|
||||
raise FileshareError("Ungültiger Dateiname.")
|
||||
if len(cleaned) > 200:
|
||||
raise FileshareError("Der Dateiname ist zu lang.")
|
||||
return cleaned
|
||||
|
||||
|
||||
def all_directories(mount: Mount, *, tiefe: int = 4, grenze: int = 400) -> list[str]:
|
||||
"""Die Ordner der Freigabe, relativ und alphabetisch -- fuer eine Auswahlliste.
|
||||
|
||||
Begrenzt in Tiefe und Anzahl: eine Netzwerkfreigabe kann zehntausende
|
||||
Verzeichnisse haben, und eine Liste, die den Browser lahmlegt, hilft
|
||||
niemandem. Wer tiefer speichern will, tippt den Pfad -- das Feld nimmt
|
||||
beides.
|
||||
"""
|
||||
wurzel = mount.path.resolve()
|
||||
gefunden: list[str] = []
|
||||
|
||||
def hinein(verzeichnis: Path, ebene: int) -> None:
|
||||
if ebene > tiefe or len(gefunden) >= grenze:
|
||||
return
|
||||
try:
|
||||
eintraege = sorted(verzeichnis.iterdir(), key=lambda e: e.name.lower())
|
||||
except OSError:
|
||||
return
|
||||
for eintrag in eintraege:
|
||||
if len(gefunden) >= grenze:
|
||||
return
|
||||
if not eintrag.is_dir() or eintrag.name.startswith("."):
|
||||
continue
|
||||
gefunden.append(eintrag.relative_to(wurzel).as_posix())
|
||||
hinein(eintrag, ebene + 1)
|
||||
|
||||
hinein(wurzel, 1)
|
||||
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:
|
||||
"""Ein freier Name neben dem Original: ``vorlage-ausgefuellt.pdf``.
|
||||
|
||||
Nichts ueberschreiben: auf einer Freigabe liegt meist die leere Vorlage,
|
||||
und zwei Leute fuellen sie nacheinander aus. Ab dem zweiten Mal wird
|
||||
durchnummeriert.
|
||||
"""
|
||||
kandidat = parent / f"{stem}{zusatz}{suffix}"
|
||||
zaehler = 2
|
||||
while kandidat.exists():
|
||||
kandidat = parent / f"{stem}{zusatz}-{zaehler}{suffix}"
|
||||
zaehler += 1
|
||||
if zaehler > 200:
|
||||
raise FileshareError("Zu viele Dateien dieses Namens – bitte aufräumen.")
|
||||
return kandidat
|
||||
|
||||
|
||||
def delete_entry(mount: Mount, relative: str) -> str:
|
||||
path = resolve_path(mount, relative)
|
||||
if path == mount.path.resolve():
|
||||
raise FileshareError("Das Wurzelverzeichnis der Freigabe kann nicht geloescht werden.")
|
||||
raise FileshareError("Das Wurzelverzeichnis der Freigabe kann nicht gelöscht werden.")
|
||||
if path.is_dir():
|
||||
shutil.rmtree(path)
|
||||
return "Verzeichnis"
|
||||
@@ -589,8 +1081,26 @@ def make_directory(mount: Mount, relative: str, name: str) -> Path:
|
||||
|
||||
|
||||
def rename_entry(mount: Mount, relative: str, new_name: str) -> Path:
|
||||
"""Benennt um und behaelt die Endung der Datei.
|
||||
|
||||
Eingegeben wird nur der Name ohne Endung. Eine Datei, die danach auf
|
||||
``.pdf.txt`` oder auf gar nichts endet, waere auf einer Windows-Freigabe
|
||||
praktisch kaputt -- sie oeffnet sich nicht mehr mit dem richtigen
|
||||
Programm. Verzeichnisse haben keine Endung; dort bleibt der Name, wie er
|
||||
eingegeben wurde.
|
||||
|
||||
Wer die Endung trotzdem mittippt, bekommt sie nicht doppelt.
|
||||
"""
|
||||
path = resolve_path(mount, relative)
|
||||
target = path.parent / safe_filename(new_name)
|
||||
endung = path.suffix if path.is_file() else ""
|
||||
gewuenscht = (new_name or "").strip()
|
||||
if endung and gewuenscht.lower().endswith(endung.lower()):
|
||||
gewuenscht = gewuenscht[: -len(endung)]
|
||||
if not gewuenscht:
|
||||
raise FileshareError("Der Name darf nicht leer sein.")
|
||||
target = path.parent / safe_filename(gewuenscht + endung)
|
||||
if target == path:
|
||||
return path
|
||||
if target.exists():
|
||||
raise FileshareError("Ein Eintrag mit diesem Namen existiert bereits.")
|
||||
path.rename(target)
|
||||
|
||||
@@ -54,13 +54,13 @@ def is_ssh_managed(category: str | None) -> bool:
|
||||
#: war "Windows-Client" vorher -- waehlbar, aber ohne jede Wirkung.
|
||||
CATEGORY_EFFECTS: dict[str, str] = {
|
||||
"switch": "Anmeldung an Switchen: PoE schalten, Konsole, Statusabfragen.",
|
||||
"linux": "Wartung ueber SSH: Paketaktualisierung (apt) und Neustart. Konsole.",
|
||||
"linux": "Wartung über SSH: Paketaktualisierung (apt) und Neustart. Konsole.",
|
||||
"windows": (
|
||||
"Neustart ueber RPC (wie 'shutdown /m'). Kein SSH, kein Host-Schluessel. Das "
|
||||
"Geraet braucht offene Datei- und Druckerfreigabe (Port 445); das Konto braucht "
|
||||
"Neustart über RPC (wie 'shutdown /m'). Kein SSH, kein Host-Schlüssel. Das "
|
||||
"Gerät braucht offene Datei- und Druckerfreigabe (Port 445); das Konto braucht "
|
||||
"das Recht, aus der Ferne herunterzufahren. Paketaktualisierung gibt es hier nicht."
|
||||
),
|
||||
"router": "Nur Dokumentation -- diese Kategorie loest keine Aktion aus.",
|
||||
"router": "Nur Dokumentation – diese Kategorie löst keine Aktion aus.",
|
||||
}
|
||||
|
||||
DEVICE_CATEGORIES: tuple[tuple[str, str], ...] = (
|
||||
@@ -95,7 +95,7 @@ def normalize_mac(value: str) -> str:
|
||||
cleaned = re.sub(r"[^0-9a-fA-F]", "", value or "")
|
||||
if len(cleaned) != 12:
|
||||
raise ValidationError(
|
||||
"Die MAC-Adresse muss zwoelf hexadezimale Zeichen enthalten "
|
||||
"Die MAC-Adresse muss zwölf hexadezimale Zeichen enthalten "
|
||||
"(z. B. aa:bb:cc:dd:ee:ff)."
|
||||
)
|
||||
return ":".join(cleaned[index : index + 2] for index in range(0, 12, 2)).lower()
|
||||
@@ -106,7 +106,7 @@ def validate_ip(value: str, *, field: str = "IP-Adresse") -> str:
|
||||
try:
|
||||
ipaddress.ip_address(text)
|
||||
except ValueError as exc:
|
||||
raise ValidationError(f"{field}: {text!r} ist keine gueltige IP-Adresse.") from exc
|
||||
raise ValidationError(f"{field}: {text!r} ist keine gültige IP-Adresse.") from exc
|
||||
return text
|
||||
|
||||
|
||||
@@ -116,7 +116,7 @@ def validate_port_name(value: str) -> str:
|
||||
return ""
|
||||
if not PORT_RE.match(text):
|
||||
raise ValidationError(
|
||||
"Die Portbezeichnung enthaelt unerlaubte Zeichen (erlaubt: Buchstaben, "
|
||||
"Die Portbezeichnung enthält unerlaubte Zeichen (erlaubt: Buchstaben, "
|
||||
"Ziffern, / . : -)."
|
||||
)
|
||||
return text
|
||||
@@ -125,7 +125,7 @@ def validate_port_name(value: str) -> str:
|
||||
def validate_hostname(value: str) -> str:
|
||||
text = (value or "").strip()
|
||||
if not HOSTNAME_RE.match(text):
|
||||
raise ValidationError("Der Hostname enthaelt unerlaubte Zeichen oder ist leer.")
|
||||
raise ValidationError("Der Hostname enthält unerlaubte Zeichen oder ist leer.")
|
||||
return text
|
||||
|
||||
|
||||
@@ -208,7 +208,7 @@ def create_credential(
|
||||
if existing["deleted_at"]:
|
||||
raise ValidationError(
|
||||
f"Zugangsdaten namens {clean_name!r} liegen im Papierkorb. Stellen Sie sie "
|
||||
"wieder her oder loeschen Sie sie endgueltig."
|
||||
"wieder her oder löschen Sie sie endgültig."
|
||||
)
|
||||
raise ValidationError(f"Zugangsdaten namens {clean_name!r} existieren bereits.")
|
||||
|
||||
@@ -258,11 +258,11 @@ def update_credential(
|
||||
if clean_name != str(row["name"]):
|
||||
changes.append(f"Name: {row['name']} -> {clean_name}")
|
||||
if (username or "").strip() != str(row["username"] or ""):
|
||||
changes.append("Benutzer geaendert")
|
||||
changes.append("Benutzer geändert")
|
||||
if category != str(row["category"]):
|
||||
changes.append(f"Kategorie: {row['category']} -> {category}")
|
||||
if (description or "").strip() != str(row["description"] or ""):
|
||||
changes.append("Beschreibung geaendert")
|
||||
changes.append("Beschreibung geändert")
|
||||
|
||||
conn.execute(
|
||||
"UPDATE credentials SET name=?, username=?, category=?, description=?, updated_at=?, "
|
||||
@@ -274,7 +274,7 @@ def update_credential(
|
||||
"UPDATE credentials SET secret_encrypted=? WHERE id=?",
|
||||
(keystore.encrypt(secret, aad=aad_for_credential(credential_id)), credential_id),
|
||||
)
|
||||
changes.append("Passwort geaendert")
|
||||
changes.append("Passwort geändert")
|
||||
return changes
|
||||
|
||||
|
||||
@@ -296,8 +296,8 @@ def credential_secret(
|
||||
secret = keystore.decrypt(encrypted, aad=aad_for_credential(int(credential_id)))
|
||||
except DecryptionError as exc:
|
||||
raise ValidationError(
|
||||
"Das gespeicherte Passwort konnte nicht entschluesselt werden. Wurde der "
|
||||
"Datenschluessel ausgetauscht? Bitte die Zugangsdaten neu eingeben."
|
||||
"Das gespeicherte Passwort konnte nicht entschlüsselt werden. Wurde der "
|
||||
"Datenschlüssel ausgetauscht? Bitte die Zugangsdaten neu eingeben."
|
||||
) from exc
|
||||
return str(row["username"] or ""), secret
|
||||
|
||||
@@ -315,7 +315,7 @@ def delete_credential(conn: sqlite3.Connection, credential_id: int) -> None:
|
||||
if int(row.get("switches") or 0) or int(row.get("devices") or 0):
|
||||
raise ValidationError(
|
||||
f"Diese Zugangsdaten werden noch von {row.get('switches')} Switch(en) und "
|
||||
f"{row.get('devices')} Geraet(en) verwendet."
|
||||
f"{row.get('devices')} Gerät(en) verwendet."
|
||||
)
|
||||
conn.execute("UPDATE credentials SET deleted_at=? WHERE id=?", (_now(), credential_id))
|
||||
|
||||
@@ -509,7 +509,7 @@ def delete_switch(conn: sqlite3.Connection, switch_id: int) -> None:
|
||||
)
|
||||
if int(count or 0):
|
||||
raise ValidationError(
|
||||
f"Diesem Switch sind noch {count} Geraet(e) zugeordnet. Bitte zuerst umhaengen."
|
||||
f"Diesem Switch sind noch {count} Gerät(e) zugeordnet. Bitte zuerst umhängen."
|
||||
)
|
||||
conn.execute("UPDATE switches SET deleted_at=? WHERE id=?", (_now(), switch_id))
|
||||
|
||||
@@ -542,7 +542,11 @@ def list_devices(
|
||||
WHERE k.host = d.ip AND k.port = d.ssh_port) AS host_key_known,
|
||||
COALESCE(st.state,'unknown') AS state, st.latency_ms, st.last_check_at,
|
||||
st.last_ok_at, st.last_change_at, st.last_restart_at, st.restart_count,
|
||||
st.fail_count
|
||||
st.fail_count, COALESCE(st.vnc_open, 0) AS vnc_open, st.vnc_checked_at,
|
||||
COALESCE(st.vnc_target, '') AS vnc_target,
|
||||
COALESCE(st.vnc_types, '') AS vnc_types,
|
||||
COALESCE(st.vnc_reason, '') AS vnc_reason, st.vnc_types_at,
|
||||
COALESCE(st.vnc_method, 0) AS vnc_method
|
||||
FROM devices d
|
||||
LEFT JOIN switches s ON s.id = d.switch_id AND s.deleted_at IS NULL
|
||||
LEFT JOIN credentials c ON c.id = d.credential_id AND c.deleted_at IS NULL
|
||||
@@ -590,13 +594,13 @@ def create_device(
|
||||
)
|
||||
if existing is not None:
|
||||
if existing["deleted_at"]:
|
||||
raise ValidationError(f"Ein Geraet mit der MAC {clean_mac} liegt im Papierkorb.")
|
||||
raise ValidationError(f"Ein Geraet mit der MAC {clean_mac} existiert bereits.")
|
||||
raise ValidationError(f"Ein Gerät mit der MAC {clean_mac} liegt im Papierkorb.")
|
||||
raise ValidationError(f"Ein Gerät mit der MAC {clean_mac} existiert bereits.")
|
||||
|
||||
if switch_id and not clean_port:
|
||||
raise ValidationError(
|
||||
"Zu einem zugeordneten Switch gehoert eine Portbezeichnung -- sonst ist kein "
|
||||
"PoE-Neustart moeglich."
|
||||
"Zu einem zugeordneten Switch gehört eine Portbezeichnung – sonst ist kein "
|
||||
"PoE-Neustart möglich."
|
||||
)
|
||||
|
||||
cursor = conn.execute(
|
||||
@@ -651,7 +655,7 @@ def update_device(
|
||||
conn, "SELECT * FROM devices WHERE id=? AND deleted_at IS NULL", (device_id,)
|
||||
)
|
||||
if row is None:
|
||||
raise ValidationError("Dieses Geraet existiert nicht (mehr).")
|
||||
raise ValidationError("Dieses Gerät existiert nicht (mehr).")
|
||||
clean_name = (name or "").strip()
|
||||
if not clean_name:
|
||||
raise ValidationError("Der Name darf nicht leer sein.")
|
||||
@@ -664,9 +668,9 @@ def update_device(
|
||||
(clean_mac, device_id),
|
||||
)
|
||||
if clash is not None:
|
||||
raise ValidationError(f"Ein Geraet mit der MAC {clean_mac} existiert bereits.")
|
||||
raise ValidationError(f"Ein Gerät mit der MAC {clean_mac} existiert bereits.")
|
||||
if switch_id and not clean_port:
|
||||
raise ValidationError("Zu einem zugeordneten Switch gehoert eine Portbezeichnung.")
|
||||
raise ValidationError("Zu einem zugeordneten Switch gehört eine Portbezeichnung.")
|
||||
|
||||
changes: list[str] = []
|
||||
for label, before, after in (
|
||||
@@ -677,6 +681,12 @@ def update_device(
|
||||
("Port", str(row["port"] or ""), clean_port),
|
||||
("Aktiv", str(bool(row["is_active"])), str(bool(is_active))),
|
||||
("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:
|
||||
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"),
|
||||
(10, "impress-servers", "ipv4-address", "Impress-Server"),
|
||||
(11, "resource-location-servers", "ipv4-address", "Resource Location Server"),
|
||||
(13, "boot-size", "uint16", "Boot-Image-Groesse (Bloecke)"),
|
||||
(13, "boot-size", "uint16", "Boot-Image-Größe (Blöcke)"),
|
||||
(14, "merit-dump", "string", "Crash-Dump-Datei"),
|
||||
(16, "swap-server", "ipv4-address", "Swap-Server"),
|
||||
(17, "root-path", "string", "Root-Pfad (NFS)"),
|
||||
@@ -54,8 +54,8 @@ STANDARD_OPTIONS: tuple[tuple[int, str, str, str], ...] = (
|
||||
(19, "ip-forwarding", "boolean", "IP-Forwarding aktivieren"),
|
||||
(20, "non-local-source-routing", "boolean", "Non-Local Source Routing"),
|
||||
(21, "policy-filter", "string", "Policy Filter (Ziel,Maske,...)"),
|
||||
(22, "max-dgram-reassembly", "uint16", "Maximale Datagramm-Reassemblierungsgroesse"),
|
||||
(23, "default-ip-ttl", "uint8", "Standard-TTL fuer IP"),
|
||||
(22, "max-dgram-reassembly", "uint16", "Maximale Datagramm-Reassemblierungsgröße"),
|
||||
(23, "default-ip-ttl", "uint8", "Standard-TTL für IP"),
|
||||
(24, "path-mtu-aging-timeout", "uint32", "Path-MTU-Aging-Timeout (Sek.)"),
|
||||
(25, "path-mtu-plateau-table", "uint16", "Path-MTU-Plateau-Tabelle"),
|
||||
(26, "interface-mtu", "uint16", "MTU des Interfaces"),
|
||||
@@ -69,7 +69,7 @@ STANDARD_OPTIONS: tuple[tuple[int, str, str, str], ...] = (
|
||||
(34, "trailer-encapsulation", "boolean", "Trailer Encapsulation"),
|
||||
(35, "arp-cache-timeout", "uint32", "ARP-Cache-Timeout (Sek.)"),
|
||||
(36, "ieee802-3-encapsulation", "boolean", "Ethernet-Encapsulation (IEEE 802.3)"),
|
||||
(37, "default-tcp-ttl", "uint8", "Standard-TTL fuer TCP"),
|
||||
(37, "default-tcp-ttl", "uint8", "Standard-TTL für TCP"),
|
||||
(38, "tcp-keepalive-interval", "uint32", "TCP-Keepalive-Intervall (Sek.)"),
|
||||
(40, "nis-domain", "string", "NIS-Domain"),
|
||||
(41, "nis-servers", "ipv4-address", "NIS-Server"),
|
||||
@@ -190,12 +190,12 @@ def validate_subnet(
|
||||
) -> str:
|
||||
"""Rueckgabe: leerer String bei Erfolg, sonst die Fehlermeldung."""
|
||||
if not interface:
|
||||
return "Es ist kein Interface ausgewaehlt."
|
||||
return "Es ist kein Interface ausgewählt."
|
||||
try:
|
||||
start = ipaddress.ip_address(range_start)
|
||||
end = ipaddress.ip_address(range_end)
|
||||
except ValueError:
|
||||
return "Start- und Endadresse muessen gueltige IPv4-Adressen sein."
|
||||
return "Start- und Endadresse müssen gültige IPv4-Adressen sein."
|
||||
if int(end) < int(start):
|
||||
return "Die Endadresse liegt vor der Startadresse."
|
||||
if matching_network(networks, interface, range_start, range_end) is None:
|
||||
@@ -405,7 +405,7 @@ def render_config(
|
||||
# fuehren, dass gar keine Konfiguration mehr geschrieben wird.
|
||||
warnings.append(
|
||||
f"Subnetz {subnet['range_start']}-{subnet['range_end']} auf {interface} "
|
||||
"passt in kein aktuelles Netz und wurde uebersprungen."
|
||||
"passt in kein aktuelles Netz und wurde übersprungen."
|
||||
)
|
||||
continue
|
||||
interfaces.add(interface)
|
||||
@@ -444,7 +444,7 @@ def render_config(
|
||||
)
|
||||
|
||||
if not subnet_entries:
|
||||
warnings.append("Es ist kein gueltiges Subnetz konfiguriert -- Kea wuerde nicht starten.")
|
||||
warnings.append("Es ist kein gültiges Subnetz konfiguriert | Kea würde nicht starten.")
|
||||
|
||||
global_option_data = option_data(global_values)
|
||||
domain = str(settings.get("dhcp_domain") or "").strip()
|
||||
@@ -560,14 +560,14 @@ class Lease:
|
||||
#: benutzte, und ein Geraet, das noch auf einer alten Adresse haengt, faellt
|
||||
#: ueberhaupt nicht auf.
|
||||
RESERVATION_STATES: dict[str, tuple[str, str]] = {
|
||||
"aktiv": ("online", "Das Geraet benutzt diese Adresse gerade."),
|
||||
"abgelaufen": ("", "Lease auf dieser Adresse ist abgelaufen -- Geraet derzeit nicht da."),
|
||||
"aktiv": ("online", "Das Gerät benutzt diese Adresse gerade."),
|
||||
"abgelaufen": ("", "Lease auf dieser Adresse ist abgelaufen | Gerät derzeit nicht da."),
|
||||
"andere_adresse": (
|
||||
"warning",
|
||||
"Das Geraet haelt noch eine Lease auf einer anderen Adresse. Es uebernimmt die "
|
||||
"Das Gerät hält noch eine Lease auf einer anderen Adresse. Es übernimmt die "
|
||||
"Reservierung erst nach Ablauf oder Neuanforderung.",
|
||||
),
|
||||
"unbenutzt": ("", "Fuer dieses Geraet liegt keine Lease vor."),
|
||||
"unbenutzt": ("", "Für dieses Gerät liegt keine Lease vor."),
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -16,10 +16,20 @@ eingelesen. Ein eigener Schreibpfad fuer Altdaten waere eine zweite Stelle, an
|
||||
der Fremdeingaben in die Datenbank gelangen -- und die zweite Stelle ist die,
|
||||
die niemand pflegt.
|
||||
|
||||
Uebersetzt werden Clients, Switche, Zugangsdaten und die DHCP-Konfiguration.
|
||||
Was in der Datei sonst noch steht, wird nicht stillschweigend verworfen,
|
||||
sondern im Bericht benannt -- damit sichtbar bleibt, was von Hand nachzuziehen
|
||||
ist.
|
||||
Uebersetzt werden Clients, Switche, Zugangsdaten, die DHCP-Konfiguration, die
|
||||
Freigabe-Zuordnungen und die Anbindung an das Verzeichnis (Server, Transport,
|
||||
Basis-DN, Anmeldeattribut, Bind-Konto samt Passwort, und die Zuordnung
|
||||
"diese AD-Gruppe wird Administrator"). Was in der Datei sonst noch steht, wird nicht
|
||||
stillschweigend verworfen, sondern im Bericht benannt -- damit sichtbar bleibt,
|
||||
was von Hand nachzuziehen ist.
|
||||
|
||||
Die Freigaben standen bis 2.8.0 auf der Liste "von Hand nachtragen", obwohl es
|
||||
dafuer keinen Grund gab: der Vorgaenger fuehrt sie in
|
||||
``ldap_fileshare_mappings`` mit denselben vier Angaben, die diese Anwendung in
|
||||
``fileshare_mappings`` braucht (Verzeichnisgruppe, deren Name, Beschriftung,
|
||||
UNC-Pfad), und einen Import dafuer gibt es hier ohnehin. Was sich uebernehmen
|
||||
laesst, soll man nicht abtippen muessen -- ein UNC-Pfad von Hand ist ein
|
||||
Tippfehler, der erst auffaellt, wenn jemand die Freigabe braucht.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -36,25 +46,24 @@ LEGACY_KDF_ITERATIONS = 390000
|
||||
LEGACY_APP_TAG = "tesm"
|
||||
|
||||
#: Diese Abschnitte werden uebersetzt.
|
||||
SUPPORTED = ("credentials", "switches", "devices", "dhcp")
|
||||
SUPPORTED = ("credentials", "switches", "devices", "dhcp", "ldap")
|
||||
|
||||
#: Was der alte Export sonst enthaelt. Wird gemeldet, nicht uebersetzt.
|
||||
KNOWN_UNSUPPORTED: dict[str, str] = {
|
||||
"users": (
|
||||
"Lokale Konten: der alte Passwort-Hash ist bcrypt, diese Anwendung "
|
||||
"verwendet Argon2id. Ein Hash laesst sich nicht umrechnen -- das ist sein "
|
||||
"verwendet Argon2id. Ein Hash lässt sich nicht umrechnen – das ist sein "
|
||||
"Zweck. Konten neu anlegen."
|
||||
),
|
||||
"groups": "Gruppen und Rechte: die Rechteschluessel wurden neu geschnitten.",
|
||||
"ldap": (
|
||||
"Verzeichnisdienst: Einstellungen, Gruppen- und Freigabe-Zuordnungen von "
|
||||
"Hand uebertragen."
|
||||
),
|
||||
"nginx": "Webserver: Domain, Ports und Zertifikat von Hand uebertragen.",
|
||||
"groups": "Gruppen und Rechte: die Rechteschlüssel wurden neu geschnitten.",
|
||||
# Der Abschnitt "ldap" wird uebersetzt (siehe _verzeichnis) -- was dabei
|
||||
# nicht mitkommt, benennt die Uebersetzung einzeln und genauer, als eine
|
||||
# pauschale Zeile hier es koennte.
|
||||
"nginx": "Webserver: Domain, Ports und Zertifikat von Hand übertragen.",
|
||||
"logs": "Auditlog: bleibt bewusst beim alten System.",
|
||||
"known_hosts": (
|
||||
"SSH-Host-Schluessel: werden hier je Geraet mit Fingerprint gefuehrt und "
|
||||
"ueber die Seite 'Host-Schluessel' freigegeben."
|
||||
"SSH-Host-Schlüssel: werden hier je Gerät mit Fingerprint geführt und "
|
||||
"über die Seite 'Host-Schlüssel' freigegeben."
|
||||
),
|
||||
}
|
||||
|
||||
@@ -119,10 +128,10 @@ def open_legacy(raw: bytes | str, passphrase: str) -> dict[str, Any]:
|
||||
except ValueError as exc:
|
||||
raise LegacyTransferError("Die Datei ist kein JSON.") from exc
|
||||
if not isinstance(envelope, dict):
|
||||
raise LegacyTransferError("Die Datei enthaelt kein Objekt.")
|
||||
raise LegacyTransferError("Die Datei enthält kein Objekt.")
|
||||
if str(envelope.get("app") or "") != LEGACY_APP_TAG:
|
||||
raise LegacyTransferError(
|
||||
f"Diese Datei gehoert zu {envelope.get('app') or 'einer unbekannten Anwendung'!r}, "
|
||||
f"Diese Datei gehört zu {envelope.get('app') or 'einer unbekannten Anwendung'!r}, "
|
||||
"nicht zu TESM."
|
||||
)
|
||||
for schluessel in ("salt", "payload"):
|
||||
@@ -142,15 +151,15 @@ def open_legacy(raw: bytes | str, passphrase: str) -> dict[str, Any]:
|
||||
klartext = Fernet(key).decrypt(str(envelope["payload"]).encode("utf-8"))
|
||||
except (InvalidToken, ValueError) as exc:
|
||||
raise LegacyTransferError(
|
||||
"Entschluesselung fehlgeschlagen -- falsche Passphrase oder beschaedigte Datei."
|
||||
"Entschlüsselung fehlgeschlagen – falsche Passphrase oder beschädigte Datei."
|
||||
) from exc
|
||||
|
||||
try:
|
||||
payload = json.loads(klartext.decode("utf-8"))
|
||||
except ValueError as exc:
|
||||
raise LegacyTransferError("Die entschluesselte Nutzlast ist kein JSON.") from exc
|
||||
raise LegacyTransferError("Die entschlüsselte Nutzlast ist kein JSON.") from exc
|
||||
if not isinstance(payload, dict):
|
||||
raise LegacyTransferError("Die Nutzlast enthaelt kein Objekt.")
|
||||
raise LegacyTransferError("Die Nutzlast enthält kein Objekt.")
|
||||
return payload
|
||||
|
||||
|
||||
@@ -180,14 +189,14 @@ def _credentials(items: Any, report: Report) -> list[dict[str, Any]]:
|
||||
for entry in _liste(items):
|
||||
name = str(entry.get("name") or "").strip()
|
||||
if not name:
|
||||
report.note("Zugangsdaten ohne Namen uebersprungen.")
|
||||
report.note("Zugangsdaten ohne Namen übersprungen.")
|
||||
continue
|
||||
umgesetzt: dict[str, Any] = {
|
||||
"name": name,
|
||||
"username": str(entry.get("username") or ""),
|
||||
"category": _category(entry.get("category"), report, f"Zugangsdaten {name!r}"),
|
||||
# Der Vorgaenger kennt kein Beschreibungsfeld.
|
||||
"description": "aus altem TESM uebernommen",
|
||||
"description": "aus altem TESM übernommen",
|
||||
}
|
||||
# Der alte Export entschluesselt das Passwort und legt es im Klartext in
|
||||
# die -- als Ganzes passphrasenverschluesselte -- Datei. Beim Import
|
||||
@@ -199,8 +208,8 @@ def _credentials(items: Any, report: Report) -> list[dict[str, Any]]:
|
||||
ergebnis.append(umgesetzt)
|
||||
if ohne_passwort:
|
||||
report.note(
|
||||
f"{ohne_passwort} Zugangsdaten ohne Passwort in der Datei -- "
|
||||
"sie muessen nachgetragen werden."
|
||||
f"{ohne_passwort} Zugangsdaten ohne Passwort in der Datei – "
|
||||
"sie müssen nachgetragen werden."
|
||||
)
|
||||
report.took("credentials", len(ergebnis))
|
||||
return ergebnis
|
||||
@@ -211,7 +220,7 @@ def _switches(items: Any, report: Report) -> list[dict[str, Any]]:
|
||||
for entry in _liste(items):
|
||||
hostname = str(entry.get("hostname") or "").strip()
|
||||
if not hostname:
|
||||
report.note("Switch ohne Hostnamen uebersprungen.")
|
||||
report.note("Switch ohne Hostnamen übersprungen.")
|
||||
continue
|
||||
ergebnis.append(
|
||||
{
|
||||
@@ -240,7 +249,7 @@ def _devices(items: Any, credentials: list[dict[str, Any]], report: Report) -> l
|
||||
for entry in _liste(items):
|
||||
mac = str(entry.get("mac") or "").strip()
|
||||
if not mac:
|
||||
report.note("Client ohne MAC-Adresse uebersprungen.")
|
||||
report.note("Client ohne MAC-Adresse übersprungen.")
|
||||
continue
|
||||
zugang = entry.get("credential_name")
|
||||
kategorie = kategorie_je_zugang.get(str(zugang or ""), "")
|
||||
@@ -266,12 +275,174 @@ def _devices(items: Any, credentials: list[dict[str, Any]], report: Report) -> l
|
||||
if abgeleitet:
|
||||
report.note(
|
||||
f"Kategorie von {abgeleitet} Client(s) aus den zugeordneten Zugangsdaten "
|
||||
"abgeleitet -- der Vorgaenger kennt kein solches Feld. Bitte pruefen."
|
||||
"abgeleitet – der Vorgänger kennt kein solches Feld. Bitte prüfen."
|
||||
)
|
||||
report.took("devices", len(ergebnis))
|
||||
return ergebnis
|
||||
|
||||
|
||||
def _verzeichnis(data: Any, report: Report) -> dict[str, Any]:
|
||||
"""Die Anbindung an das Verzeichnis -- so weit sie sich abbilden laesst.
|
||||
|
||||
Zwei Stellen sind **nicht** 1:1, und beide werden benannt statt geraten:
|
||||
|
||||
* **Transport.** Der Vorgaenger kannte ``ldap_use_ssl`` (an/aus). Aus "an"
|
||||
wird LDAPS. Aus "aus" wird **StartTLS auf demselben Port** und nicht
|
||||
"unverschluesselt": diese Anwendung verlangt fuer eine unverschluesselte
|
||||
Verbindung einen ausdruecklichen Schalter, weil das Passwort dabei im
|
||||
Klartext ueber das Netz geht. Diesen Schalter setzt eine Uebernahme
|
||||
nicht fuer jemanden -- ein Active Directory beantwortet StartTLS auf 389
|
||||
ohnehin, und wenn nicht, sagt der Verbindungstest es sofort.
|
||||
* **Standard- und Anmeldegruppe.** Es gibt sie hier nicht als
|
||||
Einstellung: die Standardgruppe ist ein Kennzeichen an der Gruppe
|
||||
selbst, und eine erzwungene Anmeldegruppe kennt diese Anwendung nicht.
|
||||
Waren sie gesetzt, steht es im Bericht.
|
||||
"""
|
||||
if not isinstance(data, dict):
|
||||
return {}
|
||||
alt = {str(k): v for k, v in (data.get("settings") or {}).items()}
|
||||
ergebnis: dict[str, Any] = {}
|
||||
einstellungen: dict[str, Any] = {}
|
||||
|
||||
def ja(schluessel: str) -> bool:
|
||||
return str(alt.get(schluessel) or "").strip().lower() in {"1", "true", "yes", "on"}
|
||||
|
||||
server = str(alt.get("ldap_server") or "").strip()
|
||||
if server:
|
||||
einstellungen["ldap_host"] = server
|
||||
if str(alt.get("ldap_port") or "").strip().isdigit():
|
||||
einstellungen["ldap_port"] = int(str(alt["ldap_port"]).strip())
|
||||
if "ldap_enabled" in alt:
|
||||
einstellungen["ldap_enabled"] = ja("ldap_enabled")
|
||||
if str(alt.get("ldap_base_dn") or "").strip():
|
||||
einstellungen["ldap_base_dn"] = str(alt["ldap_base_dn"]).strip()
|
||||
if str(alt.get("ldap_user_filter_attr") or "").strip():
|
||||
einstellungen["ldap_user_attribute"] = str(alt["ldap_user_filter_attr"]).strip()
|
||||
|
||||
if "ldap_use_ssl" in alt:
|
||||
if ja("ldap_use_ssl"):
|
||||
einstellungen["ldap_security"] = "ldaps"
|
||||
else:
|
||||
einstellungen["ldap_security"] = "starttls"
|
||||
report.note(
|
||||
"Der Vorgänger hat unverschlüsselt verbunden. Übernommen als StartTLS auf "
|
||||
"demselben Port – eine unverschlüsselte Verbindung verlangt hier einen "
|
||||
"ausdrücklichen Schalter, den eine Übernahme nicht für Sie setzt. Bitte den "
|
||||
"Verbindungstest ausführen."
|
||||
)
|
||||
if ja("ldap_tls_skip_verify"):
|
||||
einstellungen["ldap_verify_cert"] = False
|
||||
report.note(
|
||||
"Die Prüfung des Serverzertifikats war abgeschaltet und bleibt es – sonst "
|
||||
"scheitert die Anmeldung nach dem Upgrade. Damit schützt die Verschlüsselung "
|
||||
"nur gegen Mitlesen, nicht gegen einen Man-in-the-Middle: bitte ein "
|
||||
"CA-Zertifikat hinterlegen und die Prüfung einschalten."
|
||||
)
|
||||
if einstellungen:
|
||||
ergebnis["settings"] = einstellungen
|
||||
|
||||
for schluessel, text in (
|
||||
(
|
||||
"ldap_default_group",
|
||||
"Standardgruppe {wert!r}: hier ist das ein Kennzeichen an der Gruppe selbst "
|
||||
"(Verwaltung -> Gruppen). Bitte dort setzen.",
|
||||
),
|
||||
(
|
||||
"ldap_required_login_group",
|
||||
"Erzwungene Anmeldegruppe {wert!r}: diese Anwendung kennt sie nicht. Wer sich "
|
||||
"anmelden darf, entscheidet die Gruppen-Zuordnung.",
|
||||
),
|
||||
):
|
||||
wert = str(alt.get(schluessel) or "").strip()
|
||||
if wert:
|
||||
report.note(text.format(wert=wert))
|
||||
|
||||
benutzer = str(data.get("bind_username") or alt.get("ldap_bind_dn") or "").strip()
|
||||
passwort = str(data.get("bind_password") or "")
|
||||
if benutzer:
|
||||
ergebnis["bind_username"] = benutzer
|
||||
ergebnis["bind_password"] = passwort
|
||||
if not passwort:
|
||||
report.note(
|
||||
f"Bind-Konto {benutzer!r} übernommen, aber ohne Passwort – es stand nicht in "
|
||||
"der Datei. Unter Verwaltung -> Verzeichnisdienst nachtragen."
|
||||
)
|
||||
|
||||
zuordnungen: list[dict[str, Any]] = []
|
||||
offen: list[str] = []
|
||||
for eintrag in _liste(data.get("group_mappings")):
|
||||
dn = str(eintrag.get("ad_group_dn") or "").strip()
|
||||
if not dn:
|
||||
continue
|
||||
name = str(eintrag.get("ad_group_name") or "").strip() or dn
|
||||
ziel = str(
|
||||
eintrag.get("app_group_name") or eintrag.get("app_group_id") or ""
|
||||
).strip()
|
||||
# Der Vorgaenger kannte den Sonderwert "admin" (bzw. "Admin" im
|
||||
# Export): diese AD-Gruppe macht Administratoren. Genau dafuer gibt es
|
||||
# hier seit 2.8.0 ein Ziel -- das Administratorkennzeichen.
|
||||
if ziel.lower() == "admin":
|
||||
zuordnungen.append(
|
||||
{"ad_group_dn": dn, "ad_group_name": name, "grants_admin": True}
|
||||
)
|
||||
continue
|
||||
if ziel:
|
||||
offen.append(f"{name} -> {ziel}")
|
||||
if zuordnungen:
|
||||
ergebnis["group_mappings"] = zuordnungen
|
||||
if offen:
|
||||
report.note(
|
||||
"Diese Gruppen-Zuordnungen zeigen auf lokale Gruppen, die nicht mit übernommen "
|
||||
"werden (die Rechteschlüssel sind neu geschnitten): "
|
||||
+ ", ".join(sorted(offen))
|
||||
+ ". Nach dem Anlegen der Gruppen unter Verwaltung -> Verzeichnisdienst "
|
||||
"nachtragen."
|
||||
)
|
||||
return ergebnis
|
||||
|
||||
|
||||
def _freigaben(data: Any, report: Report) -> list[dict[str, Any]]:
|
||||
"""Freigabe-Zuordnungen aus dem Abschnitt "ldap" des alten Exports.
|
||||
|
||||
Der Vorgaenger legt sie unter ``ldap.fileshare_mappings`` ab; beim Lesen
|
||||
einer Installation stehen sie unmittelbar in der Tabelle. Beides landet
|
||||
hier, damit es nur eine Stelle gibt, die die Felder umbenennt.
|
||||
"""
|
||||
if isinstance(data, dict):
|
||||
eintraege = _liste(data.get("fileshare_mappings"))
|
||||
else:
|
||||
eintraege = _liste(data)
|
||||
ergebnis: list[dict[str, Any]] = []
|
||||
ohne_pfad = 0
|
||||
for eintrag in eintraege:
|
||||
# "share_label"/"share_unc" heissen hier "label"/"unc" -- dieselbe
|
||||
# Angabe, kuerzerer Name, weil die Tabelle schon "fileshare" heisst.
|
||||
unc = str(eintrag.get("share_unc") or eintrag.get("unc") or "").strip()
|
||||
gruppe = str(eintrag.get("ad_group_dn") or "").strip()
|
||||
if not unc or not gruppe:
|
||||
ohne_pfad += 1
|
||||
continue
|
||||
beschriftung = str(eintrag.get("share_label") or eintrag.get("label") or "").strip()
|
||||
ergebnis.append(
|
||||
{
|
||||
"ad_group_dn": gruppe,
|
||||
"ad_group_name": str(eintrag.get("ad_group_name") or "").strip() or gruppe,
|
||||
# Ohne Beschriftung der letzte Teil des Pfades -- besser als
|
||||
# eine Freigabe ohne Namen. Beide Trenner: der Vorgaenger
|
||||
# speichert "//server/share", getippt wird "\server\share".
|
||||
"label": beschriftung
|
||||
or unc.replace(chr(92), "/").rstrip("/").rsplit("/", 1)[-1],
|
||||
"unc": unc,
|
||||
}
|
||||
)
|
||||
if ohne_pfad:
|
||||
report.note(
|
||||
f"{ohne_pfad} Freigabe-Zuordnung(en) ohne Verzeichnisgruppe oder UNC-Pfad "
|
||||
"übersprungen."
|
||||
)
|
||||
return ergebnis
|
||||
|
||||
|
||||
def _dhcp(data: Any, report: Report) -> tuple[dict[str, Any], dict[str, str]]:
|
||||
"""DHCP-Teil uebersetzen. Rueckgabe: ``(dhcp, settings)``."""
|
||||
if not isinstance(data, dict):
|
||||
@@ -298,14 +469,14 @@ def _dhcp(data: Any, report: Report) -> tuple[dict[str, Any], dict[str, str]]:
|
||||
for entry in _liste(data.get("reservations")):
|
||||
mac = str(entry.get("mac") or "").strip()
|
||||
if not mac:
|
||||
report.note("Manuelle Reservierung ohne MAC-Adresse uebersprungen.")
|
||||
report.note("Manuelle Reservierung ohne MAC-Adresse übersprungen.")
|
||||
continue
|
||||
reservations.append(
|
||||
{
|
||||
"mac": mac,
|
||||
"ip": str(entry.get("ip") or ""),
|
||||
"hostname": str(entry.get("name") or ""),
|
||||
"comment": "aus altem TESM uebernommen",
|
||||
"comment": "aus altem TESM übernommen",
|
||||
}
|
||||
)
|
||||
|
||||
@@ -322,7 +493,7 @@ def _dhcp(data: Any, report: Report) -> tuple[dict[str, Any], dict[str, str]]:
|
||||
"is_standard": int(bool(definition.get("is_standard"))),
|
||||
}
|
||||
if not gemeinsam["code"] or not gemeinsam["name"]:
|
||||
report.note("DHCP-Option ohne Code oder Namen uebersprungen.")
|
||||
report.note("DHCP-Option ohne Code oder Namen übersprungen.")
|
||||
continue
|
||||
werte = _liste(definition.get("values"))
|
||||
if not werte:
|
||||
@@ -339,7 +510,7 @@ def _dhcp(data: Any, report: Report) -> tuple[dict[str, Any], dict[str, str]]:
|
||||
)
|
||||
if ohne_wert:
|
||||
report.note(
|
||||
f"{ohne_wert} eigene DHCP-Option(en) ohne Wert uebersprungen -- ohne Wert "
|
||||
f"{ohne_wert} eigene DHCP-Option(en) ohne Wert übersprungen – ohne Wert "
|
||||
"gibt es nichts zu senden. Bei Bedarf von Hand anlegen."
|
||||
)
|
||||
|
||||
@@ -382,6 +553,14 @@ def convert(legacy: dict[str, Any]) -> tuple[dict[str, Any], Report]:
|
||||
if devices:
|
||||
payload["devices"] = devices
|
||||
|
||||
freigaben = _freigaben(legacy.get("ldap") or legacy.get("fileshare"), report)
|
||||
if freigaben:
|
||||
payload["fileshare"] = freigaben
|
||||
|
||||
verzeichnis = _verzeichnis(legacy.get("ldap"), report)
|
||||
if verzeichnis:
|
||||
payload["directory"] = verzeichnis
|
||||
|
||||
dhcp, settings = _dhcp(legacy.get("dhcp"), report)
|
||||
if dhcp:
|
||||
payload["dhcp"] = dhcp
|
||||
@@ -405,7 +584,7 @@ def convert(legacy: dict[str, Any]) -> tuple[dict[str, Any], Report]:
|
||||
report.note(
|
||||
"Diese Zugangsdaten sind zugeordnet, fehlen aber in der Datei und bleiben "
|
||||
f"deshalb leer: {', '.join(fehlend)}. Beim Export im alten TESM die "
|
||||
"Kategorie 'Zugangsdaten' mitwaehlen."
|
||||
"Kategorie 'Zugangsdaten' mitwählen."
|
||||
)
|
||||
|
||||
for schluessel, grund in KNOWN_UNSUPPORTED.items():
|
||||
@@ -413,12 +592,12 @@ def convert(legacy: dict[str, Any]) -> tuple[dict[str, Any], Report]:
|
||||
report.note(grund)
|
||||
unbekannt = sorted(set(legacy) - set(SUPPORTED) - set(KNOWN_UNSUPPORTED))
|
||||
if unbekannt:
|
||||
report.note(f"Unbekannte Abschnitte uebersprungen: {', '.join(unbekannt)}.")
|
||||
report.note(f"Unbekannte Abschnitte übersprungen: {', '.join(unbekannt)}.")
|
||||
|
||||
if not payload:
|
||||
raise LegacyTransferError(
|
||||
"Die Datei enthaelt keine Clients, Switche, Zugangsdaten oder DHCP-Daten. "
|
||||
"Beim Export im alten TESM diese Kategorien mitwaehlen."
|
||||
"Die Datei enthält keine Clients, Switche, Zugangsdaten, DHCP-Daten oder "
|
||||
"Freigaben. Beim Export im alten TESM diese Kategorien mitwählen."
|
||||
)
|
||||
return payload, report
|
||||
|
||||
@@ -488,7 +667,7 @@ def _entschluessler(root: Path, report: Report) -> Any:
|
||||
schluesseldatei = root / LEGACY_FERNET_NAME
|
||||
if not schluesseldatei.is_file():
|
||||
report.note(
|
||||
f"{LEGACY_FERNET_NAME} nicht gefunden -- die Passwoerter der Zugangsdaten "
|
||||
f"{LEGACY_FERNET_NAME} nicht gefunden – die Passwörter der Zugangsdaten "
|
||||
"bleiben leer und sind nachzutragen."
|
||||
)
|
||||
return lambda _token: ""
|
||||
@@ -499,7 +678,7 @@ def _entschluessler(root: Path, report: Report) -> Any:
|
||||
fernet = Fernet(schluesseldatei.read_bytes().strip())
|
||||
except (ValueError, TypeError):
|
||||
report.note(
|
||||
f"{LEGACY_FERNET_NAME} ist unlesbar -- die Passwoerter der Zugangsdaten "
|
||||
f"{LEGACY_FERNET_NAME} ist unlesbar – die Passwörter der Zugangsdaten "
|
||||
"bleiben leer und sind nachzutragen."
|
||||
)
|
||||
return lambda _token: ""
|
||||
@@ -515,8 +694,8 @@ def _entschluessler(root: Path, report: Report) -> Any:
|
||||
gescheitert.append(1)
|
||||
if len(gescheitert) == 1:
|
||||
report.note(
|
||||
"Mindestens ein Passwort liess sich mit dem gefundenen Schluessel "
|
||||
"nicht entschluesseln -- es bleibt leer."
|
||||
"Mindestens ein Passwort ließ sich mit dem gefundenen Schlüssel "
|
||||
"nicht entschlüsseln – es bleibt leer."
|
||||
)
|
||||
return ""
|
||||
|
||||
@@ -617,8 +796,9 @@ def read_installation(root: Path) -> tuple[dict[str, Any], Report]:
|
||||
continue
|
||||
payload[abschnitt] = [dict(row) for row in conn.execute(sql).fetchall()]
|
||||
|
||||
entschluesseln_bind = _entschluessler(root, report)
|
||||
if "credentials" in vorhanden:
|
||||
entschluesseln = _entschluessler(root, report)
|
||||
entschluesseln = entschluesseln_bind
|
||||
payload["credentials"] = [
|
||||
{
|
||||
"name": row["name"],
|
||||
@@ -634,6 +814,80 @@ def read_installation(root: Path) -> tuple[dict[str, Any], Report]:
|
||||
else:
|
||||
report.note("Tabelle 'credentials' fehlt in der alten Datenbank.")
|
||||
|
||||
# Der Abschnitt "ldap" -- genau so aufgebaut wie im alten Export, damit
|
||||
# die Uebersetzung beide Wege gleich liest.
|
||||
ldap: dict[str, Any] = {}
|
||||
if "ldap_fileshare_mappings" in vorhanden:
|
||||
freigaben = [
|
||||
dict(row)
|
||||
for row in conn.execute(
|
||||
"SELECT ad_group_dn, ad_group_name, share_label, share_unc "
|
||||
"FROM ldap_fileshare_mappings"
|
||||
).fetchall()
|
||||
]
|
||||
if freigaben:
|
||||
ldap["fileshare_mappings"] = freigaben
|
||||
else:
|
||||
report.note("Tabelle 'ldap_fileshare_mappings' fehlt in der alten Datenbank.")
|
||||
|
||||
if "settings" in vorhanden:
|
||||
# Nur anlegen, wenn etwas drinsteht: ein leerer Abschnitt sieht im
|
||||
# Bericht wie eine Uebernahme aus und ist keine.
|
||||
ldap_einstellungen = {
|
||||
str(row["key"]): str(row["value"])
|
||||
for row in conn.execute(
|
||||
"SELECT key, value FROM settings WHERE key LIKE 'ldap%'"
|
||||
).fetchall()
|
||||
}
|
||||
if ldap_einstellungen:
|
||||
ldap["settings"] = ldap_einstellungen
|
||||
|
||||
if "ldap_group_mappings" in vorhanden:
|
||||
# Der Sonderwert "admin" steht in derselben Spalte wie eine
|
||||
# Gruppen-Id; der Name dahinter ist das, was ueber die
|
||||
# Systemgrenze traegt.
|
||||
gruppen_da = "groups" in vorhanden
|
||||
zuordnungen = [
|
||||
{
|
||||
"ad_group_dn": str(row["ad_group_dn"]),
|
||||
"ad_group_name": str(row["ad_group_name"]),
|
||||
"app_group_name": str(row["ziel"] or ""),
|
||||
}
|
||||
for row in conn.execute(
|
||||
"SELECT m.ad_group_dn, m.ad_group_name, CASE "
|
||||
" WHEN m.app_group_id = 'admin' THEN 'admin' "
|
||||
+ (" ELSE g.name " if gruppen_da else " ELSE NULL ")
|
||||
+ "END AS ziel FROM ldap_group_mappings m"
|
||||
+ (
|
||||
" LEFT JOIN groups g ON g.id = m.app_group_id"
|
||||
if gruppen_da
|
||||
else ""
|
||||
)
|
||||
).fetchall()
|
||||
]
|
||||
if zuordnungen:
|
||||
ldap["group_mappings"] = zuordnungen
|
||||
|
||||
# Das Bind-Konto: erst das Dienstkonto (so liest es auch der alte
|
||||
# Export), sonst die beiden Einstellungen aus aelteren Fassungen.
|
||||
if "service_accounts" in vorhanden:
|
||||
konto = _eine(
|
||||
conn,
|
||||
"SELECT username, password FROM service_accounts WHERE purpose='ldap_bind'",
|
||||
)
|
||||
if konto is not None:
|
||||
ldap["bind_username"] = str(konto["username"] or "")
|
||||
ldap["bind_password"] = entschluesseln_bind(str(konto["password"] or ""))
|
||||
if not ldap.get("bind_username"):
|
||||
aus_einstellungen = (ldap.get("settings") or {}).get("ldap_bind_dn") or ""
|
||||
if aus_einstellungen:
|
||||
ldap["bind_username"] = str(aus_einstellungen)
|
||||
ldap["bind_password"] = entschluesseln_bind(
|
||||
str((ldap.get("settings") or {}).get("ldap_bind_password") or "")
|
||||
)
|
||||
if ldap:
|
||||
payload["ldap"] = ldap
|
||||
|
||||
dhcp = _alt_dhcp(conn, vorhanden)
|
||||
if dhcp:
|
||||
payload["dhcp"] = dhcp
|
||||
@@ -643,10 +897,17 @@ def read_installation(root: Path) -> tuple[dict[str, Any], Report]:
|
||||
payload = {key: value for key, value in payload.items() if value}
|
||||
if not payload:
|
||||
raise LegacyTransferError(
|
||||
f"In {root} stehen keine uebernehmbaren Daten (Clients, Switche, "
|
||||
"Zugangsdaten, DHCP)."
|
||||
f"In {root} stehen keine übernehmbaren Daten (Clients, Switche, "
|
||||
"Zugangsdaten, DHCP, Freigaben)."
|
||||
)
|
||||
for abschnitt, inhalt in payload.items():
|
||||
if abschnitt == "ldap" and isinstance(inhalt, dict):
|
||||
report.took("Freigaben", len(_liste(inhalt.get("fileshare_mappings"))))
|
||||
report.took("Verzeichniseinstellungen", len(inhalt.get("settings") or {}))
|
||||
report.took("Gruppen-Zuordnungen", len(_liste(inhalt.get("group_mappings"))))
|
||||
if inhalt.get("bind_username"):
|
||||
report.took("Bind-Konto", 1)
|
||||
continue
|
||||
report.took(abschnitt, len(inhalt) if isinstance(inhalt, list) else 1)
|
||||
return payload, report
|
||||
|
||||
|
||||
@@ -29,7 +29,7 @@ import sqlite3
|
||||
import threading
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timezone
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Any, Callable
|
||||
|
||||
from tesm_core import audit
|
||||
@@ -44,6 +44,51 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
JOB_KIND_UPDATE = "maintenance.update"
|
||||
JOB_KIND_REBOOT = "maintenance.reboot"
|
||||
JOB_KIND_COMMAND = "maintenance.command"
|
||||
|
||||
#: Vorlagen fuer den Massenbefehl: Name, Befehl, Erklaerung.
|
||||
#:
|
||||
#: ``{sudo}`` wird zur Laufzeit ersetzt -- mit ``sudo -n``, wo es ohne Passwort
|
||||
#: geht, sonst mit ``sudo -S`` und dem hinterlegten Passwort ueber stdin. Wer
|
||||
#: eine Vorlage waehlt, sieht den Befehl im Feld und kann ihn aendern: eine
|
||||
#: Vorlage ist ein Vorschlag, kein Automat.
|
||||
BEFEHLSVORLAGEN: tuple[tuple[str, str, str], ...] = (
|
||||
(
|
||||
"VNC: Streckenverschlüsselung abschalten",
|
||||
'echo "Encryption=AlwaysOff" | {sudo} tee -a /etc/vnc/config.d/common.custom',
|
||||
"Danach bietet RealVNC auch RA2ne und „ohne Authentifizierung“ an – nur damit "
|
||||
"kommt eine Sitzung im Browser zustande. Die Strecke ist dann unverschlüsselt; "
|
||||
"im Netz zwischen Server und Gerät ist sie es ohnehin.",
|
||||
),
|
||||
(
|
||||
"VNC-Dienst neu starten",
|
||||
"{sudo} systemctl restart vncserver-x11-serviced",
|
||||
"Räumt auch eine Sperre ab, die der Server nach zu vielen abgebrochenen "
|
||||
"Verbindungsversuchen gesetzt hat.",
|
||||
),
|
||||
(
|
||||
"VNC: aktuelle Einstellungen zeigen",
|
||||
"cat /etc/vnc/config.d/common.custom 2>/dev/null || echo '(keine eigene Konfiguration)'",
|
||||
"Liest nur – gut, um vor dem Ändern zu sehen, was schon dasteht.",
|
||||
),
|
||||
(
|
||||
"Zeit und Zeitzone",
|
||||
"timedatectl",
|
||||
"Liest nur.",
|
||||
),
|
||||
(
|
||||
"Belegung der Dateisysteme",
|
||||
"df -h",
|
||||
"Liest nur.",
|
||||
),
|
||||
)
|
||||
|
||||
#: Was in einer Befehlszeile nichts zu suchen hat. Nicht als Schutz vor einem
|
||||
#: Menschen mit diesem Recht -- er darf ohnehin alles, was sein Konto auf dem
|
||||
#: Geraet darf --, sondern gegen Unfaelle: ein Zeilenumbruch aus der
|
||||
#: Zwischenablage macht aus einem Befehl unbemerkt zwei.
|
||||
VERBOTENE_ZEICHEN = ("\n", "\r", "\x00")
|
||||
MAX_BEFEHLSLAENGE = 500
|
||||
|
||||
MAX_OUTPUT_LINES = 400
|
||||
CONNECT_TIMEOUT = 25
|
||||
@@ -110,47 +155,47 @@ def is_noise(line: str) -> bool:
|
||||
HINTS: tuple[tuple[str, str], ...] = (
|
||||
(
|
||||
"incorrect password attempt",
|
||||
"Das Passwort aus den Zugangsdaten wurde von 'sudo' abgewiesen. Fuer die "
|
||||
"SSH-Anmeldung hat es gereicht -- 'sudo' auf diesem Geraet verlangt also ein "
|
||||
"Das Passwort aus den Zugangsdaten wurde von 'sudo' abgewiesen. Für die "
|
||||
"SSH-Anmeldung hat es gereicht – 'sudo' auf diesem Gerät verlangt also ein "
|
||||
"anderes Passwort, oder das Konto steht nicht in der sudoers-Datei.",
|
||||
),
|
||||
(
|
||||
"is not in the sudoers file",
|
||||
"Das Wartungskonto darf auf diesem Geraet kein 'sudo' ausfuehren. Auf dem "
|
||||
"Zielgeraet eintragen, z. B. in /etc/sudoers.d/tesm-wartung: "
|
||||
"Das Wartungskonto darf auf diesem Gerät kein 'sudo' ausführen. Auf dem "
|
||||
"Zielgerät eintragen, z. B. in /etc/sudoers.d/tesm-wartung: "
|
||||
"'<benutzer> ALL=(root) /usr/bin/apt-get, /sbin/reboot'. Mit vorangestelltem "
|
||||
"NOPASSWD: entfaellt die Passwortabfrage ganz.",
|
||||
"NOPASSWD: entfällt die Passwortabfrage ganz.",
|
||||
),
|
||||
(
|
||||
"a password is required",
|
||||
"'sudo' verlangt ein Passwort, und in den Zugangsdaten dieses Geraets ist keines "
|
||||
"hinterlegt. Tragen Sie dort das Passwort des Wartungskontos ein -- TESM reicht "
|
||||
"es ueber die bereits verschluesselte SSH-Verbindung an 'sudo' weiter.",
|
||||
"'sudo' verlangt ein Passwort, und in den Zugangsdaten dieses Geräts ist keines "
|
||||
"hinterlegt. Tragen Sie dort das Passwort des Wartungskontos ein | TESM reicht "
|
||||
"es über die bereits verschlüsselte SSH-Verbindung an 'sudo' weiter.",
|
||||
),
|
||||
(
|
||||
"no tty present",
|
||||
"Das Wartungskonto verlangt fuer 'sudo' ein Terminal. In der sudoers-Datei des "
|
||||
"Zielgeraets 'Defaults requiretty' entfernen oder fuer dieses Konto abschalten.",
|
||||
"Das Wartungskonto verlangt für 'sudo' ein Terminal. In der sudoers-Datei des "
|
||||
"Zielgeräts 'Defaults requiretty' entfernen oder für dieses Konto abschalten.",
|
||||
),
|
||||
(
|
||||
"command not found",
|
||||
"Das Kommando gibt es auf dem Zielgeraet nicht. Die Wartung erwartet ein "
|
||||
"Das Kommando gibt es auf dem Zielgerät nicht. Die Wartung erwartet ein "
|
||||
"Debian- oder Ubuntu-System mit apt-get.",
|
||||
),
|
||||
(
|
||||
"Access is denied",
|
||||
"Das Konto darf auf diesem Windows-Geraet keinen Neustart ausloesen. Es muss "
|
||||
"Das Konto darf auf diesem Windows-Gerät keinen Neustart auslösen. Es muss "
|
||||
"Mitglied der lokalen Administratoren sein.",
|
||||
),
|
||||
(
|
||||
"is not recognized as an internal or external command",
|
||||
"Das Kommando gibt es auf diesem Geraet nicht. Ist bei den Zugangsdaten die "
|
||||
"richtige Kategorie gewaehlt (Linux-Client bzw. Windows-Client)?",
|
||||
"Das Kommando gibt es auf diesem Gerät nicht. Ist bei den Zugangsdaten die "
|
||||
"richtige Kategorie gewählt (Linux-Client bzw. Windows-Client)?",
|
||||
),
|
||||
(
|
||||
"Could not get lock",
|
||||
"Ein anderer Paketvorgang laeuft bereits auf dem Zielgeraet (etwa "
|
||||
"unattended-upgrades). Spaeter erneut versuchen.",
|
||||
"Ein anderer Paketvorgang läuft bereits auf dem Zielgerät (etwa "
|
||||
"unattended-upgrades). Später erneut versuchen.",
|
||||
),
|
||||
)
|
||||
|
||||
@@ -231,16 +276,89 @@ def update_job(
|
||||
|
||||
|
||||
def list_jobs(
|
||||
conn: sqlite3.Connection, *, kinds: tuple[str, ...] = (JOB_KIND_UPDATE, JOB_KIND_REBOOT), limit: int = 60
|
||||
conn: sqlite3.Connection,
|
||||
*,
|
||||
kinds: tuple[str, ...] = (JOB_KIND_UPDATE, JOB_KIND_REBOOT, JOB_KIND_COMMAND),
|
||||
limit: int = 60,
|
||||
) -> list[dict[str, Any]]:
|
||||
placeholders = ",".join("?" * len(kinds))
|
||||
return Database.all(
|
||||
conn,
|
||||
f"SELECT * FROM jobs WHERE kind IN ({placeholders}) ORDER BY created_at DESC LIMIT ?",
|
||||
# ``rowid`` als zweites Ordnungsmerkmal: ``created_at`` hat
|
||||
# Sekundenaufloesung, und ein Massenbefehl legt vierzig Auftraege in
|
||||
# derselben Sekunde an. Ohne das entschied bei Gleichstand die
|
||||
# Speicherreihenfolge -- die Liste sah bei jedem Aufruf anders aus.
|
||||
f"SELECT * FROM jobs WHERE kind IN ({placeholders}) "
|
||||
"ORDER BY created_at DESC, rowid DESC LIMIT ?",
|
||||
(*kinds, limit),
|
||||
)
|
||||
|
||||
|
||||
def laufende_und_letzte(
|
||||
conn: sqlite3.Connection,
|
||||
*,
|
||||
kinds: tuple[str, ...] = (JOB_KIND_UPDATE, JOB_KIND_REBOOT, JOB_KIND_COMMAND),
|
||||
limit: int = 30,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Alles, was laeuft oder eben fertig wurde -- und dazu die letzten Auftraege.
|
||||
|
||||
Fuer die Statusabfrage der Wartungsseite. Eine feste Obergrenze allein
|
||||
reicht dort nicht, und das war ein echter Fehler: ein Massenbefehl auf
|
||||
vierzig Geraete legt vierzig Auftraege an, ``LIMIT 30`` schnitt zehn davon
|
||||
ab -- und weil alle denselben Zeitstempel auf die Sekunde tragen, waren es
|
||||
zehn willkuerliche. Auf der Seite sah das aus, als haetten die letzten
|
||||
Geraete den Befehl nie bekommen: sie liefen, ihre Ausgabe kam nur nie an.
|
||||
|
||||
Was laeuft, faellt hier deshalb nie heraus, und was gerade fertig wurde,
|
||||
behaelt seine letzten Zeilen so lange wie ``eben_fertig`` es zusagt.
|
||||
"""
|
||||
platzhalter = ",".join("?" * len(kinds))
|
||||
schwelle = (
|
||||
datetime.now(timezone.utc) - timedelta(seconds=EBEN_FERTIG_SEKUNDEN)
|
||||
).isoformat(timespec="seconds")
|
||||
gesammelt: dict[str, dict[str, Any]] = {}
|
||||
for sql, params in (
|
||||
(
|
||||
f"SELECT * FROM jobs WHERE kind IN ({platzhalter}) "
|
||||
"AND state IN ('pending','running') ORDER BY created_at DESC, rowid DESC",
|
||||
(*kinds,),
|
||||
),
|
||||
(
|
||||
f"SELECT * FROM jobs WHERE kind IN ({platzhalter}) "
|
||||
"AND COALESCE(finished_at, updated_at) >= ? ORDER BY created_at DESC, rowid DESC",
|
||||
(*kinds, schwelle),
|
||||
),
|
||||
(
|
||||
f"SELECT * FROM jobs WHERE kind IN ({platzhalter}) "
|
||||
"ORDER BY created_at DESC, rowid DESC LIMIT ?",
|
||||
(*kinds, limit),
|
||||
),
|
||||
):
|
||||
for job in Database.all(conn, sql, params):
|
||||
gesammelt.setdefault(str(job["id"]), job)
|
||||
return sorted(
|
||||
gesammelt.values(), key=lambda job: str(job["created_at"] or ""), reverse=True
|
||||
)
|
||||
|
||||
|
||||
def letzter_auftrag_je_geraet(
|
||||
conn: sqlite3.Connection,
|
||||
*,
|
||||
kinds: tuple[str, ...] = (JOB_KIND_UPDATE, JOB_KIND_REBOOT, JOB_KIND_COMMAND),
|
||||
limit: int = 400,
|
||||
) -> dict[str, dict[str, Any]]:
|
||||
"""Zu jedem Geraetenamen sein neuester Auftrag.
|
||||
|
||||
Damit die Wartungsseite schon beim Aufbau zeigt, was wo laeuft -- und nicht
|
||||
erst, wenn die erste Statusabfrage zurueck ist. Der Schluessel ist der
|
||||
Name, denn genau den traegt ein Auftrag als ``subject``.
|
||||
"""
|
||||
stand: dict[str, dict[str, Any]] = {}
|
||||
for job in list_jobs(conn, kinds=kinds, limit=limit):
|
||||
stand.setdefault(str(job["subject"]), job)
|
||||
return stand
|
||||
|
||||
|
||||
def get_job(conn: sqlite3.Connection, job_id: str) -> dict[str, Any] | None:
|
||||
return Database.one(conn, "SELECT * FROM jobs WHERE id=?", (job_id,))
|
||||
|
||||
@@ -256,8 +374,8 @@ def mark_orphaned_jobs(conn: sqlite3.Connection) -> int:
|
||||
"UPDATE jobs SET state='unknown', message=?, finished_at=?, updated_at=? "
|
||||
"WHERE state IN ('pending','running')",
|
||||
(
|
||||
"Der Dienst wurde waehrend des Laufs neu gestartet. Was auf dem Zielgeraet "
|
||||
"passiert ist, laesst sich von hier nicht mehr feststellen.",
|
||||
"Der Dienst wurde während des Laufs neu gestartet. Was auf dem Zielgerät "
|
||||
"passiert ist, lässt sich von hier nicht mehr feststellen.",
|
||||
_now(),
|
||||
_now(),
|
||||
),
|
||||
@@ -293,7 +411,7 @@ def _run_remote_command(
|
||||
channel.sendall(stdin_text.encode("utf-8"))
|
||||
channel.shutdown_write()
|
||||
except Exception as exc: # noqa: BLE001
|
||||
on_line(f"[Eingabe konnte nicht uebergeben werden: {exc}]")
|
||||
on_line(f"[Eingabe konnte nicht übergeben werden: {exc}]")
|
||||
deadline = time.monotonic() + timeout
|
||||
pending = b""
|
||||
while True:
|
||||
@@ -308,7 +426,7 @@ def _run_remote_command(
|
||||
elif channel.exit_status_ready():
|
||||
break
|
||||
elif time.monotonic() > deadline:
|
||||
on_line("[Zeitlimit ueberschritten -- Verbindung wird getrennt]")
|
||||
on_line("[Zeitlimit überschritten | Verbindung wird getrennt]")
|
||||
channel.close()
|
||||
return 124
|
||||
else:
|
||||
@@ -341,14 +459,14 @@ def _sudo_mode(
|
||||
except Exception: # noqa: BLE001 - dann eben der Weg mit Passwort
|
||||
status = 1
|
||||
if status == 0:
|
||||
on_line("sudo ist ohne Passwort moeglich.")
|
||||
on_line("sudo ist ohne Passwort möglich.")
|
||||
return SUDO_PASSWORDLESS, ""
|
||||
if not password:
|
||||
on_line(
|
||||
"sudo verlangt ein Passwort, in den Zugangsdaten ist keines hinterlegt."
|
||||
)
|
||||
return SUDO_PASSWORDLESS, ""
|
||||
on_line("sudo verlangt ein Passwort -- es wird aus den Zugangsdaten uebergeben.")
|
||||
on_line("sudo verlangt ein Passwort – es wird aus den Zugangsdaten übergeben.")
|
||||
return SUDO_WITH_PASSWORD, password + "\n"
|
||||
|
||||
|
||||
@@ -411,7 +529,7 @@ def run_update(
|
||||
)
|
||||
if status != 0:
|
||||
failed = True
|
||||
emit(f"Schritt {label!r} endete mit Rueckgabewert {status}.")
|
||||
emit(f"Schritt {label!r} endete mit Rückgabewert {status}.")
|
||||
problem = hint_for("\n".join(collected))
|
||||
if problem:
|
||||
emit("")
|
||||
@@ -447,6 +565,149 @@ def run_update(
|
||||
)
|
||||
|
||||
|
||||
#: So lange gilt ein Auftrag als "eben fertig geworden" und schickt seine
|
||||
#: letzten Zeilen noch mit. Ohne das verschwaende die Ausgabe genau in dem
|
||||
#: Augenblick, in dem sie interessant wird: wenn der Befehl durch ist.
|
||||
EBEN_FERTIG_SEKUNDEN = 120
|
||||
|
||||
#: So viele Zeilen gehen ins Mitlesen. Genug, um zu sehen, was gerade
|
||||
#: passiert; wenig genug, dass eine Abfrage alle zwei Sekunden nichts kostet.
|
||||
MITLESEN_ZEILEN = 40
|
||||
|
||||
|
||||
def letzte_zeilen(ausgabe: str, anzahl: int = MITLESEN_ZEILEN) -> str:
|
||||
"""Das Ende einer Ausgabe -- das ist der Teil, der gerade entsteht."""
|
||||
zeilen = [zeile for zeile in ausgabe.splitlines() if zeile.strip()]
|
||||
return "\n".join(zeilen[-anzahl:])
|
||||
|
||||
|
||||
def eben_fertig(job: dict[str, Any], sekunden: int = EBEN_FERTIG_SEKUNDEN) -> bool:
|
||||
"""Ist dieser Auftrag gerade eben fertig geworden?"""
|
||||
if job.get("state") in {"pending", "running"}:
|
||||
return True
|
||||
stempel = str(job.get("finished_at") or job.get("updated_at") or "")
|
||||
if not stempel:
|
||||
return False
|
||||
try:
|
||||
moment = datetime.fromisoformat(stempel)
|
||||
except ValueError:
|
||||
return False
|
||||
if moment.tzinfo is None:
|
||||
moment = moment.replace(tzinfo=timezone.utc)
|
||||
return (datetime.now(timezone.utc) - moment).total_seconds() < sekunden
|
||||
|
||||
|
||||
def befehl_pruefen(befehl: str) -> str:
|
||||
"""Gibt den bereinigten Befehl zurueck -- oder wirft mit Klartext.
|
||||
|
||||
Eine eigene Funktion, weil genau diese Frage sich pruefen lassen muss,
|
||||
ohne ein Geraet zu brauchen.
|
||||
"""
|
||||
sauber = (befehl or "").strip()
|
||||
if not sauber:
|
||||
raise ValueError("Es wurde kein Befehl eingegeben.")
|
||||
if len(sauber) > MAX_BEFEHLSLAENGE:
|
||||
raise ValueError(
|
||||
f"Der Befehl ist zu lang (mehr als {MAX_BEFEHLSLAENGE} Zeichen). "
|
||||
"Für längere Abläufe ist ein Skript auf dem Gerät der bessere Weg."
|
||||
)
|
||||
for zeichen in VERBOTENE_ZEICHEN:
|
||||
if zeichen in sauber:
|
||||
raise ValueError(
|
||||
"Der Befehl enthält einen Zeilenumbruch. Hier läuft genau eine "
|
||||
"Befehlszeile – alles danach würde stillschweigend mitlaufen."
|
||||
)
|
||||
return sauber
|
||||
|
||||
|
||||
def run_command(
|
||||
database: Any,
|
||||
*,
|
||||
job_id: str,
|
||||
device: dict[str, Any],
|
||||
username: str,
|
||||
password: str,
|
||||
befehl: str,
|
||||
timeout: int,
|
||||
) -> None:
|
||||
"""Fuehrt **eine** Befehlszeile auf einem Geraet aus. Eigener Thread.
|
||||
|
||||
Bewusst ohne Deutung des Ergebnisses: was der Befehl tut, weiss der
|
||||
Mensch, der ihn geschrieben hat. Festgehalten wird, was zurueckkam und mit
|
||||
welchem Rueckgabewert -- mehr kann ein Werkzeug hier nicht beitragen, und
|
||||
weniger waere zu wenig.
|
||||
"""
|
||||
name = str(device.get("name") or device.get("ip"))
|
||||
|
||||
def emit(line: str) -> None:
|
||||
stamp = datetime.now().strftime("%H:%M:%S")
|
||||
update_job(database, job_id, append_output=f"{stamp} {line}\n")
|
||||
|
||||
update_job(
|
||||
database, job_id, state="running", progress=5, message="Verbindung wird aufgebaut"
|
||||
)
|
||||
try:
|
||||
with database.session() as conn:
|
||||
transport = open_transport(
|
||||
conn,
|
||||
host=str(device["ip"]),
|
||||
port=int(device.get("ssh_port") or 22),
|
||||
username=username,
|
||||
password=password,
|
||||
)
|
||||
except SshError as exc:
|
||||
emit(f"FEHLER: {exc}")
|
||||
update_job(database, job_id, state="failed", message=str(exc), finished=True)
|
||||
return
|
||||
|
||||
status = 1
|
||||
try:
|
||||
sudo, sudo_stdin = _sudo_mode(transport, password, emit)
|
||||
# Nur wer {sudo} benutzt, bekommt es auch: ein Befehl ohne diese Marke
|
||||
# laeuft als der angemeldete Benutzer, und das ist die richtige
|
||||
# Vorgabe -- Rechte holt man sich hier ausdruecklich.
|
||||
ausgefuehrt = befehl.replace("{sudo}", sudo) if "{sudo}" in befehl else befehl
|
||||
emit(f"$ {ausgefuehrt}")
|
||||
update_job(database, job_id, progress=40, message="Befehl läuft")
|
||||
status = _run_remote_command(
|
||||
transport,
|
||||
ausgefuehrt,
|
||||
timeout=timeout,
|
||||
on_line=emit,
|
||||
stdin_text=sudo_stdin if "{sudo}" in befehl else "",
|
||||
)
|
||||
except Exception as exc: # noqa: BLE001 - der Auftrag soll das Ergebnis tragen
|
||||
emit(f"FEHLER: {exc}")
|
||||
finally:
|
||||
try:
|
||||
transport.close()
|
||||
except Exception: # noqa: BLE001
|
||||
pass
|
||||
|
||||
emit(COMPLETION_MARKER)
|
||||
update_job(
|
||||
database,
|
||||
job_id,
|
||||
state="succeeded" if status == 0 else "failed",
|
||||
progress=100,
|
||||
message=(
|
||||
"Befehl ausgeführt."
|
||||
if status == 0
|
||||
else f"Der Befehl endete mit Rückgabewert {status}."
|
||||
),
|
||||
finished=True,
|
||||
)
|
||||
with database.session() as conn, database.transaction(conn):
|
||||
audit.log(
|
||||
conn,
|
||||
actor="system",
|
||||
action="maintenance.command_finished",
|
||||
target=name,
|
||||
detail=f"Rückgabewert {status}",
|
||||
severity="notice" if status == 0 else "warning",
|
||||
)
|
||||
|
||||
|
||||
def _reboot_windows(
|
||||
database: Any,
|
||||
*,
|
||||
@@ -457,6 +718,7 @@ def _reboot_windows(
|
||||
actor: str,
|
||||
emit: Callable[[str], None],
|
||||
elapsed: Callable[[], int],
|
||||
live: Any = None,
|
||||
) -> bool:
|
||||
"""Neustart eines Windows-Geraets ueber RPC. ``True`` = weiter warten.
|
||||
|
||||
@@ -465,7 +727,7 @@ def _reboot_windows(
|
||||
Rueckkehr danach ist dasselbe -- und das steht beim Aufrufer.
|
||||
"""
|
||||
host = str(device["ip"])
|
||||
emit(f"Windows-Geraet -- Neustart ueber RPC an {host} (Port {WINDOWS_PROBE_PORT}).")
|
||||
emit(f"Windows-Gerät | Neustart über RPC an {host} (Port {WINDOWS_PROBE_PORT}).")
|
||||
result = windowsops.reboot(host, username, password)
|
||||
|
||||
for line in result.output.splitlines():
|
||||
@@ -487,6 +749,7 @@ def _reboot_windows(
|
||||
duration_ms=elapsed(),
|
||||
detail=detail,
|
||||
method=METHOD_RPC,
|
||||
live=live,
|
||||
)
|
||||
return False
|
||||
|
||||
@@ -501,6 +764,7 @@ def run_reboot(
|
||||
wait_seconds: int,
|
||||
actor: str = "system",
|
||||
method: str = METHOD_SSH,
|
||||
live: Any = None,
|
||||
) -> None:
|
||||
"""Loest einen Neustart aus und wartet auf die Rueckkehr.
|
||||
|
||||
@@ -553,6 +817,7 @@ def run_reboot(
|
||||
actor=actor,
|
||||
emit=emit,
|
||||
elapsed=elapsed,
|
||||
live=live,
|
||||
):
|
||||
return
|
||||
else:
|
||||
@@ -576,19 +841,20 @@ def run_reboot(
|
||||
duration_ms=elapsed(),
|
||||
detail=str(exc),
|
||||
method=method,
|
||||
live=live,
|
||||
)
|
||||
return
|
||||
|
||||
sudo, sudo_stdin = _sudo_mode(transport, password, emit)
|
||||
command = REBOOT_COMMAND.format(sudo=sudo)
|
||||
try:
|
||||
emit(f"Neustart wird ausgeloest ({command})")
|
||||
emit(f"Neustart wird ausgelöst ({command})")
|
||||
try:
|
||||
_run_remote_command(
|
||||
transport, command, timeout=20, on_line=capture, stdin_text=sudo_stdin
|
||||
)
|
||||
except Exception as exc: # noqa: BLE001 - Abbruch ist hier normal
|
||||
emit(f"Verbindung getrennt ({exc}) -- das ist beim Neustart zu erwarten.")
|
||||
emit(f"Verbindung getrennt ({exc}) – das ist beim Neustart zu erwarten.")
|
||||
finally:
|
||||
try:
|
||||
transport.close()
|
||||
@@ -618,10 +884,11 @@ def run_reboot(
|
||||
duration_ms=elapsed(),
|
||||
detail=problem,
|
||||
method=method,
|
||||
live=live,
|
||||
)
|
||||
return
|
||||
|
||||
update_job(database, job_id, progress=40, message="Warte auf Rueckkehr des Geraets")
|
||||
update_job(database, job_id, progress=40, message="Warte auf Rückkehr des Geräts")
|
||||
time.sleep(REBOOT_GRACE_SECONDS)
|
||||
deadline = time.monotonic() + wait_seconds
|
||||
ssh_port = probe_port
|
||||
@@ -632,7 +899,7 @@ def run_reboot(
|
||||
# Zeitueberschreitung, obwohl das Geraet zurueck war. Ausserdem ist
|
||||
# "SSH antwortet wieder" die Aussage, auf die es hier ankommt.
|
||||
if tcp_reachable(host, ssh_port):
|
||||
emit(f"Geraet ist wieder erreichbar (Port {ssh_port} antwortet).")
|
||||
emit(f"Gerät ist wieder erreichbar (Port {ssh_port} antwortet).")
|
||||
update_job(
|
||||
database,
|
||||
job_id,
|
||||
@@ -656,8 +923,9 @@ def run_reboot(
|
||||
result="ok",
|
||||
actor=actor,
|
||||
duration_ms=elapsed(),
|
||||
detail="Neustart ueber SSH, Geraet ist zurueckgekehrt.",
|
||||
detail="Neustart über SSH, Gerät ist zurückgekehrt.",
|
||||
method=method,
|
||||
live=live,
|
||||
)
|
||||
return
|
||||
time.sleep(REBOOT_POLL_SECONDS)
|
||||
@@ -668,7 +936,7 @@ def run_reboot(
|
||||
job_id,
|
||||
state="failed",
|
||||
progress=100,
|
||||
message=f"Geraet nicht zurueckgekehrt ({host}:{ssh_port} antwortet nicht).",
|
||||
message=f"Gerät nicht zurückgekehrt ({host}:{ssh_port} antwortet nicht).",
|
||||
finished=True,
|
||||
)
|
||||
_log_restart(
|
||||
@@ -678,11 +946,12 @@ def run_reboot(
|
||||
actor=actor,
|
||||
duration_ms=elapsed(),
|
||||
detail=(
|
||||
f"Neustart ausgeloest, aber {host}:{ssh_port} hat innerhalb von "
|
||||
f"Neustart ausgelöst, aber {host}:{ssh_port} hat innerhalb von "
|
||||
f"{wait_seconds}s nicht wieder geantwortet."
|
||||
),
|
||||
method=method,
|
||||
)
|
||||
live=live,
|
||||
)
|
||||
|
||||
|
||||
def _log_restart(
|
||||
@@ -694,6 +963,7 @@ def _log_restart(
|
||||
duration_ms: int,
|
||||
detail: str,
|
||||
method: str = METHOD_SSH,
|
||||
live: Any = None,
|
||||
) -> None:
|
||||
"""Traegt den Neustart in die geraeteweite Neustart-Historie ein.
|
||||
|
||||
@@ -716,9 +986,10 @@ def _log_restart(
|
||||
duration_ms=duration_ms,
|
||||
detail=detail,
|
||||
method=method,
|
||||
live=live,
|
||||
)
|
||||
except Exception: # noqa: BLE001 - Protokollieren darf den Auftrag nie kippen
|
||||
logger.exception("Neustart konnte nicht protokolliert werden (Geraet %s)", device_id)
|
||||
logger.exception("Neustart konnte nicht protokolliert werden (Gerät %s)", device_id)
|
||||
|
||||
|
||||
def start_background(target: Callable[..., None], **kwargs: Any) -> None:
|
||||
|
||||
@@ -115,7 +115,7 @@ def ping(host: str, *, timeout: int = PING_TIMEOUT_SECONDS) -> PingResult:
|
||||
check=False,
|
||||
)
|
||||
except subprocess.TimeoutExpired:
|
||||
return PingResult(False, detail="Zeitlimit ueberschritten")
|
||||
return PingResult(False, detail="Zeitlimit überschritten")
|
||||
except OSError as exc:
|
||||
return PingResult(False, detail=str(exc))
|
||||
|
||||
@@ -156,6 +156,16 @@ class LiveLog:
|
||||
def info(self, scope: str, message: str) -> None:
|
||||
self.write("INFO", scope, message)
|
||||
|
||||
def notice(self, scope: str, message: str) -> None:
|
||||
"""Etwas ist passiert, und es ist in Ordnung -- aber es gehoert gesehen.
|
||||
|
||||
Zwischen "laeuft" und "stimmt etwas nicht" fehlte eine Stufe. Ein
|
||||
Geraet, das neu gestartet wird, ist kein Fehler; es einfach als INFO
|
||||
zwischen zweihundert Erreichbarkeitsmeldungen zu stellen heisst aber,
|
||||
dass es niemand findet.
|
||||
"""
|
||||
self.write("HINWEIS", scope, message)
|
||||
|
||||
def warning(self, scope: str, message: str) -> None:
|
||||
self.write("WARNUNG", scope, message)
|
||||
|
||||
@@ -274,10 +284,43 @@ METHOD_SSH = "ssh"
|
||||
METHOD_RPC = "rpc"
|
||||
METHOD_LABELS = {
|
||||
METHOD_POE: "PoE-Port",
|
||||
METHOD_SSH: "Neustart ueber SSH",
|
||||
METHOD_RPC: "Neustart ueber RPC",
|
||||
METHOD_SSH: "Neustart über SSH",
|
||||
METHOD_RPC: "Neustart über RPC",
|
||||
}
|
||||
|
||||
#: Wer den Neustart ausgeloest hat -- in Worten statt in Schluesseln.
|
||||
#:
|
||||
#: Die Schluessel stehen so in der Datenbank und bleiben dort; eine Spalte
|
||||
#: umzuschreiben, um eine Anzeige zu verbessern, waere der falsche Weg. Die
|
||||
#: Uebersetzung gehoert an genau eine Stelle, sonst heisst derselbe Vorgang im
|
||||
#: Protokoll "auto" und auf der Seite "automatisch".
|
||||
TRIGGER_LABELS = {
|
||||
"auto": "automatisch",
|
||||
"manuell": "manuell",
|
||||
"maintenance": "aus der Wartung",
|
||||
}
|
||||
|
||||
|
||||
def trigger_label(trigger: str) -> str:
|
||||
"""Der Ausloeser als Wort -- unbekannte Schluessel bleiben, wie sie sind."""
|
||||
return TRIGGER_LABELS.get(str(trigger or ""), str(trigger or "unbekannt"))
|
||||
|
||||
|
||||
def _geraet_kurz(conn: sqlite3.Connection, device_id: int) -> str:
|
||||
"""``Name (IP)`` fuer das laufende Protokoll.
|
||||
|
||||
Die Neustart-Funktionen bekommen nur eine Kennung; im Protokoll steht aber
|
||||
eine Zeile, die ein Mensch liest -- und "Geraet 17 wird neu gestartet"
|
||||
hilft niemandem.
|
||||
"""
|
||||
row = conn.execute("SELECT name, ip FROM devices WHERE id=?", (int(device_id),)).fetchone()
|
||||
if row is None:
|
||||
return f"Gerät {device_id}"
|
||||
name = str(row["name"] or "").strip() or f"Gerät {device_id}"
|
||||
ip = str(row["ip"] or "").strip()
|
||||
return f"{name} ({ip})" if ip else name
|
||||
|
||||
|
||||
#: Zustand zwischen ausgeloestem Neustart und Rueckkehr.
|
||||
#:
|
||||
#: Ohne ihn blieb die Kachel auf "online" stehen: der Zustand wechselt erst
|
||||
@@ -303,6 +346,7 @@ def record_restart(
|
||||
duration_ms: int,
|
||||
detail: str,
|
||||
method: str = METHOD_POE,
|
||||
live: Any = None,
|
||||
) -> None:
|
||||
"""Schreibt einen Neustart ins Protokoll -- unabhaengig davon, wie er lief.
|
||||
|
||||
@@ -311,6 +355,12 @@ def record_restart(
|
||||
nur in ``method``. Vorher gab es nur die PoE-Haelfte, und ein per Wartung
|
||||
neu gestartetes Geraet tauchte in der Neustart-Historie ueberhaupt nicht
|
||||
auf.
|
||||
|
||||
Mit ``live`` steht das Ergebnis auch im laufenden Protokoll. Diese
|
||||
Funktion ist der Trichter, durch den **jeder** Neustart laeuft -- PoE
|
||||
automatisch wie von Hand, SSH und RPC aus der Wartung. Die Zeile hier zu
|
||||
schreiben statt bei jeder Quelle einzeln ist der Unterschied zwischen
|
||||
"alle Wege sind abgedeckt" und "alle Wege, an die jemand gedacht hat".
|
||||
"""
|
||||
conn.execute(
|
||||
"INSERT INTO restart_events "
|
||||
@@ -318,6 +368,17 @@ def record_restart(
|
||||
"VALUES (?,?,?,?,?,?,?,?,?)",
|
||||
(device_id, _now(), trigger, actor, action, result, duration_ms, detail[:8000], method),
|
||||
)
|
||||
if live is not None:
|
||||
wer = _geraet_kurz(conn, device_id)
|
||||
weg = METHOD_LABELS.get(method, method)
|
||||
if result == "ok":
|
||||
live.info("neustart", f"{wer}: Neustart über {weg} erfolgreich")
|
||||
elif result == "skipped":
|
||||
# Warnstufe: ein Geraet, das haette neu gestartet werden sollen und
|
||||
# es nicht wurde, ist kein Hinweis am Rande.
|
||||
live.warning("neustart", f"{wer}: Neustart nicht ausgeführt – {detail}")
|
||||
else:
|
||||
live.error("neustart", f"{wer}: Neustart fehlgeschlagen – {detail}")
|
||||
if result == "ok":
|
||||
# Hier faellt nur noch der Zaehler und der Zeitstempel an. Den Zustand
|
||||
# setzt ``mark_restarting`` schon beim Ausloesen -- dieses Ergebnis
|
||||
@@ -330,7 +391,14 @@ def record_restart(
|
||||
)
|
||||
|
||||
|
||||
def mark_restarting(conn: sqlite3.Connection, device_id: int) -> None:
|
||||
def mark_restarting(
|
||||
conn: sqlite3.Connection,
|
||||
device_id: int,
|
||||
*,
|
||||
live: Any = None,
|
||||
trigger: str = "",
|
||||
method: str = METHOD_POE,
|
||||
) -> None:
|
||||
"""Setzt den Zustand auf "startet neu" -- beim **Ausloesen**, nicht am Ende.
|
||||
|
||||
Ein Neustart ueber SSH oder RPC laeuft als Auftrag: Kommando schicken, dann
|
||||
@@ -342,6 +410,12 @@ def mark_restarting(conn: sqlite3.Connection, device_id: int) -> None:
|
||||
Absicht. Und ohne ``last_restart_at``: das Kulanzfenster laeuft ab dem
|
||||
Zeitpunkt, an dem der Neustart tatsaechlich ausgeloest wurde
|
||||
(``record_restart``).
|
||||
|
||||
Mit ``live`` geht der Beginn ins laufende Protokoll -- als Hinweis, nicht
|
||||
als Warnung: ein Neustart ist kein Stoerfall, aber er erklaert die Luecke,
|
||||
die gleich in der Erreichbarkeit steht. Dass die Zeile hier entsteht und
|
||||
nicht bei den Aufrufern, ist Absicht: dies ist der Punkt, an dem ein
|
||||
Neustart **ausgeloest** wird, gleich von welcher Quelle.
|
||||
"""
|
||||
conn.execute(
|
||||
"""
|
||||
@@ -355,6 +429,12 @@ def mark_restarting(conn: sqlite3.Connection, device_id: int) -> None:
|
||||
""",
|
||||
(device_id, STATE_RESTARTING, _now(), _now()),
|
||||
)
|
||||
if live is not None:
|
||||
weg = METHOD_LABELS.get(method, method)
|
||||
woher = f" ({trigger_label(trigger)})" if trigger else ""
|
||||
live.notice(
|
||||
"neustart", f"{_geraet_kurz(conn, device_id)} wird neu gestartet – {weg}{woher}"
|
||||
)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
@@ -402,11 +482,13 @@ def restart_device_poe(
|
||||
# wie fuer den automatischen Neustart, denn beide laufen hier durch.
|
||||
if port and switch_ip:
|
||||
with write():
|
||||
mark_restarting(conn, int(device["id"]))
|
||||
mark_restarting(
|
||||
conn, int(device["id"]), live=live, trigger=trigger, method=METHOD_POE
|
||||
)
|
||||
|
||||
if not port or not switch_ip:
|
||||
message = (
|
||||
"Kein Switch oder kein Port zugeordnet -- ein PoE-Neustart ist nicht moeglich."
|
||||
"Kein Switch oder kein Port zugeordnet – ein PoE-Neustart ist nicht möglich."
|
||||
)
|
||||
with write():
|
||||
record_restart(
|
||||
@@ -418,11 +500,12 @@ def restart_device_poe(
|
||||
actor=actor,
|
||||
duration_ms=0,
|
||||
detail=message,
|
||||
live=live,
|
||||
)
|
||||
return {"ok": False, "message": message, "transcript": ""}
|
||||
|
||||
if not credential_id:
|
||||
message = f"Fuer Switch {device.get('switch_hostname') or switch_ip} sind keine Zugangsdaten hinterlegt."
|
||||
message = f"Für Switch {device.get('switch_hostname') or switch_ip} sind keine Zugangsdaten hinterlegt."
|
||||
with write():
|
||||
record_restart(
|
||||
conn,
|
||||
@@ -462,9 +545,8 @@ def restart_device_poe(
|
||||
actor=actor,
|
||||
duration_ms=duration,
|
||||
detail=str(exc),
|
||||
live=live,
|
||||
)
|
||||
if live:
|
||||
live.error("poe", f"{name}: {exc}")
|
||||
return {"ok": False, "message": str(exc), "transcript": ""}
|
||||
|
||||
duration = int((time.monotonic() - started) * 1000)
|
||||
@@ -478,15 +560,11 @@ def restart_device_poe(
|
||||
actor=actor,
|
||||
duration_ms=duration,
|
||||
detail=(outcome.error or "; ".join(outcome.steps))[:8000],
|
||||
live=live,
|
||||
)
|
||||
if live:
|
||||
if outcome.ok:
|
||||
live.info("poe", f"{name}: PoE-Neustart auf {device.get('switch_hostname')} {port} erfolgreich")
|
||||
else:
|
||||
live.error("poe", f"{name}: PoE-Neustart fehlgeschlagen -- {outcome.error}")
|
||||
return {
|
||||
"ok": outcome.ok,
|
||||
"message": outcome.error or "PoE-Neustart durchgefuehrt.",
|
||||
"message": outcome.error or "PoE-Neustart durchgeführt.",
|
||||
"transcript": outcome.transcript,
|
||||
}
|
||||
|
||||
@@ -507,6 +585,48 @@ class SweepReport:
|
||||
duration_ms: int = 0
|
||||
|
||||
|
||||
def merke_vnc(conn: sqlite3.Connection, device_id: int, offen: bool, ziel: str = "") -> None:
|
||||
"""Haelt fest, ob dort ein VNC-Server antwortet -- und unter welchem Ziel.
|
||||
|
||||
Das Ziel gehoert dazu, weil die Statusuebersicht die Verbindung unmittelbar
|
||||
verlinkt. Es aus dem Namen neu herzuleiten hiesse, je Kachel und
|
||||
Seitenaufbau eine Namensaufloesung zu versuchen; hier steht es einmal
|
||||
geschrieben, von dem, der ohnehin gefragt hat.
|
||||
|
||||
Die Anmeldeverfahren bleiben unberuehrt: sie kommen aus einer anderen
|
||||
Quelle (:func:`merke_verfahren`) und haetten hier nur den Wert "nicht
|
||||
gefragt" -- der wuerde eine gute Messung ueberschreiben.
|
||||
"""
|
||||
conn.execute(
|
||||
"UPDATE device_status SET vnc_open=?, vnc_checked_at=?, vnc_target=? WHERE device_id=?",
|
||||
(1 if offen else 0, _now(), ziel, device_id),
|
||||
)
|
||||
|
||||
|
||||
def merke_verfahren(
|
||||
conn: sqlite3.Connection, device_id: int, verfahren: tuple[int, ...], absage: str = ""
|
||||
) -> None:
|
||||
"""Haelt fest, wie sich der VNC-Server authentifizieren will.
|
||||
|
||||
Mit Zeitstempel, denn diese Messung kostet eine angefangene
|
||||
Protokollverhandlung -- und die soll sich nicht bei jedem Seitenaufbau
|
||||
wiederholen.
|
||||
"""
|
||||
conn.execute(
|
||||
"UPDATE device_status SET vnc_types=?, vnc_reason=?, vnc_types_at=? WHERE device_id=?",
|
||||
(",".join(str(v) for v in verfahren), absage, _now(), device_id),
|
||||
)
|
||||
|
||||
|
||||
def merke_verfahren_das_traegt(
|
||||
conn: sqlite3.Connection, device_id: int, verfahren: int
|
||||
) -> None:
|
||||
"""Haelt fest, womit die letzte Sitzung wirklich zustande kam."""
|
||||
conn.execute(
|
||||
"UPDATE device_status SET vnc_method=? WHERE device_id=?", (int(verfahren), device_id)
|
||||
)
|
||||
|
||||
|
||||
def run_sweep(
|
||||
*,
|
||||
database: Any,
|
||||
@@ -514,13 +634,25 @@ def run_sweep(
|
||||
keystore: Any,
|
||||
license_allows_auto_restart: bool,
|
||||
failures_before_restart: int = DEFAULT_FAILURES_BEFORE_RESTART,
|
||||
vnc_port: int = 0,
|
||||
) -> SweepReport:
|
||||
"""Ein vollstaendiger Ueberwachungsdurchlauf.
|
||||
|
||||
Die Pruefungen laufen parallel (I/O-gebunden), die Datenbankschreibvorgaenge
|
||||
danach in **einer** Transaktion -- so gibt es keinen Schreib-Wettlauf und
|
||||
keine halb geschriebenen Durchlaeufe.
|
||||
|
||||
``vnc_port`` (0 = aus) haengt eine zweite, sehr kurze Frage an jedes
|
||||
erreichbare Geraet: nimmt dort ein VNC-Server Verbindungen an? Sie gehoert
|
||||
hierher und nicht in den Seitenaufbau -- einmal je Intervall statt einmal
|
||||
je Aufruf, und nur fuer Geraete, die ohnehin gerade antworten.
|
||||
|
||||
**Nur die Frage nach dem Port**, kein Wort Protokoll: ein RealVNC Server
|
||||
zaehlt abgebrochene Verhandlungen und sperrt die Adresse eine Weile aus.
|
||||
Welche Anmeldeverfahren er anbietet, misst die Seite, die es braucht.
|
||||
"""
|
||||
from . import vnc as vnc_dienst # spaet: vnc benutzt tcp_reachable von hier
|
||||
|
||||
report = SweepReport()
|
||||
started = time.monotonic()
|
||||
|
||||
@@ -531,15 +663,37 @@ def run_sweep(
|
||||
report.duration_ms = int((time.monotonic() - started) * 1000)
|
||||
return report
|
||||
|
||||
def pruefen(
|
||||
device: dict[str, Any],
|
||||
) -> tuple[dict[str, Any], PingResult, vnc_dienst.Zugang | None]:
|
||||
ergebnis = ping(str(device["ip"]))
|
||||
if not vnc_port or not ergebnis.ok:
|
||||
# Kein Ergebnis heisst "nicht gefragt" -- und laesst den zuletzt
|
||||
# bekannten Stand stehen. Ein Geraet, das gerade neu startet, soll
|
||||
# seinen Knopf nicht verlieren.
|
||||
return device, ergebnis, None
|
||||
try:
|
||||
return device, ergebnis, vnc_dienst.zugang_kurz(device, port=vnc_port)
|
||||
except Exception: # noqa: BLE001
|
||||
# Die Ueberwachung ist die Kernaufgabe, der Bildschirmzugriff eine
|
||||
# Zugabe. Was hier schiefgeht -- ein Name, den der Resolver nicht
|
||||
# verdaut, ein Netz, das seltsam antwortet --, darf niemals den
|
||||
# ganzen Durchlauf kosten: sonst stuenden alle Geraete auf
|
||||
# "unbekannt", und niemand brächte das mit VNC in Verbindung.
|
||||
logger.warning("VNC-Pruefung fuer %s uebersprungen", device.get("name"), exc_info=True)
|
||||
return device, ergebnis, None
|
||||
|
||||
with ThreadPoolExecutor(max_workers=min(MAX_PARALLEL_CHECKS, len(devices))) as pool:
|
||||
results = list(pool.map(lambda device: (device, ping(str(device["ip"]))), devices))
|
||||
results = list(pool.map(pruefen, devices))
|
||||
|
||||
restart_candidates: list[dict[str, Any]] = []
|
||||
with database.session() as conn:
|
||||
with database.transaction(conn):
|
||||
for device, result in results:
|
||||
for device, result, vnc_offen in results:
|
||||
report.checked += 1
|
||||
previous, current = record_status(conn, int(device["id"]), result)
|
||||
if vnc_offen is not None:
|
||||
merke_vnc(conn, int(device["id"]), vnc_offen.erreichbar, vnc_offen.ziel)
|
||||
if result.ok:
|
||||
report.online += 1
|
||||
else:
|
||||
@@ -565,10 +719,22 @@ def run_sweep(
|
||||
name = str(device["name"])
|
||||
if not license_allows_auto_restart:
|
||||
report.skipped_unlicensed += 1
|
||||
live.warning(
|
||||
"poe",
|
||||
f"{name}: automatischer PoE-Neustart uebersprungen -- keine gueltige Lizenz.",
|
||||
)
|
||||
# Als Zeile in der Neustart-Historie, nicht nur als Protokollsatz:
|
||||
# wer auf der Seite "Neustarts" nachsieht, warum ein Geraet nicht
|
||||
# zurueckkam, soll dort auch lesen, dass TESM es versucht haette.
|
||||
# Ein leerer Verlauf sieht sonst aus wie "es war nichts noetig".
|
||||
with database.session() as conn, database.transaction(conn):
|
||||
record_restart(
|
||||
conn,
|
||||
device_id=int(device["id"]),
|
||||
action="cycle",
|
||||
result="skipped",
|
||||
trigger="auto",
|
||||
actor="system",
|
||||
duration_ms=0,
|
||||
detail="keine gültige Lizenz für den automatischen PoE-Neustart",
|
||||
live=live,
|
||||
)
|
||||
continue
|
||||
report.restarts_triggered += 1
|
||||
# Bewusst **keine** umschliessende Transaktion: der Neustart oeffnet eine
|
||||
|
||||
@@ -22,7 +22,7 @@ etwa bei einem Geraet, das schreibend auf eine Platte zugreift.
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
from typing import Any, Callable
|
||||
|
||||
METHOD_POE = "poe"
|
||||
METHOD_SSH = "ssh"
|
||||
@@ -40,6 +40,16 @@ class Method:
|
||||
#: Braucht dieser Weg das lizenzpflichtige Modul "Wartung"?
|
||||
licensed: bool = False
|
||||
|
||||
@property
|
||||
def recht(self) -> str:
|
||||
"""Das Recht, das genau diesen Weg freigibt.
|
||||
|
||||
Am Weg und nicht in der Route: die Liste der Wege wird an vier Stellen
|
||||
gebraucht (Dashboard, Geraeteliste, Detailseite, Route), und ein Recht,
|
||||
das man an vier Stellen von Hand dazuschreibt, fehlt an der fuenften.
|
||||
"""
|
||||
return f"device_restart_{self.key}.execute"
|
||||
|
||||
@property
|
||||
def is_poe(self) -> bool:
|
||||
return self.key == METHOD_POE
|
||||
@@ -54,36 +64,46 @@ DEFINITIONS: dict[str, Method] = {
|
||||
POWER_ON: Method(
|
||||
key=METHOD_POE,
|
||||
label="Einschalten (PoE)",
|
||||
hint="Schaltet den Switch-Port aus und wieder ein. Bei einem Geraet, das nicht "
|
||||
hint="Schaltet den Switch-Port aus und wieder ein. Bei einem Gerät, das nicht "
|
||||
"antwortet, ist das der einzige Weg, der noch etwas bewirkt.",
|
||||
),
|
||||
METHOD_POE: Method(
|
||||
key=METHOD_POE,
|
||||
label="PoE-Port",
|
||||
hint="Schaltet den Switch-Port kurz ab. Wirkt auch, wenn das Geraet haengt.",
|
||||
hint="Schaltet den Switch-Port kurz ab. Wirkt auch, wenn das Gerät hängt.",
|
||||
),
|
||||
METHOD_SSH: Method(
|
||||
key=METHOD_SSH,
|
||||
label="Neustart ueber SSH",
|
||||
hint="Faehrt das Betriebssystem sauber herunter. Setzt ein antwortendes Geraet "
|
||||
"und einen freigegebenen Host-Schluessel voraus.",
|
||||
label="Neustart über SSH",
|
||||
hint="Fährt das Betriebssystem sauber herunter. Setzt ein antwortendes Gerät "
|
||||
"und einen freigegebenen Host-Schlüssel voraus.",
|
||||
licensed=True,
|
||||
),
|
||||
METHOD_RPC: Method(
|
||||
key=METHOD_RPC,
|
||||
label="Neustart ueber RPC",
|
||||
label="Neustart über RPC",
|
||||
hint="Windows: wie 'shutdown /m'. Setzt offene Datei- und Druckerfreigabe voraus.",
|
||||
licensed=True,
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
def available(device: Any, *, maintenance_active: bool = True) -> list[Method]:
|
||||
def available(
|
||||
device: Any,
|
||||
*,
|
||||
maintenance_active: bool = True,
|
||||
darf: Callable[[str], bool] | None = None,
|
||||
) -> list[Method]:
|
||||
"""Alle Wege, die bei diesem Geraet tatsaechlich zu etwas fuehren.
|
||||
|
||||
Ein Weg, der nur *fast* geht -- Switch zugeordnet, aber kein Port --, gilt
|
||||
als nicht verfuegbar. Ein Knopf, der verlaesslich nichts bewirkt, ist
|
||||
schlimmer als kein Knopf.
|
||||
|
||||
``darf`` filtert zusaetzlich nach dem Recht des jeweiligen Weges (in der
|
||||
Anwendung ``current_user.has``). Damit verschwindet der Knopf fuer den, der
|
||||
ihn nicht druecken darf -- und zwar an allen Stellen zugleich, weil alle
|
||||
dieselbe Liste benutzen. Ohne ``darf`` ist die Antwort rein technisch.
|
||||
"""
|
||||
|
||||
def field(name: str) -> Any:
|
||||
@@ -102,7 +122,10 @@ def available(device: Any, *, maintenance_active: bool = True) -> list[Method]:
|
||||
# der PoE-Weg, und der heisst dort "Einschalten": er schaltet den Port
|
||||
# wieder ein, statt ein laufendes System neu zu starten.
|
||||
if str(field("state") or "") == "offline":
|
||||
return [DEFINITIONS[POWER_ON]] if hat_poe else []
|
||||
einschalten = [DEFINITIONS[POWER_ON]] if hat_poe else []
|
||||
if darf is not None:
|
||||
einschalten = [entry for entry in einschalten if darf(entry.recht)]
|
||||
return einschalten
|
||||
|
||||
category = str(field("credential_category") or "")
|
||||
has_login = bool(field("credential_id"))
|
||||
@@ -112,23 +135,39 @@ def available(device: Any, *, maintenance_active: bool = True) -> list[Method]:
|
||||
elif category == "linux":
|
||||
methods.append(DEFINITIONS[METHOD_SSH])
|
||||
|
||||
if darf is not None:
|
||||
methods = [entry for entry in methods if darf(entry.recht)]
|
||||
return sorted(methods, key=lambda entry: PREFERENCE.index(entry.key))
|
||||
|
||||
|
||||
def preferred(device: Any, *, maintenance_active: bool = True) -> Method | None:
|
||||
def preferred(
|
||||
device: Any,
|
||||
*,
|
||||
maintenance_active: bool = True,
|
||||
darf: Callable[[str], bool] | None = None,
|
||||
) -> Method | None:
|
||||
"""Der Weg, der vorausgewaehlt wird -- oder ``None``, wenn es keinen gibt."""
|
||||
options = available(device, maintenance_active=maintenance_active)
|
||||
options = available(device, maintenance_active=maintenance_active, darf=darf)
|
||||
return options[0] if options else None
|
||||
|
||||
|
||||
def resolve(device: Any, requested: str, *, maintenance_active: bool = True) -> Method | None:
|
||||
def resolve(
|
||||
device: Any,
|
||||
requested: str,
|
||||
*,
|
||||
maintenance_active: bool = True,
|
||||
darf: Callable[[str], bool] | None = None,
|
||||
) -> Method | None:
|
||||
"""Prueft eine Anforderung gegen das, was tatsaechlich moeglich ist.
|
||||
|
||||
Die Oberflaeche darf vorschlagen; entscheiden tut der Server. Ein Weg, der
|
||||
fuer dieses Geraet nicht offensteht, wird nicht "irgendwie" ersetzt --
|
||||
sonst startet jemand ein Geraet anders neu, als er dachte.
|
||||
"""
|
||||
options = {entry.key: entry for entry in available(device, maintenance_active=maintenance_active)}
|
||||
options = {
|
||||
entry.key: entry
|
||||
for entry in available(device, maintenance_active=maintenance_active, darf=darf)
|
||||
}
|
||||
if not options:
|
||||
return None
|
||||
if not requested:
|
||||
|
||||
@@ -128,7 +128,7 @@ class ShellSession:
|
||||
elif time.monotonic() > deadline:
|
||||
channel.close()
|
||||
raise SshError(
|
||||
f"Das Kommando hat das Zeitlimit von {timeout}s ueberschritten."
|
||||
f"Das Kommando hat das Zeitlimit von {timeout}s überschritten."
|
||||
)
|
||||
else:
|
||||
time.sleep(0.05)
|
||||
|
||||
@@ -48,8 +48,8 @@ class UnknownHostKey(SshError):
|
||||
|
||||
def __init__(self, host: str, port: int, key_type: str, fingerprint: str) -> None:
|
||||
super().__init__(
|
||||
f"Der SSH-Host-Schluessel von {host}:{port} ist unbekannt "
|
||||
f"({key_type}, {fingerprint}). Bitte pruefen und ausdruecklich freigeben."
|
||||
f"Der SSH-Host-Schlüssel von {host}:{port} ist unbekannt "
|
||||
f"({key_type}, {fingerprint}). Bitte prüfen und ausdrücklich freigeben."
|
||||
)
|
||||
self.host = host
|
||||
self.port = port
|
||||
@@ -62,9 +62,9 @@ class HostKeyChanged(SshError):
|
||||
|
||||
def __init__(self, host: str, port: int, expected: str, received: str) -> None:
|
||||
super().__init__(
|
||||
f"Der SSH-Host-Schluessel von {host}:{port} hat sich geaendert "
|
||||
f"Der SSH-Host-Schlüssel von {host}:{port} hat sich geändert "
|
||||
f"(erwartet {expected}, erhalten {received}). Der Aufbau wurde abgebrochen. "
|
||||
"Das kann ein Geraetetausch sein -- oder ein Angriff."
|
||||
"Das kann ein Gerätetausch sein – oder ein Angriff."
|
||||
)
|
||||
self.host = host
|
||||
self.port = port
|
||||
@@ -187,17 +187,17 @@ def describe_probe(
|
||||
if known is None:
|
||||
return (
|
||||
"info",
|
||||
f"Host-Schluessel gelesen: {record.key_type} {record.fingerprint}. "
|
||||
"Bitte gegen eine unabhaengige Quelle pruefen und dann freigeben.",
|
||||
f"Host-Schlüssel gelesen: {record.key_type} {record.fingerprint}. "
|
||||
"Bitte gegen eine unabhängige Quelle prüfen und dann freigeben.",
|
||||
)
|
||||
if known.fingerprint != record.fingerprint:
|
||||
return (
|
||||
"danger",
|
||||
f"Achtung: der Schluessel weicht vom gespeicherten ab. Gespeichert "
|
||||
f"Achtung: der Schlüssel weicht vom gespeicherten ab. Gespeichert "
|
||||
f"{known.fingerprint}, gelesen {record.fingerprint}. Das kann ein "
|
||||
"Geraetetausch sein -- oder ein Angriff.",
|
||||
"Gerätetausch sein – oder ein Angriff.",
|
||||
)
|
||||
return ("success", "Der Host-Schluessel stimmt mit dem gespeicherten ueberein.")
|
||||
return ("success", "Der Host-Schlüssel stimmt mit dem gespeicherten überein.")
|
||||
|
||||
|
||||
def probe_host_key(host: str, port: int = 22, *, timeout: int = CONNECT_TIMEOUT) -> HostKeyRecord:
|
||||
@@ -281,8 +281,8 @@ def open_transport(
|
||||
raise SshError(
|
||||
f"SSH-Handshake mit {host}:{port} fehlgeschlagen ({exc}). "
|
||||
f"Freigegeben sind nur {', '.join(sorted(set(trusted_types)))} -- "
|
||||
"bietet das Geraet inzwischen einen anderen Schluesseltyp an, muss der "
|
||||
"Schluessel neu ausgelesen und freigegeben werden."
|
||||
"bietet das Gerät inzwischen einen anderen Schlüsseltyp an, muss der "
|
||||
"Schlüssel neu ausgelesen und freigegeben werden."
|
||||
) from exc
|
||||
raise SshError(f"SSH-Handshake mit {host}:{port} fehlgeschlagen ({exc}).") from exc
|
||||
|
||||
|
||||
@@ -30,14 +30,37 @@ from .ssh import SshError, open_transport
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
#: Steuerzeichen eines Terminals -- ANSI-Sequenzen, Glocke, Einzelbefehle.
|
||||
#:
|
||||
#: Ein ArubaOS-Switch an einem Pseudoterminal zeichnet seine Zeile neu und
|
||||
#: haengt dabei Sequenzen wie ``ESC [ K`` ("bis Zeilenende loeschen") an. Im
|
||||
#: Terminal sieht man davon **nichts** -- und genau das war der Fehler, der
|
||||
#: Konsole und PoE-Neustart lahmgelegt hat: der Prompt stand da, aber hinter
|
||||
#: ihm standen drei unsichtbare Bytes, und das verankerte Muster traf nicht
|
||||
#: mehr. Im Protokoll las man "kein Prompt", waehrend der Prompt im Text
|
||||
#: stand. Deshalb kommen die Steuerzeichen weg, bevor irgendetwas gesucht
|
||||
#: wird -- das macht zugleich die Ausgabe in der Konsole lesbar.
|
||||
ANSI_RE = re.compile(rb"\x1b\[[0-9;?]*[ -/]*[@-~]|\x1b[@-Z\\-_]|[\x07\x00]")
|
||||
|
||||
#: Erkennt Operator- (``>``) und privilegierten Modus (``#``) sowie den
|
||||
#: Konfigurationsmodus (``switch(config)#``).
|
||||
PROMPT_RE = re.compile(rb"[\r\n][^\r\n]{0,80}?[>#]\s?$")
|
||||
#:
|
||||
#: Zwei Lockerungen gegenueber der ersten Fassung, beide aus dem Betrieb:
|
||||
#: der Prompt darf am Anfang des Puffers stehen (nicht nur nach einem
|
||||
#: Zeilenumbruch -- so kommt er nach einem geleerten Puffer), und hinter ihm
|
||||
#: darf beliebiger Leerraum stehen (nicht genau eines).
|
||||
PROMPT_RE = re.compile(rb"(?:^|[\r\n])[^\r\n]{0,120}?[>#]\s*$")
|
||||
PAGER_RE = re.compile(rb"(--\s?MORE\s?--|Press any key to continue)", re.IGNORECASE)
|
||||
ERROR_RE = re.compile(
|
||||
r"(invalid input|unknown command|incomplete|ambiguous|not supported|% *error)", re.IGNORECASE
|
||||
)
|
||||
|
||||
#: Der Weg nach draussen -- zweimal "exit" fuehrt aus jedem Modus in den
|
||||
#: privilegierten zurueck, das dritte beendet die Sitzung. Genau so macht es
|
||||
#: das bewaehrte Skript der Vorgaengerfassung.
|
||||
ABMELDUNG = ("exit", "exit", "exit")
|
||||
ABMELDE_PAUSE = 0.15
|
||||
|
||||
READ_CHUNK = 65536
|
||||
DEFAULT_COMMAND_TIMEOUT = 20
|
||||
POE_SETTLE_SECONDS = 2
|
||||
@@ -104,7 +127,42 @@ class SwitchCli:
|
||||
self.close()
|
||||
|
||||
def close(self) -> None:
|
||||
for closer in (self._channel, self._transport):
|
||||
"""Meldet sich ab und legt dann erst auf.
|
||||
|
||||
Der entscheidende Teil, und der einzige, den das ``expect``-Skript der
|
||||
Fassung 1.2.8 hatte und diese Fassung nicht: ``exit`` bis heraus aus
|
||||
dem Konfigurationsmodus, dann ``exit``, und auf die Frage
|
||||
"Do you want to log out (y/n)?" ein ``y``.
|
||||
|
||||
Warum das mehr ist als Hoeflichkeit: ArubaOS erlaubt nur eine Handvoll
|
||||
gleichzeitiger SSH-Sitzungen (``show ip ssh``, Vorgabe fuenf) und
|
||||
raeumt eine weggebrochene Verbindung **nicht sofort** ab. Wer die
|
||||
Verbindung nur fallen laesst, belegt den Platz weiter. Nach ein paar
|
||||
Vorgaengen -- ein Ueberwachungsdurchlauf mit mehreren Geraeten am
|
||||
selben Switch reicht -- sind alle Plaetze belegt. Danach kommt eine
|
||||
neue Sitzung noch durch Anmeldung und Authentisierung, bekommt aber
|
||||
**keine Shell**: kein Prompt, keine Antwort, nichts. Im Betrieb sieht
|
||||
das aus wie ein stummer Switch, und der Portzyklus passiert nicht.
|
||||
|
||||
Fehler werden hier geschluckt: eine Abmeldung, die nicht klappt, darf
|
||||
das Ergebnis des Vorgangs nicht umwerfen -- aufgelegt wird ohnehin.
|
||||
"""
|
||||
kanal = self._channel
|
||||
if kanal is not None:
|
||||
try:
|
||||
for zeile in ABMELDUNG:
|
||||
kanal.send(zeile + "\r\n")
|
||||
# Kurz warten, damit der Switch die Zeile verarbeitet --
|
||||
# auf die Antwort zu warten wuerde beim Abmelden nur
|
||||
# Zeitlimits sammeln.
|
||||
time.sleep(ABMELDE_PAUSE)
|
||||
# "Do you want to log out (y/n)?"
|
||||
kanal.send("y\r\n")
|
||||
time.sleep(ABMELDE_PAUSE)
|
||||
except Exception: # noqa: BLE001 - die Verbindung ist ohnehin am Ende
|
||||
logger.debug("Abmeldung von %s nicht mehr moeglich", self.host)
|
||||
|
||||
for closer in (kanal, self._transport):
|
||||
try:
|
||||
if closer is not None:
|
||||
closer.close()
|
||||
@@ -116,28 +174,62 @@ class SwitchCli:
|
||||
# ------------------------------------------------------------------ #
|
||||
|
||||
def _read_until_prompt(self, *, timeout: int, initial: bool = False) -> str:
|
||||
"""Liest bis zum Prompt. Ohne Prompt ist es ein Fehler, keine Antwort.
|
||||
|
||||
Bis 2.8.2 lief diese Schleife nach dem Zeitlimit einfach aus und gab
|
||||
zurueck, was bis dahin gekommen war -- oft nichts. Der Aufrufer sah
|
||||
eine leere Antwort und machte weiter: ``configure terminal`` ins
|
||||
Nichts, ``no power-over-ethernet`` ins Nichts, und weil die
|
||||
Zustandsabfrage dann "unknown" ergab und "unknown" als
|
||||
"nicht widersprochen" durchging, meldete TESM am Ende **Erfolg** --
|
||||
waehrend am Switch nichts geschaltet wurde. Genau das war im Betrieb
|
||||
zu sehen: der Client blieb durchgehend erreichbar, das Protokoll sagte
|
||||
"durchgefuehrt".
|
||||
|
||||
Ein Kommando ohne Prompt ist ein Kommando ohne Antwort. Das ist ein
|
||||
Fehlschlag und wird als solcher gemeldet -- mit dem, was zuletzt
|
||||
hereinkam, denn daran erkennt man, wo die Sitzung steht.
|
||||
"""
|
||||
assert self._channel is not None
|
||||
deadline = time.monotonic() + timeout
|
||||
collected = b""
|
||||
# Zwei Puffer: das Rohe kommt herein, gesucht wird im Sichtbaren.
|
||||
# Ohne diese Trennung entscheiden unsichtbare Steuerzeichen darueber,
|
||||
# ob ein Prompt erkannt wird.
|
||||
sichtbar = b""
|
||||
prompt_gesehen = False
|
||||
while time.monotonic() < deadline:
|
||||
if self._channel.recv_ready():
|
||||
chunk = self._channel.recv(READ_CHUNK)
|
||||
if not chunk:
|
||||
break
|
||||
collected += chunk
|
||||
if PAGER_RE.search(collected[-200:]):
|
||||
sichtbar = ANSI_RE.sub(b"", sichtbar + chunk)
|
||||
if PAGER_RE.search(sichtbar[-300:]):
|
||||
self._channel.send(" ")
|
||||
collected = PAGER_RE.sub(b"", collected)
|
||||
sichtbar = PAGER_RE.sub(b"", sichtbar)
|
||||
continue
|
||||
if PROMPT_RE.search(collected[-200:]):
|
||||
if PROMPT_RE.search(sichtbar[-300:]):
|
||||
prompt_gesehen = True
|
||||
break
|
||||
# Der Login-Banner mancher Geraete verlangt eine Bestaetigung.
|
||||
if initial and collected.rstrip().endswith(b"continue"):
|
||||
if initial and sichtbar.rstrip().endswith(b"continue"):
|
||||
self._channel.send("\r\n")
|
||||
else:
|
||||
time.sleep(0.05)
|
||||
text = collected.decode("utf-8", errors="replace")
|
||||
text = sichtbar.decode("utf-8", errors="replace")
|
||||
self.transcript.append(text)
|
||||
if not prompt_gesehen:
|
||||
letzte = " ".join(text.split())[-200:]
|
||||
if initial:
|
||||
raise SshError(
|
||||
f"{self.host} hat sich nach der Anmeldung nicht gemeldet "
|
||||
f"(kein Prompt innerhalb von {timeout}s)."
|
||||
+ (f" Zuletzt kam: {letzte}" if letzte else " Es kam nichts.")
|
||||
)
|
||||
raise SshError(
|
||||
f"{self.host} hat auf das Kommando nicht geantwortet "
|
||||
f"(kein Prompt innerhalb von {timeout}s)."
|
||||
+ (f" Zuletzt kam: {letzte}" if letzte else " Es kam nichts.")
|
||||
)
|
||||
return text
|
||||
|
||||
def send(
|
||||
@@ -149,7 +241,7 @@ class SwitchCli:
|
||||
``tolerate_error`` (z. B. ``no page``, das nicht jede Firmware kennt).
|
||||
"""
|
||||
if self._channel is None:
|
||||
raise SshError("Die Sitzung ist nicht geoeffnet.")
|
||||
raise SshError("Die Sitzung ist nicht geöffnet.")
|
||||
self.transcript.append(f"\n$ {command}\n")
|
||||
self._channel.send(command + "\r\n")
|
||||
output = self._read_until_prompt(timeout=timeout)
|
||||
@@ -207,11 +299,22 @@ class SwitchCli:
|
||||
off_state = self.poe_state(port)
|
||||
if off_state == "enabled":
|
||||
result.error = (
|
||||
"Der Switch meldet den Port nach dem Abschalten weiter als aktiv -- "
|
||||
"Der Switch meldet den Port nach dem Abschalten weiter als aktiv – "
|
||||
"der Neustart hat nicht stattgefunden."
|
||||
)
|
||||
result.transcript = self.full_transcript
|
||||
return result
|
||||
if before == "enabled" and off_state == "unknown":
|
||||
# Vorher lesbar, nachher nicht: dann ist nicht die Firmware
|
||||
# unbekannt, sondern die Sitzung aus dem Tritt. Das als Erfolg
|
||||
# zu zaehlen war der Fehler, der einen Neustart meldete, den
|
||||
# es nicht gab.
|
||||
result.error = (
|
||||
"Der Zustand des Ports ließ sich nach dem Abschalten nicht mehr "
|
||||
"lesen – vorher ging es. Der Portzyklus ist damit nicht belegt."
|
||||
)
|
||||
result.transcript = self.full_transcript
|
||||
return result
|
||||
result.add(f"Zustand im ausgeschalteten Zustand: {off_state}")
|
||||
|
||||
self.set_poe(port, enabled=True)
|
||||
@@ -224,6 +327,17 @@ class SwitchCli:
|
||||
result.transcript = self.full_transcript
|
||||
return result
|
||||
|
||||
if before == "unknown" and off_state == "unknown" and after == "unknown":
|
||||
# Kein einziger Zustand lesbar: die Kommandos hat der Switch
|
||||
# zwar mit einem Prompt bestaetigt (sonst waere die Sitzung
|
||||
# oben abgebrochen), aber belegt ist der Zyklus nicht. Das
|
||||
# gehoert in die Meldung -- "durchgefuehrt" waere zu viel
|
||||
# gesagt.
|
||||
result.add(
|
||||
"Der Zustand war nicht lesbar – die Kommandos hat der Switch "
|
||||
"angenommen, bestätigt ist der Zyklus damit nicht."
|
||||
)
|
||||
|
||||
result.ok = True
|
||||
except SshError as exc:
|
||||
result.error = str(exc)
|
||||
@@ -245,11 +359,11 @@ class SwitchCli:
|
||||
#: Kommandos, die die Oberflaeche ohne freies Eingabefeld anbietet.
|
||||
SAFE_COMMANDS: tuple[tuple[str, str], ...] = (
|
||||
("show system information", "Systeminformationen"),
|
||||
("show power-over-ethernet brief", "PoE-Uebersicht aller Ports"),
|
||||
("show power-over-ethernet brief", "PoE-Übersicht aller Ports"),
|
||||
("show interfaces brief", "Portstatus"),
|
||||
("show vlans", "VLAN-Uebersicht"),
|
||||
("show mac-address", "MAC-Adresstabelle"),
|
||||
("show lldp info remote-device", "Nachbargeraete (LLDP)"),
|
||||
("show lldp info remote-device", "Nachbargeräte (LLDP)"),
|
||||
("show version", "Firmware-Version"),
|
||||
("show logging", "Switch-Protokoll"),
|
||||
)
|
||||
@@ -14,6 +14,7 @@ from typing import Any, Callable, Iterable
|
||||
|
||||
from tesm_core.db import Database
|
||||
from tesm_core.keystore import Keystore
|
||||
from tesm_core.rbac.legacy_permissions import umgeschrieben
|
||||
from tesm_core.transfer import (
|
||||
MIN_PASSPHRASE_LENGTH,
|
||||
PREVIEW_TTL_MINUTES,
|
||||
@@ -50,20 +51,35 @@ def unseal(raw: bytes | str, passphrase: str) -> tuple[dict[str, Any], dict[str,
|
||||
|
||||
|
||||
CATEGORIES: tuple[Category, ...] = (
|
||||
Category("devices", "Clients", "Geraetestammdaten inklusive Switch- und Portzuordnung"),
|
||||
Category("devices", "Clients", "Gerätestammdaten inklusive Switch- und Portzuordnung"),
|
||||
Category("switches", "Switche", "Switch-Inventar"),
|
||||
Category(
|
||||
"credentials",
|
||||
"Zugangsdaten",
|
||||
"SSH-Logins. Die Passwoerter liegen im Export im Klartext -- geschuetzt "
|
||||
"ausschliesslich durch die Passphrase.",
|
||||
"SSH-Logins. Die Passwörter liegen im Export im Klartext – geschützt "
|
||||
"ausschließlich durch die Passphrase.",
|
||||
sensitive=True,
|
||||
),
|
||||
Category("dhcp", "DHCP-Konfiguration", "Subnetze, Optionen und manuelle Reservierungen"),
|
||||
Category("fileshare", "Freigabe-Zuordnungen", "Verzeichnisgruppe zu UNC-Pfad"),
|
||||
Category(
|
||||
"directory",
|
||||
"Verzeichnisdienst",
|
||||
"LDAP-Anbindung, Bind-Konto und Gruppen-Zuordnungen. Das Passwort des "
|
||||
"Bind-Kontos nur mit \"Geheimnisse einschliessen\".",
|
||||
admin_only=True,
|
||||
sensitive=True,
|
||||
),
|
||||
Category("users", "Benutzer", "Lokale Konten mit Passwort-Hash", admin_only=True),
|
||||
Category("groups", "Gruppen und Rechte", "Rechtematrix", 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}
|
||||
@@ -123,7 +139,7 @@ def build_export(
|
||||
str(row["secret_encrypted"]), aad=aad_for_credential(int(row["id"]))
|
||||
)
|
||||
except Exception: # noqa: BLE001
|
||||
entry["secret_error"] = "nicht entschluesselbar"
|
||||
entry["secret_error"] = "nicht entschlüsselbar"
|
||||
entries.append(entry)
|
||||
payload["credentials"] = entries
|
||||
if "dhcp" in wanted:
|
||||
@@ -178,6 +194,49 @@ def build_export(
|
||||
}
|
||||
for row in Database.all(conn, "SELECT * FROM groups WHERE deleted_at IS NULL")
|
||||
]
|
||||
if "directory" in wanted:
|
||||
# Die Anbindung an das Verzeichnis war das einzige Stueck Konfiguration,
|
||||
# das man nach einer Wiederherstellung von Hand nachbauen musste --
|
||||
# inklusive Bind-Passwort, das dabei jemand aus einem Kennwortspeicher
|
||||
# holen muss. Es gehoert zur Anlage wie die Netzwerkeinstellung.
|
||||
from tesm_core.auth import directory as verzeichnis
|
||||
|
||||
bind_benutzer, bind_passwort = verzeichnis.bind_credentials(conn)
|
||||
gesetzt = {
|
||||
str(row["key"]): str(row["value"])
|
||||
for row in Database.all(conn, "SELECT key, value FROM settings")
|
||||
}
|
||||
payload["directory"] = {
|
||||
# Nur die Schluessel der Anbindung, und nur die, die wirklich
|
||||
# gesetzt sind: ein Vorgabewert in der Datei sieht aus wie eine
|
||||
# Entscheidung und ueberschreibt beim Einlesen eine echte.
|
||||
"settings": {
|
||||
spec.key: gesetzt[spec.key] for spec in verzeichnis.SETTINGS
|
||||
if spec.key in gesetzt
|
||||
},
|
||||
"bind_username": bind_benutzer,
|
||||
# Ohne "Geheimnisse einschliessen" reist das Passwort nicht mit.
|
||||
# Der Benutzername schon: er ist keine Geheimnis, und ohne ihn
|
||||
# muesste man die halbe Anbindung doch wieder tippen.
|
||||
"bind_password": bind_passwort if include_secrets else "",
|
||||
# Die Zuordnung zeigt auf eine Gruppe **oder** auf das
|
||||
# Administratorkennzeichen. Ueber eine Systemgrenze hinweg ist eine
|
||||
# Gruppen-Id nichts wert -- der Name ist es.
|
||||
"group_mappings": [
|
||||
{
|
||||
"ad_group_dn": str(row["ad_group_dn"]),
|
||||
"ad_group_name": str(row["ad_group_name"]),
|
||||
"group_name": str(row["group_name"] or ""),
|
||||
"grants_admin": bool(row["grants_admin"]),
|
||||
}
|
||||
for row in Database.all(
|
||||
conn,
|
||||
"SELECT m.ad_group_dn, m.ad_group_name, m.grants_admin, g.name AS group_name "
|
||||
"FROM ldap_group_mappings m "
|
||||
"LEFT JOIN groups g ON g.id = m.app_group_id AND g.deleted_at IS NULL",
|
||||
)
|
||||
],
|
||||
}
|
||||
if "settings" in wanted:
|
||||
payload["settings"] = {
|
||||
str(row["key"]): str(row["value"])
|
||||
@@ -186,6 +245,16 @@ def build_export(
|
||||
# Sicherung -- sonst wuerde ein Restore eine fremde Aktivierung erben.
|
||||
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
|
||||
|
||||
|
||||
@@ -237,6 +306,9 @@ def apply_import(
|
||||
"""
|
||||
from .inventory import ValidationError, aad_for_credential, normalize_mac
|
||||
|
||||
#: Merkt, ob das Paket alte Rechteschluessel mitbringt (siehe unten).
|
||||
alte_fassung: set[bool] = set()
|
||||
|
||||
def mac_or_none(value: Any) -> str | None:
|
||||
"""MAC normalisieren, ohne den ganzen Import zu sprengen.
|
||||
|
||||
@@ -513,19 +585,33 @@ def apply_import(
|
||||
|
||||
if "fileshare" in wanted:
|
||||
for entry in payload.get("fileshare", []):
|
||||
gruppe = str(entry.get("ad_group_dn") or "")
|
||||
pfad = str(entry.get("unc") or "")
|
||||
if not gruppe or not pfad:
|
||||
bump("fileshare", "skipped")
|
||||
continue
|
||||
# Vorher nachsehen, damit der Bericht die Wahrheit sagt: bisher
|
||||
# stand dort auch bei einer frischen Installation "2 aktualisiert",
|
||||
# obwohl zwei Zuordnungen **angelegt** wurden. Wer einen Import
|
||||
# prueft, liest genau diese Zahlen.
|
||||
vorhanden = Database.value(
|
||||
conn,
|
||||
"SELECT 1 FROM fileshare_mappings WHERE ad_group_dn=? AND unc=? LIMIT 1",
|
||||
(gruppe, pfad),
|
||||
)
|
||||
conn.execute(
|
||||
"INSERT INTO fileshare_mappings (ad_group_dn, ad_group_name, label, unc, created_at) "
|
||||
"VALUES (?,?,?,?,?) ON CONFLICT(ad_group_dn, unc) DO UPDATE SET "
|
||||
"label=excluded.label, ad_group_name=excluded.ad_group_name",
|
||||
(
|
||||
str(entry.get("ad_group_dn") or ""),
|
||||
gruppe,
|
||||
str(entry.get("ad_group_name") or ""),
|
||||
str(entry.get("label") or ""),
|
||||
str(entry.get("unc") or ""),
|
||||
pfad,
|
||||
stamp,
|
||||
),
|
||||
)
|
||||
bump("fileshare", "updated")
|
||||
bump("fileshare", "updated" if vorhanden else "created")
|
||||
|
||||
if "groups" in wanted:
|
||||
for entry in payload.get("groups", []):
|
||||
@@ -557,9 +643,21 @@ def apply_import(
|
||||
group_id = int(existing["id"])
|
||||
bump("groups", "updated")
|
||||
conn.execute("DELETE FROM group_permissions WHERE group_id=?", (group_id,))
|
||||
# Eine Sicherung aus 2.7.x nennt devices.view und devices.edit.
|
||||
# Ohne die Umschreibung landen lauter unbekannte Schluessel in der
|
||||
# Gruppe, und sanitize() wirft Unbekanntes still weg: die
|
||||
# wiederhergestellte Gruppe waere leer, ohne eine Zeile Meldung.
|
||||
mitgebracht = [str(recht) for recht in entry.get("permissions", []) or []]
|
||||
neue = umgeschrieben(mitgebracht)
|
||||
if sorted(set(mitgebracht)) != neue:
|
||||
# Das Paket kennt die alten Schluessel -- es stammt also von
|
||||
# vor 2.8.0. Ein Versionsfeld gibt es im Umschlag nicht, aber
|
||||
# die Schluessel selbst sind das verlaesslichere Kennzeichen:
|
||||
# sie stehen genau dann drin, wenn die Sicherung aelter ist.
|
||||
alte_fassung.add(True)
|
||||
conn.executemany(
|
||||
"INSERT OR IGNORE INTO group_permissions (group_id, permission) VALUES (?,?)",
|
||||
[(group_id, str(permission)) for permission in entry.get("permissions", [])],
|
||||
[(group_id, permission) for permission in neue],
|
||||
)
|
||||
|
||||
if "users" in wanted:
|
||||
@@ -625,6 +723,95 @@ def apply_import(
|
||||
(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 "directory" in wanted:
|
||||
from tesm_core.auth import directory as verzeichnis
|
||||
|
||||
abschnitt = payload.get("directory") or {}
|
||||
bekannt = {spec.key for spec in verzeichnis.SETTINGS}
|
||||
for key, value in (abschnitt.get("settings") or {}).items():
|
||||
if str(key) not in bekannt:
|
||||
# Ein fremder Schluessel in diesem Abschnitt gehoert nicht hier
|
||||
# hin; die Kategorie "Einstellungen" nimmt alles andere.
|
||||
bump("directory", "skipped")
|
||||
continue
|
||||
set_setting(str(key), value)
|
||||
bump("directory", "updated")
|
||||
|
||||
benutzer = str(abschnitt.get("bind_username") or "").strip()
|
||||
passwort = str(abschnitt.get("bind_password") or "")
|
||||
if benutzer:
|
||||
# Ohne Passwort bleibt ein vorhandenes stehen -- genau dafuer nimmt
|
||||
# store_bind_credentials None. Eine Sicherung ohne Geheimnisse darf
|
||||
# das Passwort nicht loeschen.
|
||||
verzeichnis.store_bind_credentials(conn, benutzer, passwort or None)
|
||||
bump("directory", "updated")
|
||||
|
||||
for eintrag in abschnitt.get("group_mappings") or []:
|
||||
dn = str(eintrag.get("ad_group_dn") or "").strip()
|
||||
if not dn:
|
||||
bump("directory", "skipped")
|
||||
continue
|
||||
kennzeichen = bool(eintrag.get("grants_admin"))
|
||||
gruppe = None
|
||||
if not kennzeichen:
|
||||
name = str(eintrag.get("group_name") or "").strip()
|
||||
gruppe = Database.value(
|
||||
conn, "SELECT id FROM groups WHERE name=? COLLATE NOCASE", (name,)
|
||||
)
|
||||
if gruppe is None:
|
||||
# Eine Zuordnung auf eine Gruppe, die es hier nicht gibt,
|
||||
# waere eine Zuordnung ins Leere: beim naechsten Login
|
||||
# bekaeme das Konto nichts, und niemand wuesste warum.
|
||||
bump("directory", "skipped")
|
||||
continue
|
||||
conn.execute(
|
||||
"INSERT INTO ldap_group_mappings "
|
||||
"(ad_group_dn, ad_group_name, app_group_id, grants_admin, created_at) "
|
||||
"VALUES (?,?,?,?,?) ON CONFLICT(ad_group_dn) DO UPDATE SET "
|
||||
"ad_group_name=excluded.ad_group_name, app_group_id=excluded.app_group_id, "
|
||||
"grants_admin=excluded.grants_admin",
|
||||
(
|
||||
dn,
|
||||
str(eintrag.get("ad_group_name") or dn),
|
||||
int(gruppe) if gruppe is not None else None,
|
||||
int(kennzeichen),
|
||||
stamp,
|
||||
),
|
||||
)
|
||||
bump("directory", "created")
|
||||
|
||||
if "settings" in wanted:
|
||||
for key, value in (payload.get("settings") or {}).items():
|
||||
if str(key).startswith("license_"):
|
||||
@@ -634,4 +821,17 @@ def apply_import(
|
||||
set_setting(str(key), value)
|
||||
bump("settings", "updated")
|
||||
|
||||
if alte_fassung and "users" in wanted:
|
||||
# Dieselbe Ueberlegung wie in der Migration: in der Welt, aus der
|
||||
# diese Sicherung kommt, konnte ein Administrator alles. Ohne die
|
||||
# Gruppe fehlt ihm nach dem Einlesen die halbe Anwendung -- und er
|
||||
# haelt das fuer einen Fehler der Wiederherstellung.
|
||||
from tesm_core.rbac import service as rbac
|
||||
|
||||
from ..permissions import FUNKTIONEN_VOLLZUGRIFF, PERMISSIONS
|
||||
|
||||
rbac.funktionsgruppe_fuer_administratoren(
|
||||
conn, PERMISSIONS, FUNKTIONEN_VOLLZUGRIFF
|
||||
)
|
||||
|
||||
return report
|
||||
@@ -0,0 +1,414 @@
|
||||
"""Bildschirmzugriff per VNC: Ziel bestimmen, Erreichbarkeit pruefen, Datei bauen.
|
||||
|
||||
Auf den HAPs (Raspberry Pis) laeuft ein VNC-Server. TESM kennt von jedem
|
||||
Client Namen und Adresse -- daraus laesst sich die Verbindung erzeugen, statt
|
||||
sie in jedem Viewer von Hand zu pflegen.
|
||||
|
||||
Zwei Wege, beide aus denselben zwei Angaben:
|
||||
|
||||
* eine **Verbindungsdatei** (``.vnc``), wie der RealVNC Viewer sie schreibt.
|
||||
Sie enthaelt genau drei Zeilen -- ``ConnMethod``, ``FriendlyName``, ``Host``
|
||||
-- und **nichts weiter**. Jede zusaetzliche Angabe ist eine Gelegenheit,
|
||||
etwas zu verlangen, das hier niemand verlangen soll: keine
|
||||
Verschluesselungsvorgabe, keine Identitaetspruefung, kein Benutzername, kein
|
||||
Passwortfeld. Was am Ende gefragt wird, entscheidet ohnehin der VNC-Server
|
||||
auf dem Geraet; die Datei fragt von sich aus nichts.
|
||||
* eine **Adresse** (``vnc://host``) fuer den Fall, dass der Viewer als
|
||||
Handler registriert ist -- ein Klick statt Herunterladen und Oeffnen.
|
||||
|
||||
Der Zugang erscheint nur dort, wo wirklich ein VNC-Server antwortet
|
||||
(:func:`erreichbar`). Das ist der Unterschied zu einem Schalter am Geraet:
|
||||
niemand muss ihn pflegen, und er zeigt nie ins Leere.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import socket
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, Mapping
|
||||
|
||||
from .monitor import tcp_reachable
|
||||
|
||||
#: Vorgabeport von VNC (Anzeige :0). Ueberschreibbar in den Einstellungen.
|
||||
STANDARD_PORT = 5900
|
||||
|
||||
#: So kurz, dass die Detailseite es nicht merkt, und so lang, dass ein Geraet
|
||||
#: im selben Netz antworten kann. Gefragt wird ohnehin nur, wenn der Client
|
||||
#: gerade als erreichbar gilt.
|
||||
ZEITLIMIT = 0.6
|
||||
|
||||
#: Ein Name, den ein Rechner aufloesen kann: Buchstaben, Ziffern, Bindestrich,
|
||||
#: Punkt als Trenner. "HAP 02" oder "Kamera Eingang" ist keiner -- dann bleibt
|
||||
#: die IP-Adresse, die im Datensatz ohnehin steht.
|
||||
HOSTNAME_RE = re.compile(r"^(?!-)[A-Za-z0-9-]{1,63}(?<!-)(\.(?!-)[A-Za-z0-9-]{1,63}(?<!-))*$")
|
||||
|
||||
#: Zeilenende der Datei: CRLF. Der Viewer liest beides, aber die Datei landet
|
||||
#: auf einem Windows-Arbeitsplatz -- und dort soll sie in jedem Editor
|
||||
#: vernuenftig aussehen, wenn jemand nachsieht, was drinsteht.
|
||||
ZEILE = "\r\n"
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class Zugang:
|
||||
"""Was die Detailseite ueber den Bildschirmzugriff eines Clients weiss."""
|
||||
|
||||
ziel: str
|
||||
port: int
|
||||
erreichbar: bool
|
||||
#: Die Sicherheitsverfahren, die der Server angeboten hat (leer: nicht
|
||||
#: gemessen). Daraus entsteht der Satz, warum eine Sitzung im Browser
|
||||
#: gehen wird oder nicht.
|
||||
verfahren: tuple[int, ...] = ()
|
||||
#: Der Text, mit dem der Server die Verbindung abgelehnt hat -- er nennt
|
||||
#: dann kein Verfahren, sondern eine 0 und einen Grund.
|
||||
absage: str = ""
|
||||
#: Das Verfahren, mit dem zuletzt wirklich eine Sitzung zustande kam
|
||||
#: (0: noch keine). Damit faengt der naechste Versuch an.
|
||||
bewaehrt: int = 0
|
||||
|
||||
@property
|
||||
def browsertauglich(self) -> bool:
|
||||
"""Ist mindestens ein angebotenes Verfahren eines, das noVNC kann?"""
|
||||
return bool(set(self.verfahren) & BROWSER_VERFAHREN)
|
||||
|
||||
@property
|
||||
def hinderungsgrund(self) -> str:
|
||||
"""Warum eine Sitzung im Browser nicht zustande kaeme -- im Klartext."""
|
||||
if not self.erreichbar:
|
||||
return f"Auf {self.ziel}:{self.port} antwortet kein VNC-Server."
|
||||
if self.absage:
|
||||
return (
|
||||
f"Der VNC-Server auf {self.ziel} weist die Verbindung ab: "
|
||||
f"„{self.absage}“. Das ist seine Auskunft, nicht unsere – "
|
||||
"typisch sind eine Sperre nach zu vielen Fehlversuchen "
|
||||
"(RealVNC: BlacklistTimeout), eine Adressfilterung oder eine "
|
||||
"belegte Sitzung."
|
||||
)
|
||||
if not self.verfahren:
|
||||
return ""
|
||||
if self.browsertauglich:
|
||||
return ""
|
||||
angeboten = ", ".join(verfahren_name(v) for v in self.verfahren)
|
||||
return (
|
||||
f"Der VNC-Server bietet nur {angeboten} an. Der Browser-Client kann "
|
||||
"davon keines: herstellereigene Verfahren sind nicht offengelegt, "
|
||||
"und RA2 (5) verschlüsselt die ganze Strecke mit einem Verfahren, "
|
||||
"das er nicht mitbringt. Auf dem Gerät hilft eine Zeile: "
|
||||
"Encryption=AlwaysOff (dann bietet der Server RA2ne an) oder "
|
||||
"Authentication=VncAuth samt vncpasswd (dann das klassische "
|
||||
"VNC-Passwort)."
|
||||
)
|
||||
|
||||
@property
|
||||
def adresse(self) -> str:
|
||||
"""``vnc://host`` -- den Standardport schreibt niemand dazu."""
|
||||
if self.port == STANDARD_PORT:
|
||||
return f"vnc://{self.ziel}"
|
||||
return f"vnc://{self.ziel}:{self.port}"
|
||||
|
||||
|
||||
#: Aufloesungen, die wir schon einmal versucht haben: ``name -> (Zeit, Ergebnis)``.
|
||||
#:
|
||||
#: Nicht wegen der Geschwindigkeit -- ein arbeitender Resolver antwortet in
|
||||
#: Bruchteilen einer Millisekunde --, sondern wegen des Gegenteils: faellt der
|
||||
#: Nameserver aus, laeuft jede Abfrage in sein eigenes Zeitlimit. Ohne diesen
|
||||
#: Puffer haette das der Seitenaufbau ausgebadet, und zwar bei jedem Aufruf.
|
||||
_AUFLOESUNG: dict[str, tuple[float, bool]] = {}
|
||||
|
||||
#: Fuenf Minuten. Lang genug, damit ein kaputter Resolver nicht jede Seite
|
||||
#: aufhaelt; kurz genug, dass ein neuer DNS-Eintrag nicht bis zum Neustart
|
||||
#: wartet.
|
||||
AUFLOESUNG_GILT = 300.0
|
||||
|
||||
|
||||
def aufloesbar(name: str) -> bool:
|
||||
"""Kennt der Resolver diesen Namen?"""
|
||||
jetzt = time.monotonic()
|
||||
gemerkt = _AUFLOESUNG.get(name)
|
||||
if gemerkt is not None and jetzt - gemerkt[0] < AUFLOESUNG_GILT:
|
||||
return gemerkt[1]
|
||||
try:
|
||||
socket.getaddrinfo(name, None)
|
||||
ergebnis = True
|
||||
except Exception: # noqa: BLE001
|
||||
# Nicht nur OSError: ein Name mit Sonderzeichen wirft UnicodeError,
|
||||
# und der ist kein OSError. Aufloesbar ist er so oder so nicht.
|
||||
ergebnis = False
|
||||
_AUFLOESUNG[name] = (jetzt, ergebnis)
|
||||
return ergebnis
|
||||
|
||||
|
||||
def ziel(device: Mapping[str, Any]) -> str:
|
||||
"""Der Name des Clients, sonst seine IP-Adresse.
|
||||
|
||||
Der Name ist die bessere Wahl, wo er gilt: er ueberlebt einen
|
||||
Adresswechsel, und genau so stehen die gewachsenen Verbindungen im Viewer
|
||||
(``Host=hap02``). Zwei Dinge muessen dafuer stimmen -- er muss als Hostname
|
||||
**taugen** ("Kamera Eingang" tut es nicht) und er muss sich **aufloesen**
|
||||
lassen. Sonst zeigte die Datei auf einen Namen, den der Arbeitsplatz nicht
|
||||
kennt, und niemand saehe, woran es liegt; dann ist die Adresse richtig, die
|
||||
ohnehin im Datensatz steht.
|
||||
|
||||
Loest der Name auf eine **andere** Adresse auf als die gespeicherte, gewinnt
|
||||
trotzdem der Name: dann ist der Eintrag im Bestand veraltet, nicht das DNS.
|
||||
"""
|
||||
name = str(device.get("name") or "").strip()
|
||||
if name and HOSTNAME_RE.match(name) and aufloesbar(name):
|
||||
return name.lower()
|
||||
return str(device.get("ip") or "").strip()
|
||||
|
||||
|
||||
def erreichbar(host: str, port: int = STANDARD_PORT) -> bool:
|
||||
"""Nimmt auf diesem Host ein VNC-Server Verbindungen an?"""
|
||||
if not host:
|
||||
return False
|
||||
return tcp_reachable(host, port, timeout=ZEITLIMIT)
|
||||
|
||||
|
||||
#: Was der Browser-Client (noVNC 1.6) aushandeln kann. Aus dessen
|
||||
#: ``_isSupportedSecurityType`` uebernommen -- steht hier, weil die Oberflaeche
|
||||
#: **vor** dem Verbinden sagen koennen soll, ob es klappen wird.
|
||||
BROWSER_VERFAHREN: frozenset[int] = frozenset({1, 2, 6, 16, 19, 22, 30, 113})
|
||||
|
||||
#: Die Namen dazu. Was nicht drinsteht, ist herstellereigen -- die Nummern
|
||||
#: 7 bis 15 gehoeren RealVNC, 3 und 4 ebenfalls.
|
||||
VERFAHREN_NAMEN: dict[int, str] = {
|
||||
1: "ohne Authentifizierung",
|
||||
2: "VNC-Passwort",
|
||||
5: "RA2 (RSA-AES, verschlüsselt)",
|
||||
6: "RA2ne (RSA-AES, unverschlüsselt)",
|
||||
16: "Tight",
|
||||
18: "TLS",
|
||||
19: "VeNCrypt",
|
||||
22: "xvp",
|
||||
30: "Apple ARD",
|
||||
113: "MS-Logon II",
|
||||
}
|
||||
|
||||
|
||||
def verfahren_name(nummer: int) -> str:
|
||||
"""Lesbarer Name eines Sicherheitsverfahrens."""
|
||||
if nummer in VERFAHREN_NAMEN:
|
||||
return f"{nummer} ({VERFAHREN_NAMEN[nummer]})"
|
||||
if 3 <= nummer <= 15:
|
||||
return f"{nummer} (RealVNC-eigen)"
|
||||
return str(nummer)
|
||||
|
||||
|
||||
def _absage(sock: Any) -> str:
|
||||
"""Der Grund, den ein Server einer abgelehnten Verbindung mitgibt.
|
||||
|
||||
Auf eine 0 -- ob als Anzahl (ab 3.7) oder als bestimmtes Verfahren (3.3) --
|
||||
folgt eine Laenge und ein Text. Genau dieser Satz ist die Auskunft, die
|
||||
sonst niemand bekommt: "zu viele Fehlversuche", "Verbindung von dieser
|
||||
Adresse nicht erlaubt", "kein gemeinsames Verfahren". Ohne ihn steht da
|
||||
nur, dass es nicht geht.
|
||||
"""
|
||||
try:
|
||||
roh = sock.recv(4)
|
||||
if len(roh) < 4:
|
||||
return ""
|
||||
laenge = int.from_bytes(roh, "big")
|
||||
if not 0 < laenge <= 1024:
|
||||
return ""
|
||||
text = b""
|
||||
while len(text) < laenge:
|
||||
stueck = sock.recv(laenge - len(text))
|
||||
if not stueck:
|
||||
break
|
||||
text += stueck
|
||||
return text.decode("utf-8", "replace").strip()
|
||||
except OSError:
|
||||
return ""
|
||||
|
||||
|
||||
def sicherheitsverfahren(
|
||||
host: str, port: int = STANDARD_PORT
|
||||
) -> tuple[str, tuple[int, ...], str]:
|
||||
"""Fragt den Server, **wie** er sich authentifizieren will.
|
||||
|
||||
Der Anfang einer VNC-Verbindung ist fuer jeden Server derselbe: er nennt
|
||||
seine Protokollfassung, der Client die seine, dann kommt die Liste der
|
||||
Sicherheitsverfahren. Weiter gehen wir nicht -- die Verbindung wird sofort
|
||||
geschlossen, es wird nichts angemeldet und nichts geraten.
|
||||
|
||||
Das ist die Angabe, an der eine Bildschirmsitzung im Browser scheitert oder
|
||||
gelingt, und sie hier zu messen ist der Unterschied zwischen einer
|
||||
schwarzen Flaeche und dem Satz "dieser Server bietet nur Verfahren an, die
|
||||
der Browser nicht kennt".
|
||||
|
||||
Gibt ``(Fassung, Verfahren, Absage)`` zurueck. Die Absage ist der Text, den
|
||||
ein Server mitschickt, wenn er die Verbindung **ablehnt** -- er meldet dann
|
||||
kein Verfahren, sondern eine 0 und einen Grund. Bei einem Fehler
|
||||
``("", (), "")``.
|
||||
"""
|
||||
import socket
|
||||
|
||||
try:
|
||||
with socket.create_connection((host, int(port)), timeout=ZEITLIMIT) as sock:
|
||||
sock.settimeout(ZEITLIMIT)
|
||||
gruss = sock.recv(12)
|
||||
if not gruss.startswith(b"RFB "):
|
||||
return "", (), ""
|
||||
fassung = gruss[4:11].decode("ascii", "replace")
|
||||
# Wir antworten mit 3.8 -- der Fassung, die auch der Browser
|
||||
# spricht. Ein Server, der mehr kann, faellt darauf zurueck und
|
||||
# nennt trotzdem seine Verfahren.
|
||||
# Antworten wird mit *seiner* Fassung, hoechstens 3.8. Wer einem
|
||||
# 3.3-Server mit 3.8 antwortet, bekommt keine Liste, sondern eine
|
||||
# 32-Bit-Zahl -- und liest sie als "keine Verfahren".
|
||||
eigene = "003.003" if fassung == "003.003" else "003.008"
|
||||
sock.sendall(b"RFB " + eigene.encode("ascii") + bytes([10]))
|
||||
|
||||
if eigene == "003.003":
|
||||
# RFB 3.3: der Server bestimmt allein und schickt eine
|
||||
# 32-Bit-Zahl. Das ist kein Sonderfall aus der Vorzeit --
|
||||
# RealVNC faellt darauf zurueck, sobald keine Anmeldung
|
||||
# verlangt wird.
|
||||
rohwert = b""
|
||||
while len(rohwert) < 4:
|
||||
stueck = sock.recv(4 - len(rohwert))
|
||||
if not stueck:
|
||||
break
|
||||
rohwert += stueck
|
||||
if len(rohwert) < 4:
|
||||
return fassung, (), ""
|
||||
bestimmt = int.from_bytes(rohwert, "big")
|
||||
if bestimmt == 0:
|
||||
return fassung, (), _absage(sock)
|
||||
return fassung, (bestimmt,), ""
|
||||
|
||||
anzahl = sock.recv(1)
|
||||
if not anzahl:
|
||||
return fassung, (), ""
|
||||
wie_viele = anzahl[0]
|
||||
if wie_viele == 0:
|
||||
# Null heisst: der Server lehnt ab und schickt den Grund.
|
||||
return fassung, (), _absage(sock)
|
||||
liste = b""
|
||||
while len(liste) < wie_viele:
|
||||
stueck = sock.recv(wie_viele - len(liste))
|
||||
if not stueck:
|
||||
break
|
||||
liste += stueck
|
||||
return fassung, tuple(liste), ""
|
||||
except OSError:
|
||||
return "", (), ""
|
||||
|
||||
|
||||
def zugang_kurz(device: Mapping[str, Any], *, port: int = STANDARD_PORT) -> Zugang:
|
||||
"""Nur die Frage, ob dort jemand annimmt -- ohne ein Wort Protokoll.
|
||||
|
||||
Das ist die Fassung fuer den Ueberwachungsdurchlauf. Er laeuft alle paar
|
||||
Minuten ueber jedes Geraet, und eine angefangene und sofort wieder
|
||||
abgebrochene Protokollverhandlung ist nicht nur unhoeflich: ein RealVNC
|
||||
Server zaehlt abgebrochene Versuche und sperrt die Adresse eine Weile aus
|
||||
(``BlacklistTimeout``). Eine Ueberwachung, die ihr eigenes Ziel aussperrt,
|
||||
waere ein schoener Eigentor -- also fragt sie nur, ob der Port annimmt.
|
||||
|
||||
Welche Anmeldeverfahren der Server kann, misst erst die Seite, die es
|
||||
wirklich braucht -- und auch die nicht oefter als noetig.
|
||||
"""
|
||||
host = ziel(device)
|
||||
return Zugang(ziel=host, port=port, erreichbar=erreichbar(host, port))
|
||||
|
||||
|
||||
def zugang(device: Mapping[str, Any], *, port: int = STANDARD_PORT, pruefen: bool = True) -> Zugang:
|
||||
"""Ziel, Erreichbarkeit und Verfahren in einem Stueck -- so nimmt es die Vorlage."""
|
||||
host = ziel(device)
|
||||
if not pruefen:
|
||||
return Zugang(ziel=host, port=port, erreichbar=False)
|
||||
fassung, verfahren, absage = sicherheitsverfahren(host, port)
|
||||
return Zugang(
|
||||
ziel=host,
|
||||
port=port,
|
||||
erreichbar=bool(fassung),
|
||||
verfahren=verfahren,
|
||||
absage=absage,
|
||||
)
|
||||
|
||||
|
||||
def zugang_gemerkt(device: Mapping[str, Any], *, port: int = STANDARD_PORT) -> Zugang:
|
||||
"""Der Zugang, wie er in der Datenbank steht -- **ohne** das Geraet zu fragen.
|
||||
|
||||
Frueher hat diese Funktion selbst gemessen, wenn die letzte Messung alt war.
|
||||
Das ist gestrichen, und zwar aus Erfahrung: jede Messung ist eine
|
||||
angefangene und abgebrochene Protokollverhandlung, und ein RealVNC Server
|
||||
zaehlt so etwas. Wer eine Bildschirmseite oeffnet, klopfte damit zweimal an
|
||||
-- einmal zum Messen, einmal fuer die Sitzung --, und das zweite Klopfen
|
||||
fiel dem ersten zum Opfer.
|
||||
|
||||
Jetzt beruehrt das Oeffnen einer Seite das Geraet gar nicht mehr. Die
|
||||
Erreichbarkeit kommt aus dem Ueberwachungsdurchlauf (ein blosser
|
||||
Verbindungsaufbau), die Anmeldeverfahren aus der letzten **ausdruecklichen**
|
||||
Pruefung -- der Knopf dafuer steht auf der Seite und wird gedrueckt, wenn
|
||||
etwas nicht geht.
|
||||
"""
|
||||
gespeichert = str(device.get("vnc_types") or "")
|
||||
return Zugang(
|
||||
ziel=ziel(device),
|
||||
port=port,
|
||||
erreichbar=bool(device.get("vnc_open")),
|
||||
verfahren=tuple(
|
||||
int(teil) for teil in gespeichert.split(",") if teil.strip().isdigit()
|
||||
),
|
||||
absage=str(device.get("vnc_reason") or ""),
|
||||
bewaehrt=int(device.get("vnc_method") or 0),
|
||||
)
|
||||
|
||||
|
||||
def verbindungsdatei(
|
||||
device: Mapping[str, Any], *, port: int = STANDARD_PORT, warnung_aus: bool = False
|
||||
) -> str:
|
||||
"""Der Inhalt der ``.vnc``-Datei -- drei Zeilen, keine vierte.
|
||||
|
||||
``ConnMethod=tcp`` ist die direkte Verbindung (nicht ueber den Cloud-Dienst
|
||||
von RealVNC), ``FriendlyName`` der Name, den der Viewer anzeigt, ``Host``
|
||||
das Ziel. Ein abweichender Port gehoert in dieser Datei an den Host, mit
|
||||
Doppelpunkt -- einen eigenen Schluessel dafuer gibt es nicht.
|
||||
|
||||
``warnung_aus`` haengt eine vierte Zeile an: ``WarnUnencrypted=False``. Sie
|
||||
betrifft **den Viewer**, nicht den Server -- er zeigt sonst bei jeder
|
||||
Verbindung zu einem Geraet ohne Streckenverschluesselung einen roten
|
||||
Hinweis, den man wegklicken muss. Wer ``Encryption=AlwaysOff`` auf dem
|
||||
Geraet gesetzt hat (damit der Browser ueberhaupt verbinden kann), sieht
|
||||
diesen Hinweis fortan bei jedem Start. Er bleibt in der Vorgabe stehen:
|
||||
eine unverschluesselte Strecke soll man einmal bewusst hinnehmen, nicht
|
||||
stillschweigend. Abschaltbar unter Verwaltung -> System.
|
||||
"""
|
||||
host = ziel(device)
|
||||
if port != STANDARD_PORT:
|
||||
host = f"{host}:{port}"
|
||||
zeilen = [
|
||||
"ConnMethod=tcp",
|
||||
f"FriendlyName={str(device.get('name') or host).strip()}",
|
||||
f"Host={host}",
|
||||
]
|
||||
if warnung_aus:
|
||||
zeilen.append("WarnUnencrypted=False")
|
||||
return ZEILE.join(zeilen) + ZEILE
|
||||
|
||||
|
||||
def dateiname(device: Mapping[str, Any]) -> str:
|
||||
"""``HAP02.vnc`` -- aus dem Namen, auf Unbedenkliches beschraenkt.
|
||||
|
||||
Auch der Punkt faellt weg, nicht nur der Schraegstrich: ein Geraetename ist
|
||||
Freitext, und ein Dateiname, in dem ".." vorkommen kann, ist eine Frage,
|
||||
die man sich gar nicht erst stellen muss.
|
||||
"""
|
||||
name = re.sub(r"[^A-Za-z0-9_-]+", "-", str(device.get("name") or "client")).strip("-")
|
||||
return f"{name or 'client'}.vnc"
|
||||
|
||||
|
||||
def port_aus_einstellungen(extension: Any) -> int:
|
||||
"""Der eingestellte Port -- oder 0, wenn der Zugang abgeschaltet ist.
|
||||
|
||||
Nimmt die Erweiterung statt der Verbindung, weil der Ueberwachungsprozess
|
||||
ausserhalb eines Anfragekontextes laeuft und seine eigene Sitzung oeffnet.
|
||||
"""
|
||||
with extension.database.session() as conn:
|
||||
if not extension.settings.get(conn, "vnc_enabled"):
|
||||
return 0
|
||||
return int(extension.settings.get(conn, "vnc_port") or STANDARD_PORT)
|
||||
@@ -51,28 +51,28 @@ def available() -> bool:
|
||||
HINTS: tuple[tuple[str, str], ...] = (
|
||||
(
|
||||
"NT_STATUS_LOGON_FAILURE",
|
||||
"Benutzername oder Passwort wurden vom Windows-Geraet abgewiesen. Bei einem "
|
||||
"Domaenenkonto die Form DOMAENE\\benutzer verwenden -- mit *einem* "
|
||||
"Benutzername oder Passwort wurden vom Windows-Gerät abgewiesen. Bei einem "
|
||||
"Domänenkonto die Form DOMAENE\\benutzer verwenden – mit *einem* "
|
||||
"Backslash.",
|
||||
),
|
||||
(
|
||||
"NT_STATUS_ACCESS_DENIED",
|
||||
"Das Konto darf auf diesem Geraet keinen Neustart ausloesen. Es braucht das Recht "
|
||||
"'Herunterfahren von einem Remotesystem aus' -- Mitglied der lokalen "
|
||||
"Administratoren genuegt dafuer in der Regel.",
|
||||
"Das Konto darf auf diesem Gerät keinen Neustart auslösen. Es braucht das Recht "
|
||||
"'Herunterfahren von einem Remotesystem aus' | Mitglied der lokalen "
|
||||
"Administratoren genügt dafür in der Regel.",
|
||||
),
|
||||
(
|
||||
"NT_STATUS_CONNECTION_REFUSED",
|
||||
"Das Geraet nimmt keine RPC-Verbindung an. Die Datei- und Druckerfreigabe muss "
|
||||
"Das Gerät nimmt keine RPC-Verbindung an. Die Datei- und Druckerfreigabe muss "
|
||||
"aktiv und Port 445 in der Windows-Firewall freigegeben sein.",
|
||||
),
|
||||
(
|
||||
"NT_STATUS_IO_TIMEOUT",
|
||||
"Keine Antwort auf Port 445. Ist das Geraet erreichbar und die Firewall offen?",
|
||||
"Keine Antwort auf Port 445. Ist das Gerät erreichbar und die Firewall offen?",
|
||||
),
|
||||
(
|
||||
"NT_STATUS_HOST_UNREACHABLE",
|
||||
"Das Geraet ist nicht erreichbar.",
|
||||
"Das Gerät ist nicht erreichbar.",
|
||||
),
|
||||
# Diese Meldung liest sich wie ein technisches Detail, hat aber fast immer
|
||||
# eine einzige Ursache: das Konto ist auf dem Zielgeraet kein Administrator.
|
||||
@@ -81,14 +81,14 @@ HINTS: tuple[tuple[str, str], ...] = (
|
||||
# bekommt ein Standardkonto die winreg-Pipe gar nicht zu sehen.
|
||||
(
|
||||
"pipe winreg",
|
||||
"Das Konto hat auf diesem Geraet keine Administratorrechte. Ein Neustart ueber "
|
||||
"RPC braucht sie: die Pipe, ueber die er laeuft, ist fuer Standardkonten nicht "
|
||||
"sichtbar. Ein Konto der lokalen Administratoren hinterlegen -- pruefen laesst "
|
||||
"Das Konto hat auf diesem Gerät keine Administratorrechte. Ein Neustart über "
|
||||
"RPC braucht sie: die Pipe, über die er läuft, ist für Standardkonten nicht "
|
||||
"sichtbar. Ein Konto der lokalen Administratoren hinterlegen – prüfen lässt "
|
||||
"sich das mit 'net rpc service status RemoteRegistry'.",
|
||||
),
|
||||
(
|
||||
"NT_STATUS_OBJECT_NAME_NOT_FOUND",
|
||||
"Der benoetigte RPC-Dienst antwortet auf dem Geraet nicht. Meist fehlen dem "
|
||||
"Der benötigte RPC-Dienst antwortet auf dem Gerät nicht. Meist fehlen dem "
|
||||
"Konto die Administratorrechte; sonst ist der Dienst 'Remoteregistrierung' "
|
||||
"gestoppt.",
|
||||
),
|
||||
@@ -143,8 +143,8 @@ def _net_with_credentials(
|
||||
return RemoteResult(
|
||||
False,
|
||||
"",
|
||||
f"Auf diesem Server fehlt das Paket {REQUIRED_PACKAGE}. Ohne 'net' laesst sich "
|
||||
"ein Windows-Geraet von hier nicht ansprechen.",
|
||||
f"Auf diesem Server fehlt das Paket {REQUIRED_PACKAGE}. Ohne 'net' lässt sich "
|
||||
"ein Windows-Gerät von hier nicht ansprechen.",
|
||||
)
|
||||
if not username:
|
||||
return RemoteResult(False, "", "Es sind keine Zugangsdaten hinterlegt.")
|
||||
@@ -176,7 +176,7 @@ def _net_with_credentials(
|
||||
check=False,
|
||||
)
|
||||
except subprocess.TimeoutExpired:
|
||||
return RemoteResult(False, "", f"Zeitlimit von {timeout}s ueberschritten.")
|
||||
return RemoteResult(False, "", f"Zeitlimit von {timeout}s überschritten.")
|
||||
except OSError as exc:
|
||||
return RemoteResult(False, "", str(exc))
|
||||
return completed.returncode, (completed.stdout + completed.stderr).strip()
|
||||
@@ -190,7 +190,7 @@ def _net_with_credentials(
|
||||
stream.flush()
|
||||
os.fsync(stream.fileno())
|
||||
except OSError: # pragma: no cover - defensiv
|
||||
logger.warning("Anmeldedatei konnte nicht ueberschrieben werden: %s", path)
|
||||
logger.warning("Anmeldedatei konnte nicht überschrieben werden: %s", path)
|
||||
try:
|
||||
os.unlink(path)
|
||||
except OSError: # pragma: no cover - defensiv
|
||||
@@ -236,10 +236,10 @@ def check(
|
||||
return RemoteResult(
|
||||
False,
|
||||
output,
|
||||
"Anmeldung erfolgreich, aber das Konto hat auf diesem Geraet keine "
|
||||
"Administratorrechte. Fuer einen Neustart ueber RPC braucht es sie.",
|
||||
"Anmeldung erfolgreich, aber das Konto hat auf diesem Gerät keine "
|
||||
"Administratorrechte. Für einen Neustart über RPC braucht es sie.",
|
||||
)
|
||||
return RemoteResult(False, output, hint_for(output) or "Pruefung fehlgeschlagen.")
|
||||
return RemoteResult(False, output, hint_for(output) or "Prüfung fehlgeschlagen.")
|
||||
|
||||
|
||||
def reboot(
|
||||
@@ -275,5 +275,5 @@ def reboot(
|
||||
return ergebnis
|
||||
code, output = ergebnis
|
||||
if code == 0:
|
||||
return RemoteResult(True, output or "Neustart ausgeloest.")
|
||||
return RemoteResult(True, output or "Neustart ausgelöst.")
|
||||
return RemoteResult(False, output, hint_for(output))
|
||||
@@ -12,8 +12,8 @@ TESM_SETTINGS: tuple[SettingSpec, ...] = (
|
||||
"monitor_interval_seconds",
|
||||
300,
|
||||
"int",
|
||||
"Pruefintervall (Sekunden)",
|
||||
hint="Abstand zwischen zwei Erreichbarkeitspruefungen. Lizenzpflichtig.",
|
||||
"Prüfintervall (Sekunden)",
|
||||
hint="Abstand zwischen zwei Erreichbarkeitsprüfungen. Lizenzpflichtig.",
|
||||
section="monitoring",
|
||||
minimum=30,
|
||||
maximum=86400,
|
||||
@@ -23,8 +23,8 @@ TESM_SETTINGS: tuple[SettingSpec, ...] = (
|
||||
2,
|
||||
"int",
|
||||
"Fehlversuche bis zum automatischen PoE-Neustart",
|
||||
hint="Erst nach so vielen aufeinanderfolgenden Fehlschlaegen wird geschaltet. "
|
||||
"Der Vorgaenger schaltete sofort beim ersten Ausfall.",
|
||||
hint="Erst nach so vielen aufeinanderfolgenden Fehlschlägen wird geschaltet. "
|
||||
"Der Vorgänger schaltete sofort beim ersten Ausfall.",
|
||||
section="monitoring",
|
||||
minimum=1,
|
||||
maximum=10,
|
||||
@@ -40,11 +40,54 @@ TESM_SETTINGS: tuple[SettingSpec, ...] = (
|
||||
"dashboard_public",
|
||||
True,
|
||||
"bool",
|
||||
"Statusuebersicht ohne Anmeldung erreichbar",
|
||||
hint="Zeigt ausschliesslich Name und Erreichbarkeit aktiver Geraete -- keine "
|
||||
"Statusübersicht ohne Anmeldung erreichbar",
|
||||
hint="Zeigt ausschließlich Name und Erreichbarkeit aktiver Geräte – keine "
|
||||
"IP-Adressen, keine Switch-Zuordnung, keine Verwaltungsfunktionen.",
|
||||
section="monitoring",
|
||||
),
|
||||
# -- Bildschirmzugriff (VNC) -------------------------------------------- #
|
||||
SettingSpec(
|
||||
"vnc_enabled",
|
||||
True,
|
||||
"bool",
|
||||
"Bildschirmzugriff (VNC) anbieten",
|
||||
hint="Der Überwachungsdurchlauf fragt bei jedem erreichbaren Client kurz, ob "
|
||||
"auf diesem Port ein VNC-Server antwortet; nur dort erscheint der Knopf. "
|
||||
"Sehen darf ihn trotzdem nur, wer das Recht dafür hat.",
|
||||
section="monitoring",
|
||||
),
|
||||
SettingSpec(
|
||||
"vnc_port",
|
||||
5900,
|
||||
"int",
|
||||
"VNC-Port",
|
||||
hint="5900 ist Anzeige :0 – die Vorgabe eines Raspberry Pi.",
|
||||
section="monitoring",
|
||||
minimum=1,
|
||||
maximum=65535,
|
||||
),
|
||||
SettingSpec(
|
||||
"vnc_idle_timeout",
|
||||
300,
|
||||
"int",
|
||||
"Bildschirmsitzung bei Untätigkeit trennen nach (Sekunden)",
|
||||
hint="Gezählt wird, wenn in **keine** Richtung mehr etwas fließt: der Bildschirm "
|
||||
"ändert sich nicht und es kommt keine Eingabe. Eine Sitzung, die jemand offen "
|
||||
"vergisst, hält sonst die Verbindung zum Gerät. 0 schaltet die Trennung ab.",
|
||||
section="monitoring",
|
||||
minimum=0,
|
||||
maximum=86400,
|
||||
),
|
||||
SettingSpec(
|
||||
"vnc_warn_unencrypted_off",
|
||||
False,
|
||||
"bool",
|
||||
"Warnung des Viewers vor unverschlüsselter Verbindung unterdrücken",
|
||||
hint="Setzt WarnUnencrypted=False in die Verbindungsdatei. Betrifft nur den "
|
||||
"RealVNC Viewer auf dem Arbeitsplatz, nicht die Verbindung selbst: die bleibt "
|
||||
"unverschlüsselt, wenn das Gerät es so anbietet.",
|
||||
section="monitoring",
|
||||
),
|
||||
# -- DHCP --------------------------------------------------------------- #
|
||||
SettingSpec("dhcp_domain", "local", "str", "Domainname", section="dhcp"),
|
||||
SettingSpec(
|
||||
@@ -74,20 +117,11 @@ TESM_SETTINGS: tuple[SettingSpec, ...] = (
|
||||
"fileshare_max_age_hours",
|
||||
12,
|
||||
"int",
|
||||
"Einbindungen automatisch loesen nach (Stunden)",
|
||||
"Einbindungen automatisch lösen nach (Stunden)",
|
||||
section="fileshare",
|
||||
minimum=1,
|
||||
maximum=168,
|
||||
),
|
||||
SettingSpec(
|
||||
"fileshare_upload_max_mb",
|
||||
512,
|
||||
"int",
|
||||
"Maximale Dateigroesse beim Hochladen (MB)",
|
||||
section="fileshare",
|
||||
minimum=1,
|
||||
maximum=8192,
|
||||
),
|
||||
# -- Wartung ------------------------------------------------------------ #
|
||||
SettingSpec(
|
||||
"maintenance_command_timeout",
|
||||
@@ -102,26 +136,50 @@ TESM_SETTINGS: tuple[SettingSpec, ...] = (
|
||||
"maintenance_reboot_wait",
|
||||
300,
|
||||
"int",
|
||||
"Wartezeit auf die Rueckkehr nach einem Neustart (Sekunden)",
|
||||
"Wartezeit auf die Rückkehr nach einem Neustart (Sekunden)",
|
||||
section="maintenance",
|
||||
minimum=30,
|
||||
maximum=1800,
|
||||
),
|
||||
# -- Terminal ----------------------------------------------------------- #
|
||||
# -- Unterschriften ------------------------------------------------------ #
|
||||
SettingSpec(
|
||||
"terminal_enabled",
|
||||
"signature_witness_enabled",
|
||||
True,
|
||||
"bool",
|
||||
"Browser-Terminal fuer Switche aktiv",
|
||||
hint="Auch wenn aktiv: erfordert das Recht 'Ausfuehren' auf Switche, eine frische "
|
||||
"Passwortbestaetigung und wird vollstaendig protokolliert.",
|
||||
"Unterschriften mit dem ablegenden Konto bezeugen",
|
||||
hint="Eine Zeile unter der **ersten** Unterschrift nennt das Konto, das das "
|
||||
"Dokument in TESM abgelegt hat. In den Dokumenteigenschaften und im "
|
||||
"Änderungsprotokoll steht es ohnehin, unabhängig von diesem Schalter. Der Name in "
|
||||
"der Unterschrift ist eine Angabe des Unterschreibenden – wie auf Papier; das "
|
||||
"Konto ist belegt. Eine Passwortabfrage gibt es dafür nicht.",
|
||||
section="fileshare",
|
||||
),
|
||||
# -- Terminal ----------------------------------------------------------- #
|
||||
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",
|
||||
),
|
||||
SettingSpec(
|
||||
"terminal_idle_timeout",
|
||||
300,
|
||||
"int",
|
||||
"Terminal bei Untaetigkeit trennen nach (Sekunden)",
|
||||
"Terminal bei Untätigkeit trennen nach (Sekunden)",
|
||||
section="monitoring",
|
||||
minimum=60,
|
||||
maximum=3600,
|
||||
|
||||
@@ -84,22 +84,7 @@
|
||||
<a class="btn btn--sm btn--ghost" href="{{ url_for('devices.detail', device_id=tile.id) }}">
|
||||
Details
|
||||
</a>
|
||||
{% if current_user.has('devices.execute') and tile.restart_methods %}
|
||||
{% set method = tile.restart_methods[0] %}
|
||||
<form method="post" data-no-guard
|
||||
action="{{ url_for('devices.restart_device', device_id=tile.id) }}">
|
||||
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
|
||||
<input type="hidden" name="method" value="{{ method.key }}">
|
||||
<button class="btn btn--sm" type="submit" title="{{ method.hint }}"
|
||||
data-busy-label="Startet ..."
|
||||
data-confirm="{{ tile.name }} über {{ method.label }} neu starten? {{ method.hint }}"
|
||||
data-confirm-title="Neustart" data-confirm-ok="Neu starten"
|
||||
data-confirm-style="primary">
|
||||
{{ icon('power', size=14) }} {{ method.label }}
|
||||
</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
{% if current_user.has('devices.edit') and tile.state == 'disabled' %}
|
||||
{% if current_user.has('devices.write') and tile.state == 'disabled' %}
|
||||
<form method="post" action="{{ url_for('devices.toggle_active', device_id=tile.id) }}"
|
||||
data-no-guard>
|
||||
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
|
||||
@@ -108,6 +93,54 @@
|
||||
</form>
|
||||
{% endif %}
|
||||
</div>
|
||||
{# Die beiden Eingriffe stehen unten und immer an derselben
|
||||
Stelle: links der Bildschirm, rechts der Neustart. Wer
|
||||
eine Reihe Kacheln abarbeitet, trifft denselben Knopf,
|
||||
ohne hinzusehen -- und verwechselt ihn nicht mit dem
|
||||
anderen. #}
|
||||
{% if tile.vnc or tile.restart_methods %}
|
||||
<div class="tile__actions">
|
||||
{% if tile.vnc %}
|
||||
{# Die Sitzung laeuft in TESM selbst: ein Klick, kein
|
||||
Viewer auf dem Arbeitsplatz, keine Datei.
|
||||
|
||||
Mit frischer Passwortbestätigung öffnet sie sich hier
|
||||
im Fenster -- man bleibt bei der Übersicht und sieht
|
||||
die anderen Geräte weiter. Ohne sie führt der Knopf
|
||||
auf die Geräteseite, wo danach gefragt wird. #}
|
||||
{% if vnc_fenster %}
|
||||
<button class="btn btn--sm" type="button"
|
||||
data-vnc-oeffnen="{{ tile.vnc_quelle }}"
|
||||
data-vnc-name="{{ tile.name }}"
|
||||
title="Bildschirm im Fenster öffnen">
|
||||
{{ icon('monitor', size=14) }} VNC
|
||||
</button>
|
||||
{% else %}
|
||||
<a class="btn btn--sm" href="{{ url_for('screen.screen', device_id=tile.id) }}"
|
||||
title="Bildschirm im Browser öffnen">
|
||||
{{ icon('monitor', size=14) }} VNC
|
||||
</a>
|
||||
{% endif %}
|
||||
{% else %}
|
||||
<span class="tile__spacer" aria-hidden="true"></span>
|
||||
{% endif %}
|
||||
{% if tile.restart_methods %}
|
||||
{% set method = tile.restart_methods[0] %}
|
||||
<form method="post" data-no-guard
|
||||
action="{{ url_for('devices.restart_device', device_id=tile.id) }}">
|
||||
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
|
||||
<input type="hidden" name="method" value="{{ method.key }}">
|
||||
<button class="btn btn--sm" type="submit" title="{{ method.hint }}"
|
||||
data-busy-label="Startet ..."
|
||||
data-confirm="{{ tile.name }} über {{ method.label }} neu starten? {{ method.hint }}"
|
||||
data-confirm-title="Neustart" data-confirm-ok="Neu starten"
|
||||
data-confirm-style="primary">
|
||||
{{ icon('power', size=14) }} {{ method.label }}
|
||||
</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
</article>
|
||||
{% endfor %}
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
{# Die Bildschirmfläche -- einmal geschrieben, an zwei Orten benutzt: auf der
|
||||
Geräteseite und im Fenster der Statusübersicht.
|
||||
|
||||
Die Teile finden sich über `data-rolle`, nicht über Kennungen: im Fenster
|
||||
gäbe es sie sonst zweimal auf derselben Seite, und die zweite wäre die, die
|
||||
niemand findet. #}
|
||||
{% macro vnc_flaeche(quelle='') %}
|
||||
<div class="screen" {% if quelle %}data-sitzung="{{ quelle }}"{% endif %}>
|
||||
<div data-rolle="leinwand" class="screen__canvas" hidden></div>
|
||||
</div>
|
||||
{% endmacro %}
|
||||
|
||||
{% macro vnc_status() %}
|
||||
<div data-rolle="status" class="screen__status">Wird vorbereitet …</div>
|
||||
{% endmacro %}
|
||||
|
||||
{% macro vnc_zugangsdaten() %}
|
||||
<form data-rolle="zugangsdaten" class="row row--tight" hidden onsubmit="return false">
|
||||
<label class="field">
|
||||
<span class="field__label">Benutzer</span>
|
||||
<input class="input" type="text" data-rolle="benutzer" autocomplete="off">
|
||||
</label>
|
||||
<label class="field">
|
||||
<span class="field__label">Passwort</span>
|
||||
<input class="input" type="password" data-rolle="passwort" autocomplete="off">
|
||||
</label>
|
||||
<button class="btn btn--primary" type="button" data-rolle="anmelden">Anmelden</button>
|
||||
</form>
|
||||
{% endmacro %}
|
||||
|
||||
{# Tasten, die der Arbeitsplatz selbst abfängt und die deshalb einen Knopf
|
||||
brauchen: Strg+Alt+Entf schaltet der Browser gar nicht erst durch, Alt+F4
|
||||
schlösse das Fenster hier statt das Fenster dort. #}
|
||||
{% macro vnc_tasten() %}
|
||||
<button class="btn btn--sm" type="button" data-rolle="taste-cad" hidden
|
||||
title="Strg+Alt+Entf an das Gerät senden">Strg+Alt+Entf</button>
|
||||
<button class="btn btn--sm" type="button" data-rolle="taste-altf4" hidden
|
||||
title="Alt+F4 an das Gerät senden – schließt dort das oberste Fenster">Alt+F4</button>
|
||||
<button class="btn btn--sm" type="button" data-rolle="taste-f5" hidden
|
||||
title="F5 an das Gerät senden – der Browser des Arbeitsplatzes würde sonst diese Seite neu laden">F5</button>
|
||||
<button class="btn btn--sm" type="button" data-rolle="trennen" hidden>Trennen</button>
|
||||
{% endmacro %}
|
||||
@@ -149,10 +149,10 @@ sie getrennt von der Sicherung auf.', 'warning', 'Passphrase') }}
|
||||
</div>
|
||||
<div class="card__body">
|
||||
<ul class="stack stack--tight fs-sm muted">
|
||||
<li>Verzeichniskonten werden nie exportiert -- sie entstehen am Ziel bei der ersten Anmeldung neu.</li>
|
||||
<li>Verzeichniskonten werden nie exportiert – sie entstehen am Ziel bei der ersten Anmeldung neu.</li>
|
||||
<li>Lizenz- und Aktivierungszustand bleiben ausgenommen: sie gehören zur Maschine, nicht zur Sicherung.</li>
|
||||
<li>Sitzungen, Ratenbegrenzung und das Änderungsprotokoll sind nicht Teil der Sicherung.</li>
|
||||
<li>Der Zwischenstand des Imports liegt in der Datenbank -- ein Dienst-Neustall verliert ihn nicht.</li>
|
||||
<li>Der Zwischenstand des Imports liegt in der Datenbank – ein Dienst-Neustall verliert ihn nicht.</li>
|
||||
</ul>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -79,7 +79,7 @@ davon abhängt, welche Geräte auf der Wartungsseite erscheinen.', 'info') }}
|
||||
data-confirm-style="primary">{{ icon('eye', size=15) }}</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
{% if current_user.has('credentials.edit') %}
|
||||
{% if current_user.has('credentials.write') %}
|
||||
<button class="btn btn--sm btn--ghost" type="button"
|
||||
data-dialog-open="credential-edit-{{ entry.id }}" aria-label="Bearbeiten">
|
||||
{{ icon('pencil', size=15) }}
|
||||
@@ -121,7 +121,7 @@ davon abhängt, welche Geräte auf der Wartungsseite erscheinen.', 'info') }}
|
||||
</form>
|
||||
{% endcall %}
|
||||
{% endif %}
|
||||
{% if current_user.has('credentials.edit') %}
|
||||
{% if current_user.has('credentials.write') %}
|
||||
{% for entry in credentials %}
|
||||
{% call modal('credential-edit-' ~ entry.id, entry.name) %}
|
||||
<form method="post" action="{{ url_for('credentials.update', credential_id=entry.id) }}">
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
{% extends "tesm_core/base.html" %}
|
||||
{% from "tesm_core/macros.html" import search_box %}
|
||||
{% from "tesm_core/macros.html" import search_box, modal %}
|
||||
{% from "tesm/_vnc_flaeche.html" import vnc_flaeche, vnc_status, vnc_zugangsdaten, vnc_tasten %}
|
||||
|
||||
{% block page_title %}Statusübersicht{% endblock %}
|
||||
{% block page_sub %}
|
||||
@@ -19,4 +20,27 @@
|
||||
data-refresh-interval="20000">
|
||||
{% include "tesm/_dashboard_tiles.html" %}
|
||||
</div>
|
||||
|
||||
{% if vnc_fenster %}
|
||||
{# Ein Fenster für alle Kacheln: welches Gerät gemeint ist, bringt der Knopf
|
||||
beim Öffnen mit. Ein Dialog je Kachel wäre dieselbe Sache dutzendfach im
|
||||
Quelltext -- und dutzendfach eine Sitzung, die niemand aufräumt. #}
|
||||
{% call modal('vnc-fenster', 'Bildschirm', 'Tastatur und Maus gehen an das Gerät. Die Zwischenablage bleibt getrennt.', 'wide') %}
|
||||
<div class="modal__body stack" data-rolle="behaelter">
|
||||
<div class="row row--tight">
|
||||
<strong data-rolle="titel" class="flex-1"></strong>
|
||||
{{ vnc_tasten() }}
|
||||
</div>
|
||||
{{ vnc_status() }}
|
||||
{{ vnc_zugangsdaten() }}
|
||||
{{ vnc_flaeche() }}
|
||||
</div>
|
||||
{% endcall %}
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
{% if vnc_fenster %}
|
||||
<script type="module" src="{{ asset_url('js/screen.js') }}" nonce="{{ csp_nonce }}"></script>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
@@ -11,12 +11,18 @@
|
||||
{% endblock %}
|
||||
|
||||
{% block topbar_extra %}
|
||||
{% if terminal_enabled and current_user.has('maintenance.execute') and device.credential_id %}
|
||||
{% if terminal_enabled and current_user.has('device_console.execute') and device.credential_id %}
|
||||
<a class="btn btn--sm" href="{{ url_for('terminal.device_console', device_id=device.id) }}">
|
||||
{{ icon('terminal', size=15) }} Konsole
|
||||
</a>
|
||||
{% endif %}
|
||||
{% if current_user.has('devices.execute') and restart_methods %}
|
||||
{% if vnc and vnc.erreichbar %}
|
||||
<a class="btn btn--sm" href="{{ url_for('screen.screen', device_id=device.id) }}"
|
||||
title="Bildschirm im Browser öffnen">
|
||||
{{ icon('monitor', size=15) }} VNC
|
||||
</a>
|
||||
{% endif %}
|
||||
{% if restart_methods %}
|
||||
{% for method in restart_methods %}
|
||||
<form method="post" style="display:inline"
|
||||
action="{{ url_for('devices.restart_device', device_id=device.id) }}" data-no-guard>
|
||||
@@ -88,7 +94,7 @@
|
||||
<div class="flex-1">
|
||||
<div class="card__title">{{ icon('power', size=17) }} Neustarts</div>
|
||||
<div class="card__sub">
|
||||
Die letzten {{ events | length }} Neustarts -- über den PoE-Port wie per SSH
|
||||
Die letzten {{ events | length }} Neustarts – über den PoE-Port wie per SSH
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -138,7 +144,7 @@
|
||||
Ohne freigegebenen Schlüssel verweigern Wartung und Konsole die Verbindung.
|
||||
</div>
|
||||
</div>
|
||||
{% if current_user.has('devices.edit') %}
|
||||
{% if current_user.has('devices.write') %}
|
||||
<form method="post" action="{{ url_for('devices.probe_host_key', device_id=device.id) }}"
|
||||
data-no-guard>
|
||||
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
|
||||
@@ -151,7 +157,7 @@
|
||||
<div class="card__body stack">
|
||||
{% set probed_type = request.args.get('probed_type') %}
|
||||
{% set probed_fingerprint = request.args.get('probed_fingerprint') %}
|
||||
{% if probed_type and probed_fingerprint and current_user.has('devices.edit') %}
|
||||
{% if probed_type and probed_fingerprint and current_user.has('devices.write') %}
|
||||
<div class="alert alert--warning">
|
||||
{{ icon('alert', size=18) }}
|
||||
<div class="alert__body">
|
||||
@@ -182,7 +188,7 @@
|
||||
<dd class="mono fs-xs break-all">{{ key.fingerprint }}</dd>
|
||||
{% endfor %}
|
||||
</dl>
|
||||
{% if current_user.has('devices.edit') %}
|
||||
{% if current_user.has('devices.write') %}
|
||||
<form method="post"
|
||||
action="{{ url_for('devices.forget_host_key', device_id=device.id) }}"
|
||||
data-no-guard>
|
||||
@@ -206,7 +212,7 @@
|
||||
<section class="card">
|
||||
<div class="card__header">
|
||||
<div class="flex-1"><div class="card__title">{{ icon('info', size=17) }} Stammdaten</div></div>
|
||||
{% if current_user.has('devices.edit') %}
|
||||
{% if current_user.has('devices.write') %}
|
||||
<a class="btn btn--sm" href="{{ url_for('devices.overview') }}">Bearbeiten</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
@@ -225,6 +231,29 @@
|
||||
<dt>Kategorie</dt><dd>{{ device.category or '–' | safe }}</dd>
|
||||
<dt>Standort</dt><dd>{{ device.location or '–' | safe }}</dd>
|
||||
<dt>Zugangsdaten</dt><dd>{{ device.credential_name or 'keine' }}</dd>
|
||||
{% if vnc %}
|
||||
<dt>Bildschirm (VNC)</dt>
|
||||
<dd>
|
||||
{% if vnc.erreichbar %}
|
||||
<span class="mono">{{ vnc.ziel }}:{{ vnc.port }}</span>
|
||||
<div class="fs-xs faint">
|
||||
<a href="{{ url_for('screen.screen', device_id=device.id) }}">
|
||||
Im Browser öffnen</a> – oder die
|
||||
<a href="{{ url_for('devices.vnc_connection', device_id=device.id) }}">
|
||||
Verbindungsdatei</a> für einen Viewer auf dem Arbeitsplatz; sie
|
||||
enthält nur Ziel und Namen.
|
||||
{% if vnc.hinderungsgrund %}
|
||||
<div class="mt-1">{{ vnc.hinderungsgrund }}</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% else %}
|
||||
<span class="faint">
|
||||
kein VNC-Server auf {{ vnc.ziel }}:{{ vnc.port }}
|
||||
{%- if device.state != 'online' %} (Client antwortet gerade nicht){% endif %}
|
||||
</span>
|
||||
{% endif %}
|
||||
</dd>
|
||||
{% endif %}
|
||||
<dt>Automatischer Neustart</dt><dd>{{ device.auto_restart | yes_no }}</dd>
|
||||
<dt>Angelegt</dt><dd>{{ device.created_at | dt }}</dd>
|
||||
<dt>Geändert</dt>
|
||||
@@ -232,7 +261,7 @@
|
||||
</dl>
|
||||
{% if device.notes %}<p class="fs-sm muted mt-3">{{ device.notes }}</p>{% endif %}
|
||||
</div>
|
||||
{% if current_user.has('devices.edit') %}
|
||||
{% if current_user.has('devices.write') %}
|
||||
<div class="card__footer">
|
||||
<form method="post" action="{{ url_for('devices.toggle_active', device_id=device.id) }}" data-no-guard>
|
||||
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
|
||||
|
||||
@@ -213,10 +213,10 @@
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="actions">
|
||||
{% if current_user.has('devices.execute') %}
|
||||
{% if restart_methods.get(device.id) %}
|
||||
{{ restart_control(device, restart_methods.get(device.id, []), compact=True) }}
|
||||
{% endif %}
|
||||
{% if current_user.has('devices.edit') %}
|
||||
{% if current_user.has('devices.write') %}
|
||||
<button class="btn btn--sm btn--ghost" type="button"
|
||||
data-dialog-open="device-edit-{{ device.id }}" aria-label="Bearbeiten">
|
||||
{{ icon('pencil', size=15) }}
|
||||
@@ -263,7 +263,7 @@ nicht -- damit kein Neustart vorgetäuscht wird, der nichts bewirkt.', 'info') }
|
||||
{% endcall %}
|
||||
{% endif %}
|
||||
|
||||
{% if current_user.has('devices.edit') %}
|
||||
{% if current_user.has('devices.write') %}
|
||||
{% for device in devices %}
|
||||
{% call modal('device-edit-' ~ device.id, device.name, device.mac, 'wide') %}
|
||||
<form method="post" action="{{ url_for('devices.update', device_id=device.id) }}">
|
||||
|
||||
@@ -27,7 +27,7 @@
|
||||
<div class="stat">
|
||||
<span class="stat__label">Reservierungen</span>
|
||||
<span class="stat__value">{{ reservations | length }}</span>
|
||||
<span class="stat__hint">automatisch + manüll</span>
|
||||
<span class="stat__hint">automatisch + manuell</span>
|
||||
</div>
|
||||
<div class="stat">
|
||||
<span class="stat__label">Leases</span>
|
||||
@@ -39,7 +39,7 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if current_user.has('dhcp.execute') %}
|
||||
{% if current_user.has('dhcp_service.execute') %}
|
||||
<section class="card">
|
||||
<div class="card__header">
|
||||
<div class="flex-1">
|
||||
@@ -72,7 +72,7 @@
|
||||
<div class="flex-1">
|
||||
<div class="card__title">{{ icon('network', size=17) }} Subnetze</div>
|
||||
<div class="card__sub">
|
||||
Subnetz und Maske kommen vom laufenden System -- eingegeben wird nur der Adressbereich.
|
||||
Subnetz und Maske kommen vom laufenden System – eingegeben wird nur der Adressbereich.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -89,7 +89,7 @@
|
||||
<td class="mono fs-sm">{{ subnet.dns or '–' | safe }}</td>
|
||||
<td>{% if subnet.enabled %}{{ pill('aktiv', 'online') }}{% else %}{{ pill('pausiert', '') }}{% endif %}</td>
|
||||
<td class="actions">
|
||||
{% if current_user.has('dhcp.edit') %}
|
||||
{% if current_user.has('dhcp_subnets.write') %}
|
||||
<form method="post" action="{{ url_for('dhcp.delete_subnet', subnet_id=subnet.id) }}"
|
||||
style="display:inline" data-no-guard>
|
||||
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
|
||||
@@ -109,7 +109,7 @@
|
||||
{{ empty_state('Kein Subnetz', 'Ohne Subnetz startet Kea nicht.', 'network') }}
|
||||
{% endif %}
|
||||
|
||||
{% if current_user.has('dhcp.edit') %}
|
||||
{% if current_user.has('dhcp_subnets.create') %}
|
||||
<form method="post" action="{{ url_for('dhcp.add_subnet') }}">
|
||||
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
|
||||
<div class="card__body">
|
||||
@@ -135,7 +135,7 @@
|
||||
</section>
|
||||
|
||||
{# -- Globale Einstellungen --------------------------------------------- #}
|
||||
{% if current_user.has('dhcp.edit') %}
|
||||
{% if current_user.has('dhcp_subnets.write') %}
|
||||
<section class="card">
|
||||
<div class="card__header">
|
||||
<div class="flex-1"><div class="card__title">{{ icon('sliders', size=17) }} Globale Einstellungen</div></div>
|
||||
@@ -183,7 +183,7 @@
|
||||
</td>
|
||||
<td class="fs-xs mono">{{ option.type }}</td>
|
||||
<td>
|
||||
{% if current_user.has('dhcp.edit') %}
|
||||
{% if current_user.has('dhcp_options.write') %}
|
||||
<form method="post" action="{{ url_for('dhcp.set_option_value') }}" class="row row--tight"
|
||||
data-no-guard>
|
||||
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
|
||||
@@ -197,7 +197,7 @@
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="actions">
|
||||
{% if current_user.has('dhcp.edit') and not option.is_standard %}
|
||||
{% if current_user.has('dhcp_options.write') and not option.is_standard %}
|
||||
<form method="post" action="{{ url_for('dhcp.delete_custom_option', option_id=option.id) }}"
|
||||
data-no-guard>
|
||||
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
|
||||
@@ -213,7 +213,7 @@
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% if current_user.has('dhcp.edit') %}
|
||||
{% if current_user.has('dhcp_options.write') %}
|
||||
<form method="post" action="{{ url_for('dhcp.add_custom_option') }}">
|
||||
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
|
||||
<div class="card__body">
|
||||
@@ -339,7 +339,7 @@
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="actions">
|
||||
{% if reservation.source == 'manüll' and current_user.has('dhcp.edit') %}
|
||||
{% if reservation.source == 'manuell' and current_user.has('dhcp_reservations.delete') %}
|
||||
<form method="post" data-no-guard
|
||||
action="{{ url_for('dhcp.delete_reservation', reservation_id=reservation.reservation_id) }}">
|
||||
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
|
||||
@@ -355,7 +355,7 @@
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% if current_user.has('dhcp.edit') %}
|
||||
{% if current_user.has('dhcp_reservations.create') %}
|
||||
<form method="post" action="{{ url_for('dhcp.add_reservation') }}">
|
||||
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
|
||||
<div class="card__body">
|
||||
@@ -377,6 +377,10 @@
|
||||
<div class="card__title">{{ icon('clock', size=17) }} Leases</div>
|
||||
<div class="card__sub">
|
||||
Direkt aus {{ lease_file }} gelesen – je Adresse gilt der letzte Eintrag.
|
||||
{% if current_user.has('dhcp_lease_adopt.read') and current_user.has('devices.create') %}
|
||||
Eine <strong>dynamische</strong> Lease lässt sich als Client übernehmen; sie bekommt
|
||||
damit eine feste Adresse und wird überwacht.
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -394,7 +398,7 @@
|
||||
<div class="table-wrap" style="max-height:420px;overflow:auto">
|
||||
<table class="table table--compact">
|
||||
<thead><tr><th>IP</th><th>MAC</th><th>Hostname</th><th>Art</th><th>Zustand</th>
|
||||
<th>Läuft ab</th></tr></thead>
|
||||
<th>Läuft ab</th><th></th></tr></thead>
|
||||
<tbody>
|
||||
{% for lease in leases %}
|
||||
<tr>
|
||||
@@ -413,6 +417,31 @@
|
||||
<td class="fs-sm">{{ lease.expires_text }}
|
||||
<div class="fs-xs faint">{{ lease.remaining_text }}</div>
|
||||
</td>
|
||||
<td class="nowrap">
|
||||
{# Übernehmen gibt es nur für dynamische Leases: was reserviert
|
||||
ist, gehört schon zu einem gepflegten Datensatz. Und nur,
|
||||
wenn beide Rechte stehen -- hier entsteht ein Client. #}
|
||||
{% if lease.address not in reserved_ips
|
||||
and current_user.has('dhcp_lease_adopt.read')
|
||||
and current_user.has('devices.create') %}
|
||||
{% if lease.mac in bekannte_macs %}
|
||||
<span class="faint fs-xs" title="Zu dieser MAC gibt es bereits einen Client">
|
||||
bereits Client
|
||||
</span>
|
||||
{% else %}
|
||||
<form method="post" action="{{ url_for('dhcp.adopt_lease') }}">
|
||||
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
|
||||
<input type="hidden" name="mac" value="{{ lease.mac }}">
|
||||
<button class="btn btn--sm btn--ghost" type="submit"
|
||||
data-confirm="{{ lease.hostname or lease.address }} als überwachten Client anlegen? Switch, Port und Zugangsdaten tragen Sie danach nach."
|
||||
data-confirm-title="Als Client übernehmen"
|
||||
data-confirm-ok="Übernehmen">
|
||||
{{ icon('plus', size=14) }} Als Client
|
||||
</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
@@ -434,7 +463,7 @@
|
||||
{{ icon('chevron-down', size=16) }}
|
||||
</summary>
|
||||
<pre class="log-view">{{ preview }}</pre>
|
||||
{% if current_user.has('dhcp.edit') %}
|
||||
{% if current_user.has('dhcp_config.execute') %}
|
||||
<div class="card__footer">
|
||||
<form method="post" action="{{ url_for('dhcp.write_config') }}" data-no-guard>
|
||||
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
{% block page_sub %}{{ mounts | length }} eingebundene Freigabe(n){% endblock %}
|
||||
|
||||
{% block topbar_extra %}
|
||||
{% if current_user.has('fileshare.edit') %}
|
||||
{% if current_user.has('fileshare.write') %}
|
||||
<a class="btn btn--sm" href="{{ url_for('fileshare.mappings') }}">
|
||||
{{ icon('settings', size=15) }} Zuordnungen
|
||||
</a>
|
||||
@@ -23,7 +23,7 @@
|
||||
<div class="tabs">
|
||||
{% for mount in mounts %}
|
||||
<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 %}
|
||||
</div>
|
||||
|
||||
@@ -33,9 +33,9 @@
|
||||
<div class="card__header">
|
||||
<div class="flex-1">
|
||||
<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 %}
|
||||
<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 %}
|
||||
</nav>
|
||||
<div class="card__sub mono fs-xs">{{ current.unc }}</div>
|
||||
@@ -69,8 +69,8 @@
|
||||
<tr>
|
||||
{% if current_user.has('fileshare.delete') %}<td></td>{% endif %}
|
||||
<td colspan="4">
|
||||
<a class="link-icon" href="{{ url_for('fileshare.browse', freigabe=token,
|
||||
pfad=(listing.breadcrumb[-2].relative if listing.breadcrumb | length > 1 else '')) }}">
|
||||
<a class="link-icon" href="{{ url_for('fileshare.browse', share=token,
|
||||
path=(listing.breadcrumb[-2].relative if listing.breadcrumb | length > 1 else '')) }}">
|
||||
{{ icon('chevron-right', size=14) }} eine Ebene höher
|
||||
</a>
|
||||
</td>
|
||||
@@ -79,13 +79,26 @@
|
||||
{% for entry in listing.directories %}
|
||||
<tr>
|
||||
{% 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 %}
|
||||
<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) }}
|
||||
<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 class="numeric faint">–</td>
|
||||
<td class="fs-sm">{{ entry.modified | dt }}</td>
|
||||
<td class="actions"></td>
|
||||
</tr>
|
||||
@@ -93,7 +106,7 @@
|
||||
{% for entry in listing.files %}
|
||||
<tr>
|
||||
{% 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 %}
|
||||
{# Der Name ist die grösste Fläche der Zeile -- er soll auch
|
||||
das Naheliegende tun. Was sich anzeigen lässt, öffnet die
|
||||
@@ -102,16 +115,27 @@
|
||||
{% if entry.previewable %}
|
||||
<button class="link-icon file-name" type="button"
|
||||
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-Pfad="{{ entry.relative }}"
|
||||
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 }}
|
||||
</button>
|
||||
{% else %}
|
||||
<a class="link-icon"
|
||||
href="{{ url_for('fileshare.download', token=token, pfad=entry.relative) }}">
|
||||
{{ icon('file-text', size=15) }} {{ entry.name }}
|
||||
</a>
|
||||
{% if current_user.has('fileshare.export') %}
|
||||
<a class="link-icon"
|
||||
href="{{ url_for('fileshare.download', token=token, path=entry.relative) }}">
|
||||
{{ 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 %}
|
||||
</td>
|
||||
<td class="numeric fs-sm">{{ entry.size | bytes }}</td>
|
||||
@@ -120,21 +144,27 @@
|
||||
{% if entry.previewable %}
|
||||
<button class="btn btn--sm btn--ghost" type="button"
|
||||
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-Pfad="{{ entry.relative }}"
|
||||
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>
|
||||
{% endif %}
|
||||
{% if current_user.has('fileshare.edit') %}
|
||||
{% if current_user.has('fileshare.write') %}
|
||||
<button class="btn btn--sm btn--ghost" type="button"
|
||||
data-dialog-open="file-rename"
|
||||
data-fill-Pfad="{{ entry.relative }}"
|
||||
data-fill-Name="{{ entry.name }}"
|
||||
data-fill-Name="{{ entry.stem }}"
|
||||
data-fill-Endung="{{ entry.suffix or '' }}"
|
||||
aria-label="Umbenennen">{{ icon('pencil', size=15) }}</button>
|
||||
{% endif %}
|
||||
<a class="btn btn--sm btn--ghost"
|
||||
href="{{ url_for('fileshare.download', token=token, pfad=entry.relative) }}"
|
||||
aria-label="Herunterladen">{{ icon('download', size=15) }}</a>
|
||||
{% if current_user.has('fileshare.export') %}
|
||||
<a class="btn btn--sm btn--ghost"
|
||||
href="{{ url_for('fileshare.download', token=token, path=entry.relative) }}"
|
||||
aria-label="Herunterladen">{{ icon('download', size=15) }}</a>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
@@ -167,7 +197,7 @@
|
||||
<form method="post" enctype="multipart/form-data" data-no-guard
|
||||
action="{{ url_for('fileshare.upload', token=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">
|
||||
<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>
|
||||
@@ -184,7 +214,7 @@
|
||||
{% call modal('folder-new', 'Ordner anlegen', listing.relative or 'Wurzelverzeichnis', 'narrow') %}
|
||||
<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="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__footer">
|
||||
<button class="btn" type="button" data-dialog-close>Abbrechen</button>
|
||||
@@ -201,29 +231,181 @@
|
||||
Dialog für alle Dateien statt einer je Zeile. #}
|
||||
{% call modal('file-preview', 'Vorschau', '', 'wide') %}
|
||||
<div class="modal__caption" data-fill-target="Name"></div>
|
||||
<div class="modal__body modal__body--flush">
|
||||
<iframe class="file-preview" title="Vorschau" data-fill-target="Src" data-fill-as="src"
|
||||
src="about:blank" referrerpolicy="same-origin"></iframe>
|
||||
{# Drei Anzeigen, eine je Art. Ein Rahmen für alles zeigte ein Foto vom
|
||||
iPad in Originalgrösse (also einen Ausschnitt) und ein PDF auf iPadOS
|
||||
nur als Standbild -- Formularfelder liessen sich nicht anfassen. #}
|
||||
<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.read') else '' }}"
|
||||
data-viewer-pdfjs="{{ asset_url('vendor/pdfjs/pdf.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 }}">
|
||||
<div class="viewer">
|
||||
<div class="viewer__stage" data-viewer-bildbuehne hidden>
|
||||
<img class="viewer__img" alt="" data-viewer-bild>
|
||||
</div>
|
||||
<div class="viewer__pdf" data-viewer-pdf hidden>
|
||||
{# Zwei Ebenen: aussen ein normaler Kasten, der sich die Hoehe mit
|
||||
Leiste und Speicherzeile teilt, innen der absolute Scrollbereich,
|
||||
den pdf.js verlangt. #}
|
||||
<div class="viewer__pdfscroll" data-viewer-pdfscroll>
|
||||
<div class="pdfViewer" data-viewer-pdfseiten></div>
|
||||
</div>
|
||||
</div>
|
||||
<iframe class="file-preview" title="Vorschau" data-viewer-rahmen
|
||||
src="about:blank" referrerpolicy="same-origin" hidden></iframe>
|
||||
{# Gefragt wird erst, wenn gespeichert werden soll -- vorher waere es
|
||||
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>
|
||||
{# 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">
|
||||
<span class="field__label">Ordner</span>
|
||||
{# Auswahl statt Eingabe mit Vorschlagsliste: auf einem iPad laesst
|
||||
sich ein <datalist> kaum bedienen -- das Freigabefeld daneben
|
||||
zeigt, wie es sein soll. #}
|
||||
<select class="select" data-viewer-ordner>
|
||||
<option value="">Wurzelverzeichnis der Freigabe</option>
|
||||
{% for eintrag in verzeichnisse %}
|
||||
<option value="{{ eintrag }}">{{ eintrag }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</label>
|
||||
<label class="viewer__feld">
|
||||
<span class="field__label">Dateiname (ohne Endung)</span>
|
||||
<input class="input" data-viewer-name autocomplete="off">
|
||||
</label>
|
||||
<label class="viewer__feld">
|
||||
<span class="field__label">Speichern als</span>
|
||||
{# Zwei Wege, beide gewollt: weiter ausfüllbar (das Bild wird das
|
||||
Aussehen des Knopfes und lässt sich später austauschen) oder
|
||||
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>
|
||||
{% if current_user.has('fileshare_pdf.write') %}
|
||||
<option value="formular">Formular – weiter ausfüllbar</option>
|
||||
{% endif %}
|
||||
<option value="fertig">Abgeschlossen – Felder gesperrt</option>
|
||||
</select>
|
||||
</label>
|
||||
{# Haken über den Knöpfen und beides rechts: gelesen wird die Zeile von
|
||||
links nach rechts (wohin, wie es heißt, wie gespeichert), und die
|
||||
Entscheidung steht am Ende. #}
|
||||
<div class="viewer__saveaktionen">
|
||||
<label class="check" data-viewer-ersetzenfeld>
|
||||
<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>
|
||||
{% 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>
|
||||
<button class="btn btn--sm btn--ghost" type="button" data-viewer-zoom="aus"
|
||||
aria-label="Verkleinern">{{ icon('minus', size=15) }}</button>
|
||||
<button class="btn btn--sm btn--ghost" type="button" data-viewer-zoom="fit">Anpassen</button>
|
||||
<button class="btn btn--sm btn--ghost" type="button" data-viewer-zoom="ein"
|
||||
aria-label="Vergrössern">{{ icon('plus', size=15) }}</button>
|
||||
<span class="viewer__hint fs-xs faint" data-viewer-hinweis></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal__footer">
|
||||
<a class="btn btn--sm" data-fill-target="Download" data-fill-as="href" href="#" download>
|
||||
{{ icon('download', size=15) }} Herunterladen
|
||||
</a>
|
||||
<button class="btn btn--primary" type="button" data-dialog-close>Schliessen</button>
|
||||
{% if current_user.has('fileshare.export') %}
|
||||
<a class="btn btn--sm" data-fill-target="Download" data-fill-as="href" href="#" download>
|
||||
{{ icon('download', size=15) }} Herunterladen
|
||||
</a>
|
||||
{% endif %}
|
||||
{# Unterschreiben ist eine eigene Zeile der Matrix: wer ausgefuellte
|
||||
Formulare ablegen darf, darf damit nicht unterschreiben. Der Server
|
||||
weist es ohnehin ab -- aber ein Knopf, der immer in eine Meldung
|
||||
laeuft, ist keiner. #}
|
||||
{% if current_user.has('fileshare_signature.execute') %}
|
||||
<button class="btn btn--sm" type="button" data-viewer-unterschreiben hidden>
|
||||
{{ icon('pencil', size=15) }} Unterschreiben
|
||||
</button>
|
||||
{% endif %}
|
||||
{% if current_user.has('fileshare_pdf.create') %}
|
||||
<button class="btn btn--sm" type="button" data-viewer-speichern hidden
|
||||
data-url="{{ url_for('fileshare.save_form', token=token) }}">
|
||||
{{ icon('save', size=15) }} Ausgefüllt speichern
|
||||
</button>
|
||||
{% endif %}
|
||||
<button class="btn btn--primary" type="button" data-dialog-close>Schließen</button>
|
||||
</div>
|
||||
{% endcall %}
|
||||
{% endif %}
|
||||
|
||||
{% if listing and current_user.has('fileshare.edit') %}
|
||||
{% if listing and current_user.has('fileshare.write') %}
|
||||
{% call modal('file-rename', 'Umbenennen', '', 'narrow') %}
|
||||
<form method="post" action="{{ url_for('fileshare.rename', token=token) }}">
|
||||
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
|
||||
<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">
|
||||
{{ field('name', 'Neuer Name', required=True, attrs='data-fill-target="Name"') }}
|
||||
{{ field('name', 'Neuer Name (ohne Endung)', required=True,
|
||||
attrs='data-fill-target="Name"') }}
|
||||
<p class="fs-xs faint">
|
||||
Umbenannt wird auf der Freigabe selbst -- es gibt dort keinen Papierkorb.
|
||||
Die Endung <code data-fill-target="Endung"></code> bleibt, wie sie ist --
|
||||
eine Datei ohne passende Endung öffnet auf der Freigabe nichts mehr.
|
||||
Umbenannt wird dort direkt; einen Papierkorb gibt es nicht.
|
||||
</p>
|
||||
</div>
|
||||
<div class="modal__footer">
|
||||
|
||||
@@ -33,7 +33,7 @@ Einbindungen.', 'info') }}
|
||||
<td class="fs-sm">{{ entry.ad_group_name }}
|
||||
<div class="fs-xs faint break-all">{{ entry.ad_group_dn }}</div></td>
|
||||
<td class="actions">
|
||||
{% if current_user.has('fileshare.edit') %}
|
||||
{% if current_user.has('fileshare.write') %}
|
||||
<button class="btn btn--sm btn--ghost" type="button"
|
||||
data-dialog-open="mapping-edit-{{ entry.id }}"
|
||||
aria-label="Bearbeiten">{{ icon('pencil', size=15) }}</button>
|
||||
@@ -80,7 +80,7 @@ Einbindungen.', 'info') }}
|
||||
{# Bearbeiten statt neu anlegen: bei einem Tippfehler im UNC-Pfad oder im
|
||||
Anzeigenamen soll man ihn korrigieren können. Sonst legt man daneben einen
|
||||
zweiten Eintrag an und vergisst, den ersten zu löschen. #}
|
||||
{% if current_user.has('fileshare.edit') %}
|
||||
{% if current_user.has('fileshare.write') %}
|
||||
{% for entry in mappings %}
|
||||
{% call modal('mapping-edit-' ~ entry.id, entry.label, 'Zuordnung ändern') %}
|
||||
<form method="post" action="{{ url_for('fileshare.mapping_update', mapping_id=entry.id) }}">
|
||||
|
||||
@@ -37,7 +37,7 @@ wird nie stillschweigend übernommen: das ist der Fall, der bei einem Angriff au
|
||||
<div class="alert__title">Gelesener Schlüssel von {{ probed_host }}:{{ probed_port }}</div>
|
||||
<div class="mono fs-sm break-all">{{ probed_type }} {{ probed_fingerprint }}</div>
|
||||
<p class="fs-sm mt-2">
|
||||
Vergleichen Sie den Fingerprint mit einer unabhängigen Quelle -- Konsolenzugang am
|
||||
Vergleichen Sie den Fingerprint mit einer unabhängigen Quelle | Konsolenzugang am
|
||||
Gerät, Inventardokumentation --, bevor Sie ihn unten freigeben.
|
||||
</p>
|
||||
</div>
|
||||
@@ -145,7 +145,7 @@ wird nie stillschweigend übernommen: das ist der Fall, der bei einem Angriff au
|
||||
<div class="flex-1">
|
||||
<div class="card__title">{{ icon('alert', size=17) }} Verwaiste Freigaben</div>
|
||||
<div class="card__sub">
|
||||
Zu diesen Einträgen gibt es kein Ziel mehr -- gelöscht oder mit neuer IP.
|
||||
Zu diesen Einträgen gibt es kein Ziel mehr – gelöscht oder mit neuer IP.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -160,7 +160,7 @@ wird nie stillschweigend übernommen: das ist der Fall, der bei einem Angriff au
|
||||
<td class="mono fs-xs break-all">{{ row.fingerprint }}</td>
|
||||
<td class="fs-sm">{{ row.added_at | dt }} <span class="faint">von {{ row.added_by }}</span></td>
|
||||
<td>
|
||||
{% if current_user.has('devices.edit') or current_user.has('switches.edit') %}
|
||||
{% if current_user.has('devices.write') or current_user.has('switches.write') %}
|
||||
<form method="post" action="{{ url_for('hostkeys.forget_orphan') }}" data-no-guard>
|
||||
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
|
||||
<input type="hidden" name="host" value="{{ row.host }}">
|
||||
|
||||
@@ -60,9 +60,18 @@
|
||||
<dd>
|
||||
{% if state.modules %}
|
||||
{% for module in modules %}
|
||||
{% if module in state.modules %}{{ pill(module_labels[module], 'online') }}
|
||||
{% if module in state.modules and module in state.modules_off %}
|
||||
{{ pill(module_labels[module] ~ ' (abgeschaltet)', 'warning') }}
|
||||
{% elif module in state.modules %}{{ pill(module_labels[module], 'online') }}
|
||||
{% else %}{{ pill(module_labels[module], '') }}{% endif %}
|
||||
{% endfor %}
|
||||
{% if state.modules_off %}
|
||||
<div class="fs-xs faint">
|
||||
Abgeschaltet wurde in
|
||||
<a href="{{ url_for('settings.system') }}">Verwaltung → System</a>;
|
||||
die Lizenz ist davon unberührt.
|
||||
</div>
|
||||
{% endif %}
|
||||
{% else %}<span class="faint">keine Zusatzmodule</span>{% endif %}
|
||||
</dd>
|
||||
<dt>Lizenzserver</dt>
|
||||
@@ -76,7 +85,7 @@
|
||||
</dd>
|
||||
</dl>
|
||||
</div>
|
||||
{% if current_user.has('settings_license.edit') %}
|
||||
{% if current_user.has('settings_license.write') %}
|
||||
<div class="card__footer">
|
||||
{% if not state.activated %}
|
||||
<form method="post" action="{{ url_for('license.activate') }}" data-no-guard>
|
||||
@@ -125,7 +134,7 @@
|
||||
{% if pending.code %}
|
||||
<div class="field">
|
||||
<span class="field__label">
|
||||
Anfragecode -- an den Anbieter übermitteln
|
||||
Anfragecode – an den Anbieter übermitteln
|
||||
</span>
|
||||
<pre class="log-view" id="offline-request" style="max-height:14dvh">{{ pending.code }}</pre>
|
||||
<div class="row row--end mt-2">
|
||||
@@ -155,7 +164,7 @@
|
||||
</section>
|
||||
{% endif %}
|
||||
|
||||
{% if current_user.has('settings_license.edit') %}
|
||||
{% if current_user.has('settings_license.write') %}
|
||||
<section class="card">
|
||||
<div class="card__header">
|
||||
<div class="flex-1">
|
||||
@@ -190,7 +199,7 @@
|
||||
</section>
|
||||
{% endif %}
|
||||
|
||||
{% if current_user.has('settings_license.edit') %}
|
||||
{% if current_user.has('settings_license.write') %}
|
||||
<section class="card">
|
||||
<div class="card__header">
|
||||
<div class="flex-1">
|
||||
@@ -234,7 +243,7 @@
|
||||
</dl>
|
||||
<p class="fs-xs faint mt-3">
|
||||
Der Fingerprint stammt aus der stabilen Maschinenidentität. Eine Umbenennung des Hosts
|
||||
ändert ihn <strong>nicht</strong> mehr -- im Vorgänger führte genau das zu
|
||||
ändert ihn <strong>nicht</strong> mehr – im Vorgänger führte genau das zu
|
||||
„bereits anderweitig gebunden“.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{% extends "tesm_core/base.html" %}
|
||||
{% 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 content %}
|
||||
{% if error %}{{ alert(error, 'warning') }}{% endif %}
|
||||
|
||||
@@ -5,8 +5,9 @@
|
||||
{% block topbar_extra %}{{ search_box('restart-table', 'Gerät, Ergebnis, Methode ...') }}{% endblock %}
|
||||
{% block content %}
|
||||
{{ alert('Jeder Neustart wird mit Methode, Ergebnis, Dauer und Auslöser festgehalten -- automatisch
|
||||
wie manüll, über den PoE-Port des Switches ebenso wie per Neustart-Befehl aus der Wartung.
|
||||
Der Vorgänger protokollierte Neustarts ausdrücklich nicht.', 'info') }}
|
||||
wie manuell, über den PoE-Port des Switches ebenso wie per Neustart-Befehl aus der Wartung.
|
||||
Auch ein Neustart, den die Überwachung auslösen wollte und nicht durfte, steht hier:
|
||||
als "übersprungen" mit dem Grund. Der Vorgänger protokollierte Neustarts ausdrücklich nicht.', 'info') }}
|
||||
|
||||
<section class="card">
|
||||
<div class="card__header">
|
||||
@@ -15,7 +16,7 @@ Der Vorgänger protokollierte Neustarts ausdrücklich nicht.', 'info') }}
|
||||
<div class="card__sub">
|
||||
{% for value, label in methods %}
|
||||
<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 %}
|
||||
<a class="btn btn--sm {{ 'btn--ghost' if method else '' }}"
|
||||
href="{{ url_for('tesmlogs.restarts') }}">Alle</a>
|
||||
@@ -32,7 +33,7 @@ Der Vorgänger protokollierte Neustarts ausdrücklich nicht.', 'info') }}
|
||||
</tr></thead>
|
||||
<tbody>
|
||||
{% for row in rows %}
|
||||
<tr data-filter-text="{{ row.device_name }} {{ row.result }} {{ row.actor }} {{ method_labels.get(row.method, row.method) }} {{ row.detail }}">
|
||||
<tr data-filter-text="{{ row.device_name }} {{ row.result }} {{ row.actor }} {{ method_labels.get(row.method, row.method) }} {{ trigger_labels.get(row.trigger, row.trigger) }} {{ row.detail }}">
|
||||
<td class="fs-sm nowrap" data-sort-value="{{ row.ts }}">{{ row.ts | dt }}</td>
|
||||
<td class="fw-600 fs-sm">{{ row.device_name or '–' | safe }}</td>
|
||||
<td class="fs-sm">
|
||||
@@ -41,10 +42,15 @@ Der Vorgänger protokollierte Neustarts ausdrücklich nicht.', 'info') }}
|
||||
jahrelang als PoE ausgegeben. #}
|
||||
{% if row.method == 'ssh' %}{{ pill('SSH', 'info') }}
|
||||
{% elif row.method == 'rpc' %}{{ pill('RPC', 'accent') }}
|
||||
{% elif row.method == 'pö' %}{{ pill('PoE-Port', '') }}
|
||||
{# 'poe', nicht 'pö'. Der Umlaut stand hier durch eine
|
||||
Ersetzung, die "oe" fuer eine Umschrift hielt -- und traf den
|
||||
haeufigsten Fall von allen: jeder automatische Neustart laeuft
|
||||
ueber den PoE-Port. Sie standen damit als orangefarbenes
|
||||
"poe" da, wo "PoE-Port" stehen sollte. #}
|
||||
{% elif row.method == 'poe' %}{{ pill('PoE-Port', '') }}
|
||||
{% else %}{{ pill(row.method or 'unbekannt', 'warning') }}{% endif %}
|
||||
</td>
|
||||
<td class="fs-sm">{{ row.trigger }}</td>
|
||||
<td class="fs-sm">{{ trigger_labels.get(row.trigger, row.trigger) }}</td>
|
||||
<td class="fs-sm">{{ row.actor }}</td>
|
||||
<td>
|
||||
{% if row.result == 'ok' %}{{ pill('erfolgreich', 'online') }}
|
||||
|
||||
@@ -45,16 +45,79 @@ brauchen dafür weder SSH noch einen Host-Schlüssel, sondern die offene Datei-
|
||||
Druckerfreigabe. Der PoE-Neustart ist davon getrennt: er schaltet den Switch-Port.',
|
||||
'info') }}
|
||||
|
||||
<form method="post" id="maintenance-form" action="{{ url_for('maintenance.start_update') }}">
|
||||
{# ``stack``: Massenbefehl und Geräteauswahl sind zwei Bereiche und sollen auch
|
||||
so aussehen. Ohne das stossen die beiden Karten aneinander. #}
|
||||
<form method="post" id="maintenance-form" class="stack"
|
||||
action="{{ url_for('maintenance.start_update') }}">
|
||||
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
|
||||
|
||||
{% if current_user.has('servicing_command.read') %}
|
||||
{# Zugeklappt beim Laden: der Massenbefehl ist das seltenere Werkzeug, die
|
||||
Geräteliste darunter das tägliche. Aufgeklappt schob er sie aus dem
|
||||
Bild. #}
|
||||
<section class="card">
|
||||
<div class="card__header">
|
||||
<button class="btn btn--ghost btn--sm" type="button" data-befehl-schalter
|
||||
aria-expanded="false" title="Massenbefehl ein- und ausklappen">
|
||||
{{ icon('chevron-right', size=14) }}
|
||||
</button>
|
||||
<div class="flex-1">
|
||||
<div class="card__title">{{ icon('terminal', size=17) }} Massenbefehl</div>
|
||||
<div class="card__sub">
|
||||
Eine Befehlszeile über SSH, auf allen unten ausgewählten Clients – jeder als
|
||||
eigener Auftrag mit eigener Ausgabe. <strong>{{ '{sudo}' }}</strong> im Befehl
|
||||
wird ersetzt: mit <code>sudo -n</code>, wo es ohne Passwort geht, sonst mit dem
|
||||
hinterlegten Passwort über stdin. Ohne diese Marke läuft der Befehl als das
|
||||
angemeldete Konto.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card__body stack" data-befehl-feld hidden>
|
||||
<label class="field">
|
||||
<span class="field__label">Vorlage</span>
|
||||
<select class="select" id="befehlsvorlage">
|
||||
<option value="">– eigene Eingabe –</option>
|
||||
{% for name, befehl, erklaerung in befehlsvorlagen %}
|
||||
<option value="{{ befehl }}" data-erklaerung="{{ erklaerung }}">{{ name }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
<span class="field__hint" id="befehlserklaerung">
|
||||
Eine Vorlage füllt nur das Feld darunter – abgeschickt wird von Hand.
|
||||
</span>
|
||||
</label>
|
||||
<label class="field">
|
||||
<span class="field__label">Befehl</span>
|
||||
<input class="input mono" type="text" name="befehl" id="befehl"
|
||||
maxlength="500" autocomplete="off"
|
||||
placeholder='echo "Encryption=AlwaysOff" | {sudo} tee -a /etc/vnc/config.d/common.custom'
|
||||
{% if not current_user.has('servicing_command.execute') %}disabled{% endif %}>
|
||||
<span class="field__hint">
|
||||
Genau eine Zeile. Was der Befehl bewirkt, weiß der, der ihn schreibt – TESM
|
||||
führt ihn aus und hält fest, was zurückkam.
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
</section>
|
||||
{% endif %}
|
||||
<section class="card">
|
||||
<div class="table-toolbar">
|
||||
<label class="check">
|
||||
<input type="checkbox" id="select-all">
|
||||
<span>Alle auswählen</span>
|
||||
</label>
|
||||
<button class="btn btn--sm btn--ghost" type="button" data-alle-einklappen>
|
||||
{{ icon('chevron-up', size=14) }} Alle einklappen
|
||||
</button>
|
||||
<span class="table-toolbar__spacer"></span>
|
||||
{% if current_user.has('maintenance.execute') %}
|
||||
{% if current_user.has('servicing_command.execute') %}
|
||||
<button class="btn btn--sm" type="submit"
|
||||
formaction="{{ url_for('maintenance.start_command') }}"
|
||||
data-confirm="Den eingetragenen Befehl auf allen ausgewählten Geräten ausführen? Was er bewirkt, entscheidet er selbst – TESM führt ihn nur aus."
|
||||
data-confirm-title="Befehl senden" data-confirm-ok="Senden" data-confirm-style="danger">
|
||||
{{ icon('terminal', size=15) }} Befehl senden
|
||||
</button>
|
||||
{% endif %}
|
||||
{% if current_user.has('servicing_update.execute') %}
|
||||
<button class="btn btn--sm btn--primary" type="submit"
|
||||
formaction="{{ url_for('maintenance.start_update') }}"
|
||||
title="Nur für Linux-Clients -- Windows-Geräte werden dabei übersprungen."
|
||||
@@ -79,6 +142,7 @@ Druckerfreigabe. Der PoE-Neustart ist davon getrennt: er schaltet den Switch-Por
|
||||
<th class="sortable">Gerät</th>
|
||||
<th class="sortable">IP</th>
|
||||
<th class="sortable">Zustand</th>
|
||||
<th class="sortable">Auftrag</th>
|
||||
<th class="sortable">Zugangsdaten</th>
|
||||
<th class="sortable">Host-Schlüssel</th>
|
||||
<th></th>
|
||||
@@ -91,17 +155,40 @@ Druckerfreigabe. Der PoE-Neustart ist davon getrennt: er schaltet den Switch-Por
|
||||
<label class="check">
|
||||
<input type="checkbox" name="device_ids" value="{{ device.id }}"
|
||||
class="device-select" data-category="{{ device.category }}"
|
||||
{% if not current_user.has('maintenance.execute') %}disabled{% endif %}>
|
||||
{% if not current_user.has('servicing_update.execute') %}disabled{% endif %}>
|
||||
<span class="sr-only">{{ device.name }} auswählen</span>
|
||||
</label>
|
||||
</td>
|
||||
<td class="fw-600">{{ device.name }}</td>
|
||||
<td class="fw-600">
|
||||
{# Mitlesen, ohne die Seite zu verlassen -- so war es in der
|
||||
Fassung 1.2.8, und es fehlte seither. Die Zeile darunter
|
||||
klappt auf, sobald für dieses Gerät etwas läuft. #}
|
||||
<button class="btn btn--ghost btn--sm" type="button"
|
||||
data-ausgabe-schalter="{{ device.name }}"
|
||||
title="Ausgabe dieses Geräts mitlesen">
|
||||
{{ icon('chevron-right', size=13) }}
|
||||
</button>
|
||||
{{ device.name }}
|
||||
</td>
|
||||
<td class="mono fs-sm">{{ device.ip }}</td>
|
||||
<td>
|
||||
<td data-zustand="{{ device.id }}">
|
||||
{% if device.state == 'online' %}{{ pill('online', 'online') }}
|
||||
{% elif device.state == 'offline' %}{{ pill('offline', 'offline') }}
|
||||
{% else %}{{ pill('unbekannt', 'warning') }}{% endif %}
|
||||
</td>
|
||||
{# Wie weit ist der Auftrag dieses Geräts? Ohne diese Spalte
|
||||
blieb nur die Auftragsliste weiter unten -- und dort steht
|
||||
nicht, welche Zeile zu welchem Gerät gehört, wenn vierzig
|
||||
Aufträge in derselben Sekunde starten. #}
|
||||
<td data-auftragslage="{{ device.name }}">
|
||||
{% set lage = auftragslage.get(device.name) %}
|
||||
{% if not lage %}<span class="faint fs-xs">–</span>
|
||||
{% elif lage.state == 'running' %}{{ pill('läuft', 'info') }}
|
||||
{% elif lage.state == 'pending' %}{{ pill('wartet', '') }}
|
||||
{% elif lage.state == 'succeeded' %}{{ pill('fertig', 'online') }}
|
||||
{% elif lage.state == 'failed' %}{{ pill('Fehler', 'danger') }}
|
||||
{% else %}{{ pill('unklar', 'warning') }}{% endif %}
|
||||
</td>
|
||||
<td class="fs-sm">
|
||||
{{ device.credential_name }}
|
||||
<div class="fs-xs faint">{{ category_labels.get(device.category, device.category) }}</div>
|
||||
@@ -121,7 +208,7 @@ Druckerfreigabe. Der PoE-Neustart ist davon getrennt: er schaltet den Switch-Por
|
||||
</td>
|
||||
<td class="nowrap">
|
||||
{% if terminal_enabled and device.ssh_managed
|
||||
and current_user.has('maintenance.execute') %}
|
||||
and current_user.has('servicing_update.execute') %}
|
||||
<a class="btn btn--sm btn--ghost"
|
||||
href="{{ url_for('terminal.device_console', device_id=device.id) }}">
|
||||
{{ icon('terminal', size=14) }} Konsole
|
||||
@@ -129,6 +216,11 @@ Druckerfreigabe. Der PoE-Neustart ist davon getrennt: er schaltet den Switch-Por
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
<tr data-ausgabe-zeile="{{ device.name }}" hidden>
|
||||
<td colspan="8" class="p-0">
|
||||
<pre class="job-output" data-ausgabe="{{ device.name }}">Noch keine Ausgabe.</pre>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
@@ -151,7 +243,7 @@ Druckerfreigabe. Der PoE-Neustart ist davon getrennt: er schaltet den Switch-Por
|
||||
<div class="flex-1">
|
||||
<div class="card__title">{{ icon('clock', size=17) }} Aufträge</div>
|
||||
<div class="card__sub">
|
||||
Zustand liegt in der Datenbank -- ein Dienst-Neustart verliert ihn nicht mehr
|
||||
Zustand liegt in der Datenbank – ein Dienst-Neustart verliert ihn nicht mehr
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -161,13 +253,13 @@ Druckerfreigabe. Der PoE-Neustart ist davon getrennt: er schaltet den Switch-Por
|
||||
<thead><tr><th>Gestartet</th><th>Gerät</th><th>Art</th><th>Zustand</th><th>Fortschritt</th><th>Meldung</th></tr></thead>
|
||||
<tbody>
|
||||
{% for job in jobs %}
|
||||
<tr>
|
||||
<tr data-auftrag="{{ job.id }}">
|
||||
<td class="fs-sm nowrap">{{ job.created_at | dt }}</td>
|
||||
<td class="fs-sm fw-600">
|
||||
<a href="{{ url_for('maintenance.job_detail', job_id=job.id) }}">{{ job.subject }}</a>
|
||||
</td>
|
||||
<td class="fs-sm">{{ 'Aktualisierung' if job.kind.endswith('update') else 'Neustart' }}</td>
|
||||
<td>
|
||||
<td data-auftrag-zustand>
|
||||
{% if job.state == 'succeeded' %}{{ pill('erfolgreich', 'online') }}
|
||||
{% elif job.state == 'failed' %}{{ pill('fehlgeschlagen', 'danger') }}
|
||||
{% elif job.state == 'running' %}{{ pill('läuft', 'info') }}
|
||||
@@ -175,11 +267,12 @@ Druckerfreigabe. Der PoE-Neustart ist davon getrennt: er schaltet den Switch-Por
|
||||
{% else %}{{ pill('wartet', '') }}{% endif %}
|
||||
</td>
|
||||
<td style="min-width:120px">
|
||||
<div class="progress {{ 'progress--success' if job.state == 'succeeded' else ('progress--danger' if job.state == 'failed' else '') }}">
|
||||
<div class="progress {{ 'progress--success' if job.state == 'succeeded' else ('progress--danger' if job.state == 'failed' else '') }}"
|
||||
data-auftrag-balken>
|
||||
<div class="progress__bar" style="width: {{ job.progress }}%"></div>
|
||||
</div>
|
||||
</td>
|
||||
<td class="fs-xs muted">{{ job.message }}</td>
|
||||
<td class="fs-xs muted" data-auftrag-meldung>{{ job.message }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
@@ -193,8 +286,20 @@ Druckerfreigabe. Der PoE-Neustart ist davon getrennt: er schaltet den Switch-Por
|
||||
|
||||
{% block scripts %}
|
||||
<script type="module" nonce="{{ csp_nonce }}">
|
||||
// Eine Vorlage füllt das Feld und erklärt sich -- abgeschickt wird von Hand.
|
||||
const vorlage = document.getElementById("befehlsvorlage");
|
||||
const befehl = document.getElementById("befehl");
|
||||
const erklaerung = document.getElementById("befehlserklaerung");
|
||||
vorlage?.addEventListener("change", () => {
|
||||
const gewaehlt = vorlage.selectedOptions[0];
|
||||
if (vorlage.value) befehl.value = vorlage.value;
|
||||
erklaerung.textContent =
|
||||
gewaehlt?.dataset.erklaerung ||
|
||||
"Eine Vorlage füllt nur das Feld darunter – abgeschickt wird von Hand.";
|
||||
});
|
||||
|
||||
// Auswahl beim Laden bewusst zurücksetzen: Browser stellen den Checked-Zustand
|
||||
// sonst aus dem Verlauf wieder her -- und dann wird bei "Neustarten"
|
||||
// sonst aus dem Verlauf wieder her – und dann wird bei "Neustarten"
|
||||
// versehentlich das falsche Gerät erwischt.
|
||||
const boxes = Array.from(document.querySelectorAll(".device-select"));
|
||||
boxes.forEach((box) => (box.checked = false));
|
||||
@@ -205,14 +310,172 @@ Druckerfreigabe. Der PoE-Neustart ist davon getrennt: er schaltet den Switch-Por
|
||||
.forEach((box) => (box.checked = all.checked));
|
||||
});
|
||||
|
||||
// Auftragsliste nachladen, solange etwas läuft.
|
||||
async function poll() {
|
||||
try {
|
||||
const state = await window.tesm.api("{{ url_for('maintenance.status') }}");
|
||||
if (state.running) window.setTimeout(() => location.reload(), state.poll_ms);
|
||||
} catch {
|
||||
/* stumm bleiben -- die Seite ist auch ohne Aktualisierung nutzbar */
|
||||
// Mitlesen: die Zeile unter dem Gerät zeigt, was dort gerade passiert.
|
||||
//
|
||||
// Vorher lud diese Seite sich alle zwei Sekunden komplett neu, solange etwas
|
||||
// lief -- das verwarf jede Auswahl, jeden Tastendruck im Befehlsfeld und
|
||||
// sprang nach oben. Jetzt kommen nur die letzten Zeilen, und die Seite
|
||||
// bleibt stehen.
|
||||
// Der Massenbefehl: zugeklappt geladen, per Kopfzeile aufgeklappt.
|
||||
const befehlSchalter = document.querySelector("[data-befehl-schalter]");
|
||||
const befehlFeld = document.querySelector("[data-befehl-feld]");
|
||||
befehlSchalter?.addEventListener("click", () => {
|
||||
const zu = befehlFeld.hidden;
|
||||
befehlFeld.hidden = !zu;
|
||||
befehlSchalter.setAttribute("aria-expanded", zu ? "true" : "false");
|
||||
befehlSchalter.style.transform = zu ? "rotate(90deg)" : "";
|
||||
if (zu) document.getElementById("befehl")?.focus();
|
||||
});
|
||||
|
||||
const schalter = Array.from(document.querySelectorAll("[data-ausgabe-schalter]"));
|
||||
// Wer von Hand zuklappt, will es zu haben. Ohne dieses Gedaechtnis riss die
|
||||
// naechste Statusabfrage die Zeile wieder auf, weil dort noch Ausgabe stand
|
||||
// -- zwei Sekunden spaeter, jedes Mal.
|
||||
const zugeklappt = new Set();
|
||||
// Beim ersten Fuellen ans Ende springen, danach nur, wenn man ohnehin unten
|
||||
// steht. So steht nach dem Neuladen der Seite das Aktuelle im Bild und nicht
|
||||
// der Anfang von vorgestern -- und wer hochscrollt, um etwas zu lesen, wird
|
||||
// nicht wieder heruntergezogen.
|
||||
const gefuellt = new Set();
|
||||
|
||||
function klappen(name, zu) {
|
||||
const zeile = document.querySelector(`[data-ausgabe-zeile="${CSS.escape(name)}"]`);
|
||||
if (!zeile) return;
|
||||
zeile.hidden = zu;
|
||||
if (zu) zugeklappt.add(name);
|
||||
else zugeklappt.delete(name);
|
||||
}
|
||||
|
||||
schalter.forEach((knopf) => {
|
||||
knopf.addEventListener("click", () => {
|
||||
const name = knopf.dataset.ausgabeSchalter;
|
||||
const zeile = document.querySelector(`[data-ausgabe-zeile="${CSS.escape(name)}"]`);
|
||||
if (zeile) klappen(name, !zeile.hidden);
|
||||
});
|
||||
});
|
||||
|
||||
document.querySelector("[data-alle-einklappen]")?.addEventListener("click", () => {
|
||||
schalter.forEach((knopf) => klappen(knopf.dataset.ausgabeSchalter, true));
|
||||
});
|
||||
|
||||
function zeigen(name, text) {
|
||||
const feld = document.querySelector(`[data-ausgabe="${CSS.escape(name)}"]`);
|
||||
const zeile = document.querySelector(`[data-ausgabe-zeile="${CSS.escape(name)}"]`);
|
||||
if (!feld || !zeile) return;
|
||||
const erstmals = !gefuellt.has(name);
|
||||
const unten = feld.scrollTop + feld.clientHeight >= feld.scrollHeight - 24;
|
||||
feld.textContent = text || "Noch keine Ausgabe.";
|
||||
if (text) gefuellt.add(name);
|
||||
if (erstmals || unten) feld.scrollTop = feld.scrollHeight;
|
||||
// Was läuft, klappt von selbst auf -- wer zusieht, will nicht erst suchen.
|
||||
if (text && zeile.hidden && !zugeklappt.has(name)) zeile.hidden = false;
|
||||
}
|
||||
|
||||
// Zustand, Fortschritt und Meldung eines Auftrags nachziehen.
|
||||
//
|
||||
// Vorher lud die Seite sich dafür komplett neu, sobald der letzte Auftrag
|
||||
// durch war -- und riss damit genau das weg, was man lesen wollte: die
|
||||
// Ausgabe stand da, dann sprang die Seite, und danach war sie leer.
|
||||
const ZUSTAENDE = {
|
||||
succeeded: ["erfolgreich", "pill--online", "progress--success"],
|
||||
failed: ["fehlgeschlagen", "pill--danger", "progress--danger"],
|
||||
running: ["läuft", "pill--info", ""],
|
||||
unknown: ["unklar", "pill--warning", ""],
|
||||
pending: ["wartet", "", ""],
|
||||
};
|
||||
|
||||
function auftragZeigen(job) {
|
||||
const zeile = document.querySelector(`[data-auftrag="${CSS.escape(job.id)}"]`);
|
||||
if (!zeile) return false;
|
||||
const [text, pillenKlasse, balkenKlasse] = ZUSTAENDE[job.state] || ZUSTAENDE.pending;
|
||||
const pille = zeile.querySelector("[data-auftrag-zustand] .pill");
|
||||
if (pille && pille.textContent.trim() !== text) {
|
||||
pille.textContent = text;
|
||||
pille.className = `pill ${pillenKlasse}`.trim();
|
||||
}
|
||||
const balken = zeile.querySelector("[data-auftrag-balken]");
|
||||
if (balken) {
|
||||
balken.className = `progress ${balkenKlasse}`.trim();
|
||||
const inner = balken.querySelector(".progress__bar");
|
||||
if (inner) inner.style.width = `${job.progress || 0}%`;
|
||||
}
|
||||
const meldung = zeile.querySelector("[data-auftrag-meldung]");
|
||||
if (meldung && meldung.textContent.trim() !== (job.message || "")) {
|
||||
meldung.textContent = job.message || "";
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// Dieselben fünf Zustände wie in der Auftragsliste -- hier aber neben dem
|
||||
// Gerät, wo man sie sucht.
|
||||
const LAGE = {
|
||||
pending: ["wartet", ""],
|
||||
running: ["läuft", "pill--info"],
|
||||
succeeded: ["fertig", "pill--online"],
|
||||
failed: ["Fehler", "pill--danger"],
|
||||
unknown: ["unklar", "pill--warning"],
|
||||
};
|
||||
|
||||
function lageZeigen(name, zustand) {
|
||||
const zelle = document.querySelector(`[data-auftragslage="${CSS.escape(name)}"]`);
|
||||
if (!zelle) return;
|
||||
const [text, klasse] = LAGE[zustand] || LAGE.pending;
|
||||
const vorhanden = zelle.querySelector(".pill");
|
||||
if (vorhanden && vorhanden.textContent.trim() === text) return;
|
||||
const pille = document.createElement("span");
|
||||
pille.className = `pill ${klasse}`.trim();
|
||||
pille.textContent = text;
|
||||
zelle.replaceChildren(pille);
|
||||
}
|
||||
|
||||
let neueAuftraege = false;
|
||||
async function poll() {
|
||||
let weiter = 10000;
|
||||
try {
|
||||
const state = await window.tesm.api("{{ url_for('maintenance.status') }}", { auto: true });
|
||||
weiter = state.poll_ms || 10000;
|
||||
// Der neueste Auftrag je Gerät gewinnt; die Liste kommt neueste zuerst.
|
||||
const gesehen = new Set();
|
||||
state.jobs.forEach((job) => {
|
||||
if (!gesehen.has(job.subject)) {
|
||||
gesehen.add(job.subject);
|
||||
lageZeigen(job.subject, job.state);
|
||||
}
|
||||
if (job.output) zeigen(job.subject, job.output);
|
||||
// Ein Auftrag, der in der Tabelle noch gar nicht steht, ist neu --
|
||||
// dafür braucht es die Seite einmal frisch, aber erst, wenn nichts
|
||||
// mehr läuft.
|
||||
if (!auftragZeigen(job)) neueAuftraege = true;
|
||||
});
|
||||
// Erreichbarkeit mitziehen -- sonst steht die Seite mit dem Stand ihres
|
||||
// Aufbaus da, und wer nach einem Neustart auf die Rückkehr wartet, sieht
|
||||
// "unbekannt", bis er von Hand neu lädt.
|
||||
(state.devices || []).forEach((geraet) => {
|
||||
const zelle = document.querySelector(`[data-zustand="${geraet.id}"]`);
|
||||
if (!zelle) return;
|
||||
const [text, art] =
|
||||
geraet.state === "online"
|
||||
? ["online", "pill--online"]
|
||||
: geraet.state === "offline"
|
||||
? ["offline", "pill--offline"]
|
||||
: ["unbekannt", "pill--warning"];
|
||||
const pille = zelle.querySelector(".pill");
|
||||
if (pille && pille.textContent.trim() !== text) {
|
||||
pille.textContent = text;
|
||||
pille.className = `pill ${art}`;
|
||||
}
|
||||
});
|
||||
// Neu laden nur, wenn Aufträge dazugekommen sind, die in der Tabelle
|
||||
// fehlen -- und auch dann erst, wenn nichts mehr läuft. Wer zusieht,
|
||||
// soll nicht mitten im Lesen weggerissen werden.
|
||||
if (neueAuftraege && !state.running) {
|
||||
window.setTimeout(() => location.reload(), 8000);
|
||||
neueAuftraege = false;
|
||||
}
|
||||
} catch {
|
||||
/* stumm bleiben – die Seite ist auch ohne Aktualisierung nutzbar */
|
||||
}
|
||||
window.setTimeout(poll, weiter);
|
||||
}
|
||||
poll();
|
||||
</script>
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
{% extends "tesm_core/base.html" %}
|
||||
{% from "tesm_core/macros.html" import alert, pill %}
|
||||
{% from "tesm/_vnc_flaeche.html" import vnc_flaeche, vnc_status, vnc_zugangsdaten, vnc_tasten %}
|
||||
|
||||
{% block page_title %}Bildschirm: {{ device.name }}{% endblock %}
|
||||
{% block page_sub %}{{ zugang.ziel }}:{{ zugang.port }}{% endblock %}
|
||||
|
||||
{% block breadcrumb %}
|
||||
<nav class="breadcrumb">
|
||||
<span><a href="{{ url_for('devices.overview') }}">Clients</a></span>
|
||||
<span><a href="{{ url_for('devices.detail', device_id=device.id) }}">{{ device.name }}</a></span>
|
||||
<span>Bildschirm</span>
|
||||
</nav>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="stack">
|
||||
{% if zugang.hinderungsgrund %}
|
||||
{{ alert(zugang.hinderungsgrund, 'warning') }}
|
||||
{% endif %}
|
||||
|
||||
<section class="card">
|
||||
<div class="card__header">
|
||||
<div class="flex-1">
|
||||
<div class="card__title">
|
||||
{{ icon('monitor', size=17) }} {{ device.name }}
|
||||
{% if zugang.absage %}{{ pill('Verbindung abgelehnt', 'warning') }}
|
||||
{% elif zugang.verfahren %}
|
||||
{% if zugang.browsertauglich %}{{ pill('Browser-Sitzung möglich', 'online') }}
|
||||
{% else %}{{ pill('Verfahren nicht unterstützt', 'warning') }}{% endif %}
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="card__sub">
|
||||
Tastatur und Maus gehen an das Gerät, sobald das Bild steht. Die Zwischenablage
|
||||
bleibt getrennt.
|
||||
{% if zugang.verfahren %}
|
||||
Zuletzt angebotene Anmeldeverfahren:
|
||||
{% for nummer in zugang.verfahren %}{{ verfahren_name(nummer) }}{% if not loop.last %}, {% endif %}{% endfor %}.
|
||||
{% else %}
|
||||
Welche Anmeldeverfahren das Gerät anbietet, ist nicht bekannt – die Seite
|
||||
fragt es nicht von sich aus.
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
<div class="row row--tight">
|
||||
{{ vnc_tasten() }}
|
||||
{# Ausdrücklich ein Knopf und keine Nebenwirkung des Seitenaufbaus: die
|
||||
Prüfung bricht eine Protokollverhandlung ab, und RealVNC zählt das.
|
||||
Wer sucht, drückt ihn; wer nur arbeiten will, merkt nichts davon. #}
|
||||
<form method="post" data-no-guard
|
||||
action="{{ url_for('screen.probe', device_id=device.id) }}">
|
||||
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
|
||||
<button class="btn btn--sm btn--ghost" type="submit"
|
||||
title="Fragt das Gerät, welche Anmeldeverfahren es anbietet. Das ist
|
||||
eine eigene Verbindung, die abgebrochen wird – ein RealVNC Server zählt so etwas mit.
|
||||
Nicht unmittelbar vor dem Verbinden drücken.">
|
||||
Verfahren prüfen
|
||||
</button>
|
||||
</form>
|
||||
<a class="btn btn--sm btn--ghost"
|
||||
href="{{ url_for('devices.vnc_connection', device_id=device.id) }}">
|
||||
{{ icon('download', size=14) }} Verbindungsdatei
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card__body stack" id="screen" data-sitzung="{{ quelle }}">
|
||||
{{ vnc_status() }}
|
||||
{{ vnc_zugangsdaten() }}
|
||||
{{ vnc_flaeche() }}
|
||||
|
||||
{% if not darf_geheimnis and device.credential_id %}
|
||||
<p class="fs-xs faint">
|
||||
Für diesen Client sind Zugangsdaten hinterlegt. TESM gibt sie nur an Konten
|
||||
heraus, die Geheimnisse ohnehin einsehen dürfen – sonst wären sie über den
|
||||
Umweg einer Bildschirmsitzung für jeden lesbar, der sie öffnen darf. Tragen
|
||||
Sie sie oben ein; sie werden nicht gespeichert.
|
||||
</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
<script type="module" src="{{ asset_url('js/screen.js') }}" nonce="{{ csp_nonce }}"></script>
|
||||
{% endblock %}
|
||||
@@ -1,5 +1,5 @@
|
||||
{% 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_sub %}Überwachung, Protokolle, Host, Navigation{% endblock %}
|
||||
@@ -41,7 +41,7 @@
|
||||
</div>
|
||||
{{ checkbox('monitor_auto_restart', 'Automatischen PoE-Neustart bei Ausfall aktivieren',
|
||||
checked=values.monitor_auto_restart,
|
||||
hint_text='Zusätzlich lizenzpflichtig. Der manülle Knopf im Dashboard
|
||||
hint_text='Zusätzlich lizenzpflichtig. Der manuelle Knopf im Dashboard
|
||||
funktioniert unabhängig davon.') }}
|
||||
{{ checkbox('dashboard_public', 'Statusübersicht ohne Anmeldung erreichbar',
|
||||
checked=values.dashboard_public,
|
||||
@@ -51,21 +51,54 @@
|
||||
</form>
|
||||
</section>
|
||||
|
||||
{% if module_rows %}
|
||||
<section class="card">
|
||||
<div class="card__header">
|
||||
<div class="flex-1">
|
||||
<div class="card__title">{{ icon('puzzle', size=17) }} Module</div>
|
||||
<div class="card__sub">
|
||||
Die Lizenz sagt, was erlaubt ist – hier steht, was dieses Haus benutzt.
|
||||
Ein abgeschaltetes Modul verschwindet aus der Navigation, und seine Seiten
|
||||
antworten wie ohne Lizenz. Bestehende Daten bleiben unverändert stehen und
|
||||
sind nach dem Wiedereinschalten wieder da.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<form method="post" action="{{ url_for('settings.modules_save') }}">
|
||||
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
|
||||
<div class="card__body stack">
|
||||
{% for modul in module_rows %}
|
||||
{{ checkbox('module_' ~ modul.key, modul.label, checked=modul.enabled,
|
||||
hint_text=('In der Lizenz enthalten.' if modul.licensed
|
||||
else 'Nicht lizenziert – bleibt aus, bis die Lizenz es
|
||||
freischaltet. Der Haken gilt dann sofort.')) }}
|
||||
{% endfor %}
|
||||
</div>
|
||||
<div class="card__footer"><button class="btn btn--primary" type="submit">Speichern</button></div>
|
||||
</form>
|
||||
</section>
|
||||
{% endif %}
|
||||
|
||||
<section class="card">
|
||||
<div class="card__header">
|
||||
<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">
|
||||
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>
|
||||
<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('terminal_enabled', 'Browser-Konsole für Switche zulassen',
|
||||
checked=values.terminal_enabled) }}
|
||||
{{ checkbox('terminal_switches_enabled', 'Browser-Konsole für Switche zulassen',
|
||||
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)',
|
||||
value=values.terminal_idle_timeout, type='number') }}
|
||||
</div>
|
||||
@@ -73,6 +106,93 @@
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<section class="card">
|
||||
<div class="card__header">
|
||||
<div class="flex-1">
|
||||
<div class="card__title">{{ icon('monitor', size=17) }} Bildschirm (VNC)</div>
|
||||
<div class="card__sub">
|
||||
Dieselben Regeln wie bei der Konsole: eigenes Recht, frische
|
||||
Passwortbestätigung, getrennte Zwischenablage. Der Knopf erscheint nur, wo
|
||||
ein VNC-Server antwortet – das prüft der Überwachungsdurchlauf mit.
|
||||
</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('vnc_enabled', 'Bildschirmzugriff (VNC) anbieten',
|
||||
checked=values.vnc_enabled,
|
||||
hint_text=specs['vnc_enabled'].hint) }}
|
||||
<div class="form-grid">
|
||||
{{ field('vnc_port', 'VNC-Port', value=values.vnc_port, type='number',
|
||||
hint_text=specs['vnc_port'].hint) }}
|
||||
{{ field('vnc_idle_timeout', 'Trennen bei Untätigkeit (Sekunden)',
|
||||
value=values.vnc_idle_timeout, type='number',
|
||||
hint_text=specs['vnc_idle_timeout'].hint) }}
|
||||
</div>
|
||||
{{ checkbox('vnc_warn_unencrypted_off',
|
||||
'Warnung des Viewers vor unverschlüsselter Verbindung unterdrücken',
|
||||
checked=values.vnc_warn_unencrypted_off,
|
||||
hint_text=specs['vnc_warn_unencrypted_off'].hint) }}
|
||||
</div>
|
||||
<div class="card__footer"><button class="btn btn--primary" type="submit">Speichern</button></div>
|
||||
</form>
|
||||
</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">
|
||||
<div class="card__header">
|
||||
<div class="flex-1">
|
||||
@@ -87,9 +207,6 @@
|
||||
specs['log_rotate_interval'].choices, selected=values.log_rotate_interval) }}
|
||||
{{ field('log_rotate_keep', 'Aufbewahrte Rotationen', value=values.log_rotate_keep,
|
||||
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)',
|
||||
value=values.trash_retention_days, type='number',
|
||||
hint_text=specs['trash_retention_days'].hint) }}
|
||||
@@ -133,39 +250,8 @@
|
||||
</section>
|
||||
|
||||
{% if current_user.is_admin %}
|
||||
<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">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>
|
||||
{{ nav_order_card(nav_baum, nav_order, nav_child_order,
|
||||
url_for('admin.nav_order'), csrf_field_name, csrf_token()) }}
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
{% endblock %}
|
||||
|
||||
{% block topbar_extra %}
|
||||
{% if terminal_enabled and current_user.has('switches.execute') and host_keys %}
|
||||
{% if terminal_enabled and current_user.has('switch_console.execute') and host_keys %}
|
||||
<a class="btn btn--sm btn--primary" href="{{ url_for('terminal.switch_console', switch_id=switch.id) }}">
|
||||
{{ icon('terminal', size=15) }} Konsole
|
||||
</a>
|
||||
@@ -32,7 +32,7 @@
|
||||
Ohne freigegebenen Schlüssel verweigert die Anwendung jeden Zugriff auf diesen Switch.
|
||||
</div>
|
||||
</div>
|
||||
{% if current_user.has('switches.edit') %}
|
||||
{% if current_user.has('switches.write') %}
|
||||
<form method="post" action="{{ url_for('switches.probe_host_key', switch_id=switch.id) }}"
|
||||
data-no-guard>
|
||||
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
|
||||
@@ -53,7 +53,7 @@
|
||||
Vergleichen Sie den Fingerprint mit einer unabhängigen Quelle (Konsolenzugang am
|
||||
Switch, Inventardokumentation), bevor Sie ihn freigeben.
|
||||
</p>
|
||||
{% if current_user.has('switches.edit') %}
|
||||
{% if current_user.has('switches.write') %}
|
||||
<form method="post" class="mt-2"
|
||||
action="{{ url_for('switches.trust_host_key', switch_id=switch.id) }}">
|
||||
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
|
||||
@@ -78,7 +78,7 @@
|
||||
</dd>
|
||||
{% endfor %}
|
||||
</dl>
|
||||
{% if current_user.has('switches.edit') %}
|
||||
{% if current_user.has('switches.write') %}
|
||||
<form method="post" action="{{ url_for('switches.forget_host_key', switch_id=switch.id) }}"
|
||||
data-no-guard>
|
||||
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
|
||||
@@ -95,7 +95,7 @@
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{% if current_user.has('switches.execute') %}
|
||||
{% if current_user.has('switch_console.execute') %}
|
||||
<section class="card">
|
||||
<div class="card__header">
|
||||
<div class="flex-1">
|
||||
|
||||
@@ -83,13 +83,13 @@ stillschweigend übernommen.', 'info', 'Host-Schlüssel') }}
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="actions">
|
||||
{% if terminal_enabled and current_user.has('switches.execute') and switch.host_key_known %}
|
||||
{% if terminal_enabled and current_user.has('switch_console.execute') and switch.host_key_known %}
|
||||
<a class="btn btn--sm btn--ghost" title="Konsole"
|
||||
href="{{ url_for('terminal.switch_console', switch_id=switch.id) }}">
|
||||
{{ icon('terminal', size=15) }}
|
||||
</a>
|
||||
{% endif %}
|
||||
{% if current_user.has('switches.edit') %}
|
||||
{% if current_user.has('switches.write') %}
|
||||
<button class="btn btn--sm btn--ghost" type="button"
|
||||
data-dialog-open="switch-edit-{{ switch.id }}" aria-label="Bearbeiten">
|
||||
{{ icon('pencil', size=15) }}
|
||||
@@ -135,7 +135,7 @@ stillschweigend übernommen.', 'info', 'Host-Schlüssel') }}
|
||||
</form>
|
||||
{% endcall %}
|
||||
{% endif %}
|
||||
{% if current_user.has('switches.edit') %}
|
||||
{% if current_user.has('switches.write') %}
|
||||
{% for switch in switches %}
|
||||
{% call modal('switch-edit-' ~ switch.id, switch.hostname, switch.ip) %}
|
||||
<form method="post" action="{{ url_for('switches.update', switch_id=switch.id) }}">
|
||||
|
||||
@@ -22,7 +22,7 @@ from types import FrameType
|
||||
from tesm_core.extension import core
|
||||
|
||||
from . import create_app
|
||||
from .services import inventory, monitor
|
||||
from .services import inventory, monitor, vnc
|
||||
|
||||
logger = logging.getLogger("tesm.monitor")
|
||||
|
||||
@@ -36,7 +36,7 @@ class MonitorDaemon:
|
||||
self.running = True
|
||||
|
||||
def stop(self, signum: int, _frame: FrameType | None) -> None:
|
||||
logger.info("Signal %s empfangen -- beende nach dem laufenden Durchlauf.", signum)
|
||||
logger.info("Signal %s empfangen – beende nach dem laufenden Durchlauf.", signum)
|
||||
self.running = False
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
@@ -68,8 +68,8 @@ class MonitorDaemon:
|
||||
|
||||
def run(self) -> int:
|
||||
live = self.extension.registry["live_log"]
|
||||
live.info("monitor", "Ueberwachungsprozess gestartet")
|
||||
logger.info("Ueberwachung laeuft, Datenbank: %s", self.extension.config.db_path)
|
||||
live.info("monitor", "Überwachungsprozess gestartet")
|
||||
logger.info("Überwachung läuft, Datenbank: %s", self.extension.config.db_path)
|
||||
|
||||
while self.running:
|
||||
interval = self._interval()
|
||||
@@ -82,10 +82,11 @@ class MonitorDaemon:
|
||||
keystore=self.extension.keystore,
|
||||
license_allows_auto_restart=self._auto_restart_allowed(),
|
||||
failures_before_restart=self._failures_before_restart(),
|
||||
vnc_port=vnc.port_aus_einstellungen(self.extension),
|
||||
)
|
||||
logger.info(
|
||||
"Durchlauf: %s geprueft, %s online, %s offline, %s Neustarts (%s erfolgreich), "
|
||||
"%s ohne Lizenz uebersprungen, %sms",
|
||||
"Durchlauf: %s geprüft, %s online, %s offline, %s Neustarts (%s erfolgreich), "
|
||||
"%s ohne Lizenz übersprungen, %sms",
|
||||
report.checked,
|
||||
report.online,
|
||||
report.offline,
|
||||
@@ -96,7 +97,7 @@ class MonitorDaemon:
|
||||
)
|
||||
except Exception: # noqa: BLE001 - der Dienst darf nie sterben
|
||||
logger.exception("Durchlauf fehlgeschlagen")
|
||||
live.error("monitor", "Durchlauf fehlgeschlagen -- siehe app.log")
|
||||
live.error("monitor", "Durchlauf fehlgeschlagen – siehe app.log")
|
||||
|
||||
# Das Aufraeumen abgelaufener Freigabe-Einbindungen laeuft **nicht**
|
||||
# hier, sondern im Webprozess: eingebunden wird in dessen
|
||||
@@ -112,7 +113,11 @@ class MonitorDaemon:
|
||||
time.sleep(min(1.0, remaining))
|
||||
remaining -= 1.0
|
||||
|
||||
live.info("monitor", "Ueberwachungsprozess beendet")
|
||||
# Warnstufe, nicht INFO: dass die Ueberwachung *laeuft*, ist der
|
||||
# Normalfall und darf gruen sein. Dass sie **steht**, ist der Zustand,
|
||||
# in dem niemand mehr erfaehrt, dass ein Geraet weg ist -- und genau
|
||||
# diese Zeile sucht man hinterher im Protokoll.
|
||||
live.warning("monitor", "Überwachungsprozess beendet")
|
||||
return 0
|
||||
|
||||
|
||||
|
||||
+272
-22
@@ -28,6 +28,9 @@ set -euo pipefail
|
||||
|
||||
APP=""
|
||||
INSTANCE=""
|
||||
#: Verzeichnis der Umgebungsdateien. Ueberschreibbar, damit sich die
|
||||
#: Portsuche in einem Test wirklich ausfuehren laesst.
|
||||
ETC_TESM="${TESM_ETC_DIR:-/etc/tesm}"
|
||||
PORT=""
|
||||
HTTP_PORT=""
|
||||
SKIP_PACKAGES=0
|
||||
@@ -36,6 +39,7 @@ HTTPS=0
|
||||
HTTPS_PORT=""
|
||||
DOMAIN=""
|
||||
ACME_EMAIL=""
|
||||
ERZWINGEN=0
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
REPO_DIR="$(cd "$SCRIPT_DIR/.." && pwd)"
|
||||
@@ -46,6 +50,95 @@ warn() { printf '%s !!%s %s\n' "$COLOR_WARN" "$COLOR_OFF" "$*"; }
|
||||
fail() { printf '%s !!%s %s\n' "$COLOR_ERR" "$COLOR_OFF" "$*" >&2; exit 1; }
|
||||
info() { printf ' %s\n' "$*"; }
|
||||
|
||||
# -- Welche Fassung liegt hier, und welche laeuft schon? ---------------------
|
||||
#
|
||||
# Ein Installationslauf auf dieselbe Fassung macht nichts besser: er baut die
|
||||
# Python-Umgebung neu, kopiert dieselben Dateien, laedt nginx neu und startet
|
||||
# den Dienst -- mit der einzigen Wirkung, dass die Anwendung ein paar Sekunden
|
||||
# weg ist. Wer "update.sh" in einen Zeitplan haengt, bekaeme das taeglich.
|
||||
#
|
||||
# Verglichen wird die Anwendung **und** der Kern: die beiden Nummern laufen
|
||||
# absichtlich auseinander (siehe pyproject.toml), und eine Auslieferung kann
|
||||
# denselben Anwendungsstand mit neuem Kern enthalten.
|
||||
|
||||
#: Die Nummer aus einer pyproject.toml. Die erste Zeile "version = ..." gilt --
|
||||
#: weitere Vorkommen stehen in Abhaengigkeiten und gehen uns nichts an.
|
||||
paket_version() {
|
||||
sed -n 's/^version = "\([^"]*\)"/\1/p' "$1" 2>/dev/null | head -1
|
||||
}
|
||||
|
||||
#: Was in dieser Installation laeuft: "<anwendung> <kern>", sonst leer.
|
||||
laufende_version() {
|
||||
local wurzel="$1" modul="$2"
|
||||
[[ -x "$wurzel/venv/bin/python" ]] || return 0
|
||||
"$wurzel/venv/bin/python" -c \
|
||||
"import $modul, tesm_core; print($modul.__version__, tesm_core.__version__)" \
|
||||
2>/dev/null || true
|
||||
}
|
||||
|
||||
#: Ist das, was hier liegt, schon installiert? Eine eigene Funktion, weil genau
|
||||
#: diese Frage sich pruefen lassen muss -- ohne einen Server aufzusetzen.
|
||||
gleiche_version() {
|
||||
local laeuft="$1" liegt="$2"
|
||||
[[ -n "$laeuft" && "$laeuft" == "$liegt" ]]
|
||||
}
|
||||
|
||||
#: Merker fuer die EXIT-Falle. Zurueckgerollt wird nur, was auch gesichert ist.
|
||||
SICHERUNG_STEHT=0
|
||||
INSTALL_FERTIG=0
|
||||
ZURUECKGEROLLT=0
|
||||
|
||||
zurueckrollen_install() {
|
||||
[[ $SICHERUNG_STEHT -eq 1 && $ZURUECKGEROLLT -eq 0 ]] || return 0
|
||||
# Kommt nur zum Zug, wenn die Sicherung steht -- dann sind diese Variablen
|
||||
# gesetzt. Der Vollstaendigkeit halber trotzdem gepruefte Namen.
|
||||
[[ -n "${BACKUP_DIR:-}" && -d "$BACKUP_DIR" ]] || { warn "Keine Sicherung gefunden -- nichts wiederhergestellt."; return 0; }
|
||||
ZURUECKGEROLLT=1
|
||||
warn "Alten Stand wiederherstellen."
|
||||
systemctl stop "$UNIT" 2>/dev/null || true
|
||||
[[ "$APP" == "tesm" ]] && systemctl stop "$MONITOR_UNIT" 2>/dev/null || true
|
||||
rm -rf "$FAILED_DIR"
|
||||
mv "$ROOT" "$FAILED_DIR" 2>/dev/null || true
|
||||
cp -a "$BACKUP_DIR" "$ROOT"
|
||||
systemctl start "$UNIT" 2>/dev/null || true
|
||||
[[ "$APP" == "tesm" ]] && systemctl start "$MONITOR_UNIT" 2>/dev/null || true
|
||||
warn "Wiederhergestellt aus $BACKUP_DIR"
|
||||
warn "Fehlgeschlagener Stand liegt unter $FAILED_DIR"
|
||||
}
|
||||
|
||||
# Bei **jedem** Fehlschlag, nicht nur bei einem, an den ich gedacht habe:
|
||||
# Systempakete, venv, sudoers, nginx, ein Ctrl-C. Vorher blieb der Dienst
|
||||
# gestoppt und das Verzeichnis teils ersetzt zurueck.
|
||||
bei_fehler() {
|
||||
local code=$?
|
||||
[[ $INSTALL_FERTIG -eq 1 || "$code" == "0" ]] && return "$code"
|
||||
if [[ $SICHERUNG_STEHT -eq 1 ]]; then
|
||||
zurueckrollen_install
|
||||
else
|
||||
# Neuinstallation: es gibt keinen alten Stand. Nichts loeschen -- im
|
||||
# Zielverzeichnis kann eine Fassung 1.x liegen, deren Sicherung ein
|
||||
# anderes Skript haelt (upgrade.sh).
|
||||
# Vorsichtige Ersetzung: bricht es schon beim Auswerten der Argumente ab,
|
||||
# gibt es $ROOT noch nicht -- und "set -u" wuerde die Falle selbst zum
|
||||
# Fehler machen.
|
||||
if [[ ${IS_UPDATE:-0} -eq 1 ]]; then
|
||||
# Abbruch vor dem Sicherungsschritt: die Installation ist unangetastet.
|
||||
warn "Abgebrochen (Code $code), bevor etwas geaendert wurde."
|
||||
warn "Die bestehende Installation ist unberuehrt -- nichts zurueckzurollen."
|
||||
else
|
||||
warn "Abgebrochen (Code $code). Es gab keinen vorherigen Stand dieser Fassung,"
|
||||
warn "also wurde nichts wiederhergestellt und nichts geloescht."
|
||||
# Nur sagen, was es wirklich gibt: bei einem Abbruch an der Rueckfrage
|
||||
# existiert das Verzeichnis noch nicht.
|
||||
if [[ -n "${ROOT:-}" && -d "${ROOT:-}" ]]; then
|
||||
warn "Angelegt wurde bis hierhin: $ROOT (Daten unter data/ unberuehrt)."
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
return "$code"
|
||||
}
|
||||
trap bei_fehler EXIT
|
||||
|
||||
usage() {
|
||||
cat <<'USAGE'
|
||||
Verwendung: install.sh --app <tesm|tesm-license> [Optionen]
|
||||
@@ -70,6 +163,7 @@ Verwendung: install.sh --app <tesm|tesm-license> [Optionen]
|
||||
--acme-email <a> Kontaktadresse fuer Let's Encrypt.
|
||||
--skip-packages Keine Systempakete installieren
|
||||
--yes Rueckfragen ueberspringen
|
||||
--force Auch dann einrichten, wenn dieselbe Fassung schon laeuft
|
||||
USAGE
|
||||
}
|
||||
|
||||
@@ -85,6 +179,7 @@ while [[ $# -gt 0 ]]; do
|
||||
--acme-email) ACME_EMAIL="${2:?}"; shift 2 ;;
|
||||
--skip-packages) SKIP_PACKAGES=1; shift ;;
|
||||
--yes|-y) ASSUME_YES=1; shift ;;
|
||||
--force) ERZWINGEN=1; shift ;;
|
||||
-h|--help) usage; exit 0 ;;
|
||||
*) fail "unbekannte Option: $1" ;;
|
||||
esac
|
||||
@@ -109,16 +204,35 @@ LOG_DIR="/var/log/$NAME"
|
||||
SERVICE_USER="$APP"
|
||||
UNIT="$NAME.service"
|
||||
MONITOR_UNIT="$NAME-monitor.service"
|
||||
CLEANUP_UNIT="$NAME-cleanup.service"
|
||||
CLEANUP_TIMER="$NAME-cleanup.timer"
|
||||
NGINX_SITE="/etc/nginx/sites-available/$NAME"
|
||||
ENV_PREFIX="TESM_"
|
||||
[[ "$APP" == "tesm-license" ]] && ENV_PREFIX="TESM_LICENSE_"
|
||||
|
||||
# Ausgabe des Lizenzservers: die Master-Ausgabe bringt das Teilpaket
|
||||
# tesm_license/master mit -- den einzigen Weg, auf dem eine Lizenzdatei die
|
||||
# Rolle "master" bekommt. Das Release fuer Sub-Lizenzserver hat es nicht.
|
||||
# Die Ausgabe haengt an der Anwesenheit des Verzeichnisses, nicht an einer
|
||||
# Einstellung: eine Einstellung liesse sich vor Ort umstellen.
|
||||
MASTER_TEILPAKET="apps/tesm-license/src/tesm_license/master"
|
||||
EDITION="standard"
|
||||
if [[ "$APP" == "tesm-license" ]]; then
|
||||
if [[ -d "$REPO_DIR/$MASTER_TEILPAKET" ]]; then EDITION="master"; else EDITION="sub"; fi
|
||||
fi
|
||||
# Bei einer Aktualisierung gilt zuerst, was schon eingerichtet ist. Sonst
|
||||
# verschiebt ein Update ohne Portangaben die Anwendung: eine benannte Instanz
|
||||
# faellt auf 8080/8443 zurueck, auch wenn sie seit der Installation auf Port 80
|
||||
# lief -- und ein Reverse Proxy davor zeigt dann ins Leere. Dieselbe Regel wie
|
||||
# beim Secure-Flag weiter unten: eine Aktualisierung nimmt nichts weg, was
|
||||
# nicht ausdruecklich neu gesetzt wird.
|
||||
ENV_FILE="/etc/tesm/$NAME.env"
|
||||
ENV_FILE="$ETC_TESM/$NAME.env"
|
||||
# Wo die Daten liegen, steht in der Umgebungsdatei; ohne sie beim Vorgabeort.
|
||||
# "|| true": bei einer Neuinstallation gibt es die Datei noch nicht, und ein
|
||||
# scheiterndes sed in einer Zuweisung beendet unter "set -e" das ganze Skript.
|
||||
DATEN_DIR="$(sed -n "s/^${ENV_PREFIX}DATA_DIR=//p" "$ENV_FILE" 2>/dev/null || true)"
|
||||
DATEN_DIR="$(printf '%s' "$DATEN_DIR" | tail -1)"
|
||||
DATEN_DIR="${DATEN_DIR:-$ROOT/data}"
|
||||
if [[ -z "$PORT" && -f "$ENV_FILE" ]]; then
|
||||
PORT="$(sed -n "s/^${ENV_PREFIX}BIND=127\.0\.0\.1:\([0-9]\{1,5\}\)$/\1/p" "$ENV_FILE" | head -1)"
|
||||
fi
|
||||
@@ -127,10 +241,30 @@ if [[ -f "$NGINX_SITE" ]]; then
|
||||
[[ -z "$HTTPS_PORT" ]] && HTTPS_PORT="$(sed -n 's/^[[:space:]]*listen[[:space:]]\([0-9]\{1,5\}\)[[:space:]]\+ssl.*/\1/p' "$NGINX_SITE" | head -1)"
|
||||
fi
|
||||
|
||||
port_belegt() {
|
||||
local p="$1"
|
||||
# Schon von einer anderen Installation vergeben? Die Umgebungsdateien sind
|
||||
# die Wahrheit -- ein Dienst kann gerade gestoppt sein und den Port trotzdem
|
||||
# beanspruchen.
|
||||
grep -qs "BIND=127\.0\.0\.1:$p\$" "$ETC_TESM"/*.env 2>/dev/null &&
|
||||
return 0
|
||||
# Oder gerade in Benutzung, von was auch immer.
|
||||
command -v ss >/dev/null 2>&1 && [[ -n "$(ss -Hltn "sport = :$p" 2>/dev/null)" ]] && return 0
|
||||
return 1
|
||||
}
|
||||
|
||||
if [[ -z "$PORT" ]]; then
|
||||
PORT=5000
|
||||
[[ "$APP" == "tesm-license" ]] && PORT=5001
|
||||
[[ -n "$INSTANCE" ]] && PORT=$((PORT + 100))
|
||||
BASISPORT=5000
|
||||
[[ "$APP" == "tesm-license" ]] && BASISPORT=5001
|
||||
if [[ -n "$INSTANCE" ]]; then
|
||||
# Frueher bekam **jede** Instanz Basis+100. Die zweite startete nie
|
||||
# ("Address already in use") -- und die Gesundheitspruefung merkte es
|
||||
# nicht, weil die erste auf demselben Port antwortete.
|
||||
PORT=$((BASISPORT + 100))
|
||||
while port_belegt "$PORT"; do PORT=$((PORT + 1)); done
|
||||
else
|
||||
PORT=$BASISPORT
|
||||
fi
|
||||
fi
|
||||
if [[ -z "$HTTP_PORT" ]]; then
|
||||
HTTP_PORT=80
|
||||
@@ -163,6 +297,23 @@ FAILED_DIR="/srv/$NAME-failed-$TIMESTAMP"
|
||||
IS_UPDATE=0
|
||||
[[ -f "$ROOT/data/app.db" ]] && IS_UPDATE=1
|
||||
|
||||
# Laeuft hier schon genau das, was hier liegt? Dann ist Schluss, bevor
|
||||
# irgendetwas angefasst wird -- und zwar mit Ausstiegscode 0: es ist nichts
|
||||
# schiefgegangen, es war nur nichts zu tun.
|
||||
MODUL="tesm"
|
||||
[[ "$APP" == "tesm-license" ]] && MODUL="tesm_license"
|
||||
LIEGT="$(paket_version "$REPO_DIR/apps/$APP/pyproject.toml") $(
|
||||
paket_version "$REPO_DIR/packages/tesm-core/pyproject.toml")"
|
||||
LAEUFT="$(laufende_version "$ROOT" "$MODUL")"
|
||||
if [[ $IS_UPDATE -eq 1 && $ERZWINGEN -eq 0 ]] && gleiche_version "$LAEUFT" "$LIEGT"; then
|
||||
step "Nichts zu tun"
|
||||
info "$APP ${LIEGT%% *} (tesm-core ${LIEGT##* }) laeuft hier bereits."
|
||||
info "Ein zweiter Durchlauf wuerde nur die Umgebung neu bauen und den"
|
||||
info "Dienst neu starten -- ohne dass sich etwas aendert."
|
||||
info "Trotzdem neu einrichten: --force"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
step "TESM-Installation"
|
||||
info "Anwendung : $APP${INSTANCE:+ (Instanz $INSTANCE)}"
|
||||
info "Ziel : $ROOT"
|
||||
@@ -180,6 +331,7 @@ else
|
||||
info "nginx : Port $HTTP_PORT (nur HTTP)"
|
||||
fi
|
||||
info "Modus : $([[ $IS_UPDATE -eq 1 ]] && echo Aktualisierung || echo Neuinstallation)"
|
||||
info "Fassung : ${LIEGT%% *} (tesm-core ${LIEGT##* })${LAEUFT:+, bisher ${LAEUFT%% *}}"
|
||||
|
||||
if [[ $ASSUME_YES -eq 0 ]]; then
|
||||
read -r -p "Fortfahren? [j/N] " answer
|
||||
@@ -212,12 +364,41 @@ fi
|
||||
# Der Vorgaenger lief als root. Hier laeuft der Dienst unprivilegiert; Rootrechte
|
||||
# gibt es ausschliesslich ueber den Helfer.
|
||||
|
||||
# Ein Sub-Paket auf eine Master-Installation waere ein stiller Rueckbau: der
|
||||
# Abgleich unten laeuft mit --delete und wuerde das Teilpaket entfernen. Danach
|
||||
# gaebe es auf dem Master keinen Weg mehr, eine Master-Lizenz auszustellen --
|
||||
# und niemand haette es gesagt.
|
||||
# Die Rolle steht in der eingespielten Lizenz. Das ist die zuverlaessigere
|
||||
# Haelfte der Pruefung: eine Master-Installation aus einer aelteren Fassung
|
||||
# kennt das Teilpaket noch nicht, ist aber trotzdem eine Wurzelinstanz.
|
||||
ROLLE_ALT=""
|
||||
if [[ "$EDITION" == "sub" && -s "$DATEN_DIR/license.json" ]]; then
|
||||
ROLLE_ALT="$(python3 -c '
|
||||
import json, sys
|
||||
try:
|
||||
print(json.load(open(sys.argv[1])).get("role", ""))
|
||||
except Exception:
|
||||
pass
|
||||
' "$DATEN_DIR/license.json" 2>/dev/null || true)"
|
||||
fi
|
||||
|
||||
if [[ "$EDITION" == "sub" && ( -d "$ROOT/$MASTER_TEILPAKET" || "$ROLLE_ALT" == "master" ) ]]; then
|
||||
fail "Hier laeuft eine Wurzelinstanz${ROLLE_ALT:+ (Lizenz mit Rolle $ROLLE_ALT)}, dieses Paket
|
||||
ist die Ausgabe fuer Sub-Lizenzserver.
|
||||
Eine Aktualisierung damit wuerde die Master-Ausgabe entfernen.
|
||||
Passendes Paket: Repository tesm-license-master (privat).
|
||||
Bewusster Rueckbau: eine vom Master ausgestellte Lizenz einspielen (Rolle
|
||||
license_server) und $ROOT/$MASTER_TEILPAKET entfernen -- danach ist dies ein
|
||||
Sub-Lizenzserver und dieses Paket das richtige."
|
||||
fi
|
||||
|
||||
# -- Backup ------------------------------------------------------------------
|
||||
if [[ $IS_UPDATE -eq 1 ]]; then
|
||||
step "Sicherung vor der Aktualisierung"
|
||||
systemctl stop "$UNIT" 2>/dev/null || true
|
||||
[[ "$APP" == "tesm" ]] && systemctl stop "$MONITOR_UNIT" 2>/dev/null || true
|
||||
cp -a "$ROOT" "$BACKUP_DIR"
|
||||
SICHERUNG_STEHT=1
|
||||
info "Sicherung: $BACKUP_DIR"
|
||||
fi
|
||||
|
||||
@@ -237,9 +418,32 @@ info "Dateien abgeglichen (Instanzdaten unter data/ bleiben unangetastet)"
|
||||
|
||||
# -- venv --------------------------------------------------------------------
|
||||
step "Python-Umgebung"
|
||||
if [[ ! -x "$ROOT/venv/bin/python3" ]]; then
|
||||
# Ein venv haengt an der **Minor-Fassung**, mit der er gebaut wurde: die Pakete
|
||||
# liegen unter lib/python3.X. Wechselt die Systemfassung -- ein Release-Upgrade
|
||||
# von Ubuntu bringt genau das, 24.04 hat 3.12 und 26.04 hat 3.14 --, dann ist
|
||||
# venv/bin/python3 weiter ausfuehrbar: der Verweis zeigt auf /usr/bin/python3,
|
||||
# also auf den **neuen** Interpreter. Der venv sieht heil aus und ist es nicht.
|
||||
# Er findet danach kein einziges Paket mehr, nicht einmal pip.
|
||||
#
|
||||
# Gemessen auf dem Testserver, mit verschobenem lib-Verzeichnis:
|
||||
# [[ -x venv/bin/python3 ]] -> wahr
|
||||
# venv/bin/python3 -m pip --version -> "No module named pip"
|
||||
#
|
||||
# Die alte Pruefung fragte nur nach der Ausfuehrbarkeit. Sie haette den toten
|
||||
# venv behalten und waere eine Zeile spaeter an "python3 -m pip" gescheitert --
|
||||
# mitten in einer Aktualisierung, auf einem Server, dessen Dienste ohnehin
|
||||
# schon stehen. Gefragt wird deshalb, was gebraucht wird: laesst sich in
|
||||
# diesem venv pip benutzen?
|
||||
venv_traegt() {
|
||||
[[ -x "$ROOT/venv/bin/python3" ]] || return 1
|
||||
"$ROOT/venv/bin/python3" -c "import pip" >/dev/null 2>&1
|
||||
}
|
||||
if ! venv_traegt; then
|
||||
# Ohne --clear: ein erneutes "venv" ergaenzt das fehlende lib-Verzeichnis und
|
||||
# holt pip zurueck; die Abhaengigkeiten kommen gleich darunter ohnehin neu.
|
||||
# Auch das ist gemessen und nicht angenommen.
|
||||
python3 -m venv "$ROOT/venv"
|
||||
info "venv erzeugt"
|
||||
info "Python-Umgebung aufgebaut ($(python3 -V 2>&1))"
|
||||
fi
|
||||
# Immer ueber "python3 -m pip" -- der pip-Shim im venv baeckt einen absoluten
|
||||
# Pfad in seinen Shebang, der nach einer Umbenennung des Verzeichnisses bricht.
|
||||
@@ -347,8 +551,8 @@ step "Umgebung"
|
||||
# EnvironmentFile, die Kommandozeilenwerkzeuge ueber ihren Wrapper. Ohne das
|
||||
# wuesste "tesm-admin" nicht, wo die Instanz liegt, und wuerde sich eine leere
|
||||
# Datenbank im Arbeitsverzeichnis anlegen.
|
||||
install -d -o root -g root -m 0755 /etc/tesm
|
||||
ENV_FILE="/etc/tesm/$NAME.env"
|
||||
install -d -o root -g root -m 0755 "$ETC_TESM"
|
||||
ENV_FILE="$ETC_TESM/$NAME.env"
|
||||
TMP_ENV="$(mktemp)"
|
||||
{
|
||||
echo "# Von install.sh erzeugt fuer $NAME -- nicht von Hand bearbeiten."
|
||||
@@ -414,6 +618,11 @@ render_unit "$ROOT/deploy/systemd/$APP.service" "/etc/systemd/system/$UNIT"
|
||||
if [[ "$APP" == "tesm" ]]; then
|
||||
render_unit "$ROOT/deploy/systemd/tesm-monitor.service" "/etc/systemd/system/$MONITOR_UNIT"
|
||||
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" \
|
||||
|| info "Units unveraendert"
|
||||
|
||||
@@ -486,12 +695,18 @@ systemctl enable --now "$UNIT" >/dev/null
|
||||
if [[ "$APP" == "tesm" ]]; then
|
||||
systemctl enable --now "$MONITOR_UNIT" >/dev/null
|
||||
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 ----------------------------------------------
|
||||
step "Gesundheitspruefung"
|
||||
HEALTHY=0
|
||||
for attempt in $(seq 1 20); do
|
||||
if curl -fsS --max-time 3 "http://127.0.0.1:$PORT/gesundheit" >/dev/null 2>&1; then
|
||||
# Beides: der Dienst muss laufen **und** antworten. Nur die Antwort zu
|
||||
# pruefen ging schon einmal schief -- auf einem gemeinsamen Port antwortete
|
||||
# eine andere Instanz, und ein toter Dienst galt als gesund.
|
||||
if systemctl is-active --quiet "$UNIT" &&
|
||||
curl -fsS --max-time 3 "http://127.0.0.1:$PORT/health" >/dev/null 2>&1; then
|
||||
HEALTHY=1
|
||||
info "Antwort nach ${attempt}x"
|
||||
break
|
||||
@@ -502,13 +717,9 @@ done
|
||||
if [[ $HEALTHY -eq 0 ]]; then
|
||||
warn "Die Anwendung antwortet nicht."
|
||||
if [[ $IS_UPDATE -eq 1 ]]; then
|
||||
warn "Rollback wird ausgefuehrt."
|
||||
systemctl stop "$UNIT" 2>/dev/null || true
|
||||
[[ "$APP" == "tesm" ]] && systemctl stop "$MONITOR_UNIT" 2>/dev/null || true
|
||||
mv "$ROOT" "$FAILED_DIR"
|
||||
cp -a "$BACKUP_DIR" "$ROOT"
|
||||
systemctl start "$UNIT" || true
|
||||
[[ "$APP" == "tesm" ]] && systemctl start "$MONITOR_UNIT" || true
|
||||
# Dieselbe Funktion wie die EXIT-Falle -- zwei Wege, die dasselbe tun
|
||||
# sollen, laufen sonst irgendwann auseinander.
|
||||
zurueckrollen_install
|
||||
fail "Aktualisierung fehlgeschlagen. Alter Stand wiederhergestellt.
|
||||
Fehlgeschlagene Version : $FAILED_DIR
|
||||
Sicherung : $BACKUP_DIR
|
||||
@@ -568,12 +779,41 @@ if [[ $HTTPS -eq 1 ]]; then
|
||||
fi
|
||||
fi
|
||||
|
||||
# -- Lizenzserver einrichten -------------------------------------------------
|
||||
# Bei einer Neuinstallation: Signaturschluessel anlegen (braucht jeder
|
||||
# Lizenzserver, um Kundenlizenzen zu signieren) und in der Master-Ausgabe
|
||||
# gleich die eigene Master-Lizenz. Bei einer Aktualisierung nichts davon --
|
||||
# ein bestehender Schluessel darf nie angetastet werden.
|
||||
if [[ "$APP" == "tesm-license" && $IS_UPDATE -eq 0 ]]; then
|
||||
step "Signaturschluessel"
|
||||
if sudo -u "$SERVICE_USER" "/usr/local/bin/$NAME-admin" init-key; then
|
||||
info "Bitte die Datei $DATEN_DIR/master_signing_key.json SOFORT sichern."
|
||||
else
|
||||
fail "Der Signaturschluessel liess sich nicht erzeugen. Ohne ihn kann dieser Server
|
||||
keine Lizenz ausstellen. Protokoll: journalctl -u $UNIT -n 50"
|
||||
fi
|
||||
|
||||
if [[ "$EDITION" == "master" ]]; then
|
||||
step "Eigene Master-Lizenz"
|
||||
if [[ -s "$DATEN_DIR/license.json" ]]; then
|
||||
info "Es liegt schon eine Lizenzdatei -- unangetastet."
|
||||
elif sudo -u "$SERVICE_USER" "/usr/local/bin/$NAME-admin" bootstrap; then
|
||||
systemctl restart "$UNIT"
|
||||
info "Dienst neu gestartet, damit die Lizenz gelesen wird."
|
||||
else
|
||||
fail "Die Master-Lizenz liess sich nicht ausstellen.
|
||||
Nachtraeglich: sudo -u $SERVICE_USER $NAME-admin bootstrap"
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
|
||||
if [[ $IS_UPDATE -eq 1 ]]; then
|
||||
rm -rf "$BACKUP_DIR"
|
||||
info "Sicherung entfernt (Aktualisierung erfolgreich)"
|
||||
fi
|
||||
|
||||
# -- Abschluss ---------------------------------------------------------------
|
||||
INSTALL_FERTIG=1
|
||||
step "Fertig"
|
||||
ADMIN_CMD="tesm-admin"
|
||||
[[ "$APP" == "tesm-license" ]] && ADMIN_CMD="tesm-license-admin"
|
||||
@@ -589,14 +829,24 @@ cat <<SUMMARY
|
||||
Naechste Schritte:
|
||||
SUMMARY
|
||||
|
||||
if [[ "$APP" == "tesm-license" ]]; then
|
||||
if [[ "$APP" == "tesm-license" && "$EDITION" == "master" ]]; then
|
||||
cat <<SUMMARY
|
||||
1. Signaturschluessel erzeugen und sofort sichern:
|
||||
sudo -u $SERVICE_USER $NAME-admin init-key
|
||||
2. Eigene Master-Lizenz ausstellen:
|
||||
sudo -u $SERVICE_USER $NAME-admin bootstrap
|
||||
3. Administrator anlegen:
|
||||
1. Administrator anlegen:
|
||||
sudo -u $SERVICE_USER $NAME-admin create-admin
|
||||
2. Signaturschluessel sichern -- ohne ihn ist keine ausgestellte Lizenz
|
||||
mehr pruefbar:
|
||||
$ROOT/data/master_signing_key.json
|
||||
SUMMARY
|
||||
elif [[ "$APP" == "tesm-license" ]]; then
|
||||
cat <<SUMMARY
|
||||
1. Administrator anlegen:
|
||||
sudo -u $SERVICE_USER $NAME-admin create-admin
|
||||
2. Beim Master-Lizenzserver eine Lizenz fuer diesen Server anfordern und
|
||||
unter "Eigene Lizenz" einspielen. Diese Ausgabe ist ein Sub-Lizenzserver
|
||||
und stellt keine eigene Master-Lizenz aus.
|
||||
3. Signaturschluessel sichern -- mit ihm signiert dieser Server seine
|
||||
Kundenlizenzen:
|
||||
$ROOT/data/master_signing_key.json
|
||||
SUMMARY
|
||||
else
|
||||
cat <<SUMMARY
|
||||
|
||||
+12
-3
@@ -14,12 +14,21 @@ TAG="${1:?Verwendung: release.sh vX.Y.Z [name]}"
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
REPO_DIR="$(cd "$SCRIPT_DIR/.." && pwd)"
|
||||
|
||||
# Vorgabe: der Name der einzigen Anwendung im Baum, sonst der Verzeichnisname.
|
||||
# Vorgabe: der Name des Repositoriums. Genau den erwartet der Bootstrap von
|
||||
# update.sh als Anhangsnamen ("$repo-$TAG.tar.gz"). Die Anwendung heisst nicht
|
||||
# immer so: im Repositorium tesm-license-master liegt die Anwendung
|
||||
# "tesm-license", und ein Anhang unter deren Namen waere nie zu finden.
|
||||
if [[ -n "${2:-}" ]]; then
|
||||
NAME="$2"
|
||||
else
|
||||
mapfile -t APPS < <(ls -1 "$REPO_DIR/apps" 2>/dev/null || true)
|
||||
if [[ ${#APPS[@]} -eq 1 ]]; then NAME="${APPS[0]}"; else NAME="$(basename "$REPO_DIR")"; fi
|
||||
HERKUNFT="$(git -C "$REPO_DIR" config --get remote.origin.url 2>/dev/null || true)"
|
||||
if [[ -n "$HERKUNFT" ]]; then
|
||||
NAME="$(basename "$HERKUNFT" .git)"
|
||||
else
|
||||
# Ohne Gegenseite: die einzige Anwendung im Baum, sonst das Verzeichnis.
|
||||
mapfile -t APPS < <(ls -1 "$REPO_DIR/apps" 2>/dev/null || true)
|
||||
if [[ ${#APPS[@]} -eq 1 ]]; then NAME="${APPS[0]}"; else NAME="$(basename "$REPO_DIR")"; fi
|
||||
fi
|
||||
fi
|
||||
|
||||
OUT="$NAME-$TAG.tar.gz"
|
||||
|
||||
+3
-3
@@ -3,8 +3,8 @@
|
||||
# Benennt eine Installation um -- aus einer Instanz wird die Hauptinstallation
|
||||
# (oder umgekehrt).
|
||||
#
|
||||
# sudo bash deploy/rename.sh --app tesm --from tesm-opus --to tesm
|
||||
# sudo bash deploy/rename.sh --app tesm-license --from tesm-license-opus \
|
||||
# sudo bash deploy/rename.sh --app tesm --from tesm-test --to tesm
|
||||
# sudo bash deploy/rename.sh --app tesm-license --from tesm-license-test \
|
||||
# --to tesm-license
|
||||
#
|
||||
# Der Name steckt an mehr Stellen, als man beim Verschieben eines Verzeichnisses
|
||||
@@ -51,7 +51,7 @@ usage() {
|
||||
Verwendung: rename.sh --app <tesm|tesm-license> --from <alt> --to <neu> [Optionen]
|
||||
|
||||
--app <name> Welche Anwendung (Pflicht)
|
||||
--from <name> Bisheriger Installationsname, z.B. tesm-opus (Pflicht)
|
||||
--from <name> Bisheriger Installationsname, z.B. tesm-test (Pflicht)
|
||||
--to <name> Neuer Installationsname, z.B. tesm (Pflicht)
|
||||
--suffix <text> Namenszusatz fuer eine beiseite geschobene Installation
|
||||
der Fassung 1.x (Vorgabe: sonnet5)
|
||||
|
||||
+7
-1
@@ -5,5 +5,11 @@
|
||||
#
|
||||
# Bewusst KEIN "ALL=(ALL) NOPASSWD: ALL". Der Vorgaenger loeste dasselbe
|
||||
# Problem, indem die gesamte Anwendung als root lief.
|
||||
Defaults:tesm !requiretty
|
||||
#
|
||||
# Hier stand "Defaults:tesm !requiretty". Auf Debian und Ubuntu ist
|
||||
# requiretty nie gesetzt, die Zeile war also von Anfang an wirkungslos -- und
|
||||
# ab sudo 1.9.16 kennt visudo die Einstellung nicht mehr und lehnt die ganze
|
||||
# Datei ab ("unknown setting: 'requiretty'"). Auf einem frisch aufgesetzten
|
||||
# Server brach die Installation deshalb mitten drin ab. Nicht wieder
|
||||
# hinzufuegen; ein Hygienetest haelt das fest.
|
||||
tesm ALL=(root) NOPASSWD: /usr/local/lib/tesm/tesm-helper
|
||||
@@ -5,5 +5,11 @@
|
||||
#
|
||||
# Bewusst KEIN "ALL=(ALL) NOPASSWD: ALL". Der Vorgaenger loeste dasselbe
|
||||
# Problem, indem die gesamte Anwendung als root lief.
|
||||
Defaults:tesm-license !requiretty
|
||||
#
|
||||
# Hier stand "Defaults:tesm-license !requiretty". Auf Debian und Ubuntu ist
|
||||
# requiretty nie gesetzt, die Zeile war also von Anfang an wirkungslos -- und
|
||||
# ab sudo 1.9.16 kennt visudo die Einstellung nicht mehr und lehnt die ganze
|
||||
# Datei ab ("unknown setting: 'requiretty'"). Auf einem frisch aufgesetzten
|
||||
# Server brach die Installation deshalb mitten drin ab. Nicht wieder
|
||||
# hinzufuegen; ein Hygienetest haelt das fest.
|
||||
tesm-license ALL=(root) NOPASSWD: /usr/local/lib/tesm/tesm-helper
|
||||
@@ -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
@@ -47,7 +47,7 @@ check_in_list() {
|
||||
}
|
||||
|
||||
# Name einer Installation auf diesem Host: entweder die Hauptinstallation
|
||||
# ("tesm") oder eine benannte Instanz daneben ("tesm-opus"). Mehr als diese
|
||||
# ("tesm") oder eine benannte Instanz daneben ("tesm-test"). Mehr als diese
|
||||
# beiden Formen gibt es nicht -- so kann keine erfundene Zeichenkette einen
|
||||
# Pfad ausserhalb des vorgesehenen Namensraums erzeugen.
|
||||
check_app_key() {
|
||||
|
||||
@@ -150,6 +150,22 @@ def main(argv: list[str] | None = None) -> int:
|
||||
globals().update(AMBER="", DIM="", BOLD="", RESET="")
|
||||
|
||||
root = Path(args.root)
|
||||
|
||||
# Gibt es diese Installation ueberhaupt noch? Wenn nicht, ist hier nichts
|
||||
# zu melden -- auch kein Fehler.
|
||||
#
|
||||
# Der Hook unter /etc/update-motd.d bleibt liegen, wenn jemand eine
|
||||
# Installation von Hand entfernt; einen Rueckbau bringt TESM nicht mit.
|
||||
# Vorher druckte jeder dieser Hooks trotzdem seinen vollen Banner und
|
||||
# darunter "Datenbank nicht lesbar". Auf dem Testserver standen so
|
||||
# vierzehn Bloecke fuer vierzehn laengst geloeschte Probeinstallationen
|
||||
# vor jeder Anmeldung -- und der echte Banner ging darin unter.
|
||||
#
|
||||
# Eine entfernte Installation ist keine kaputte. Eine Datenbank, die da
|
||||
# ist und sich nicht lesen laesst, dagegen schon: die meldet sich weiter.
|
||||
if not (root / "data" / "app.db").is_file():
|
||||
return 0
|
||||
|
||||
# Kein isatty()-Test: dieses Skript schreibt beim Login nicht auf ein
|
||||
# Terminal, sondern ueber run-parts in eine Datei. Der Betrachter ist
|
||||
# trotzdem ein echtes Terminal -- deshalb immer Farbe.
|
||||
|
||||
+199
-61
@@ -1,89 +1,227 @@
|
||||
#!/usr/bin/env bash
|
||||
# =============================================================================
|
||||
# Bootstrapper: laedt ein Release herunter und ruft dessen install.sh auf.
|
||||
# Aktualisierung: laedt das aktuelle Release und ruft dessen install.sh auf.
|
||||
# Enthaelt selbst keinerlei Installationslogik.
|
||||
#
|
||||
# Zwei Fallen des Vorgaengers sind hier bewusst vermieden:
|
||||
# * Der Tag "latest" wird ueber die API aufgeloest, nie direkt in die
|
||||
# Download-URL geschrieben. Gitea liefert sonst mit HTTP 200 ein
|
||||
# automatisch erzeugtes Quell-Snapshot statt des echten Release-Assets.
|
||||
# * Nach dem Entpacken wird geprueft, dass ein install.sh existiert -- faengt
|
||||
# denselben Fehler ein zweites Mal ab.
|
||||
# Eine einzelne Datei genuegt -- sie holt sich alles Weitere:
|
||||
#
|
||||
# curl -fsSLO https://gitea.int.eertmoed.net/alientim/tesm/raw/branch/main/deploy/update.sh
|
||||
# sudo bash update.sh tesm
|
||||
#
|
||||
# Auf einer bestehenden Installation liegt sie schon bereit:
|
||||
#
|
||||
# sudo bash /srv/tesm/deploy/update.sh tesm
|
||||
#
|
||||
# Laeuft hier die Master-Ausgabe des Lizenzservers, kommt das Release aus dem
|
||||
# privaten Repositorium tesm-license-master -- dafuer braucht es einen
|
||||
# Zugriffstoken in TESM_RELEASE_TOKEN oder in /etc/tesm-release.token.
|
||||
# =============================================================================
|
||||
set -euo pipefail
|
||||
|
||||
# Ein Wagenruecklauf im Paket macht jedes Shell-Skript auf dem Ziel
|
||||
# unbrauchbar ("bad interpreter"). Die Pruefung darf dabei nicht selbst
|
||||
# von einem Zusatzpaket abhaengen: mit "file" -- das auf einem schlanken
|
||||
# Server fehlt -- endete sie in "command not found", grep bekam nichts zu
|
||||
# sehen, und das Skript machte weiter, als waere alles in Ordnung.
|
||||
hat_crlf() {
|
||||
local cr
|
||||
cr="$(printf '\r')"
|
||||
# -U (binaer lesen) ist unter Linux ohne Wirkung, auf einer Windows-Buildmaschine
|
||||
# aber entscheidend: dort entfernt grep den Wagenruecklauf beim Lesen und
|
||||
# findet deshalb nie einen.
|
||||
LC_ALL=C grep -qU "$cr" "$1"
|
||||
}
|
||||
|
||||
APP="${1:-}"
|
||||
TAG="${2:-latest}"
|
||||
|
||||
# Alles nach dem Tag geht unveraendert an install.sh weiter. Ohne das liesse
|
||||
# sich eine benannte Instanz nicht aktualisieren: install.sh leitet den Namen
|
||||
# aus "--instance" ab, und ohne den Schalter entstuende neben "tesm-opus" eine
|
||||
# aus "--instance" ab, und ohne den Schalter entstuende neben "tesm-test" eine
|
||||
# zweite Installation namens "tesm" -- mit eigener Datenbank, eigener
|
||||
# nginx-Site und demselben Port.
|
||||
shift $(( $# > 2 ? 2 : $# ))
|
||||
EXTRA=("$@")
|
||||
# Bezugsquelle. Die Vorgaben zeigen dorthin, wo die Releases tatsaechlich
|
||||
# liegen -- ein Platzhalter wie "gitea.example.invalid" macht das Skript auf
|
||||
# jedem Host unbrauchbar, und genau das war hier der Fall.
|
||||
#
|
||||
# Das Repository heisst wie die Anwendung; getrennte Repositorien, getrennte
|
||||
# Releases. Der Anhang heisst "<repo>-<tag>.tar.gz", siehe unten.
|
||||
BASE="${TESM_RELEASE_BASE:-https://gitea.int.eertmoed.net}"
|
||||
OWNER="${TESM_RELEASE_OWNER:-alientim}"
|
||||
|
||||
[[ "$APP" == "tesm" || "$APP" == "tesm-license" ]] || {
|
||||
echo "Verwendung: update.sh <tesm|tesm-license> [tag] [install.sh-Argumente ...]" >&2
|
||||
echo "Beispiel : update.sh tesm latest --instance opus" >&2
|
||||
echo "Beispiel : update.sh tesm latest --instance test" >&2
|
||||
echo "Ohne Rueckfrage: --yes anhaengen (ohne Terminal zwingend)." >&2
|
||||
echo "Laeuft die Fassung schon, endet der Aufruf ohne Arbeit; --force setzt" >&2
|
||||
echo "sie trotzdem neu auf." >&2
|
||||
exit 2
|
||||
}
|
||||
|
||||
REPO="${TESM_RELEASE_REPO:-$APP}"
|
||||
|
||||
WORK="$(mktemp -d /tmp/tesm-update-XXXXXX)"
|
||||
trap 'rm -rf "$WORK"' EXIT # anders als im Vorgaenger wird aufgeraeumt
|
||||
|
||||
if [[ "$TAG" == "latest" ]]; then
|
||||
echo "==> Ermittle aktuellen Release-Tag ..."
|
||||
TAG="$(curl -fsS "$BASE/api/v1/repos/$OWNER/$REPO/releases/latest" \
|
||||
| python3 -c 'import json,sys; print(json.load(sys.stdin)["tag_name"])')"
|
||||
echo " $TAG"
|
||||
# Repositorium: die Master-Ausgabe des Lizenzservers liegt in einem eigenen,
|
||||
# privaten Repositorium, heisst als Installation aber weiter "tesm-license".
|
||||
# Erkannt wird sie an der Installation und nicht an einem Schalter -- wer
|
||||
# aktualisiert, soll nicht erst wissen muessen, welche Ausgabe hier laeuft.
|
||||
# Sonst holte der Aufruf die Ausgabe fuer Sub-Lizenzserver, und der Installer
|
||||
# muesste sie abweisen.
|
||||
REPO="$APP"
|
||||
if [[ "$APP" == "tesm-license" ]]; then
|
||||
NAME="$APP"
|
||||
for ((i = 0; i < ${#EXTRA[@]}; i++)); do
|
||||
[[ "${EXTRA[i]}" == "--instance" ]] && NAME="$APP-${EXTRA[i + 1]:-}"
|
||||
done
|
||||
# Die Wurzel ist ueberschreibbar, damit ein Test die Erkennung wirklich
|
||||
# ausfuehren kann und nicht nur den Quelltext liest.
|
||||
if [[ -d "${TESM_SRV_ROOT:-/srv}/$NAME/apps/tesm-license/src/tesm_license/master" ]]; then
|
||||
REPO="tesm-license-master"
|
||||
echo "==> Master-Ausgabe erkannt -- Release aus $REPO."
|
||||
fi
|
||||
fi
|
||||
|
||||
ASSET="$REPO-$TAG.tar.gz"
|
||||
echo "==> Lade $ASSET ..."
|
||||
curl -fsSL -o "$WORK/$ASSET" "$BASE/$OWNER/$REPO/releases/download/$TAG/$ASSET"
|
||||
# --8<-- Bootstrap: Release holen --------------------------------------------
|
||||
# Wortgleich in update.sh und upgrade.sh; ein Hygienetest vergleicht die beiden
|
||||
# Bloecke. Zwei eigenstaendige Skripte koennen sich keine Datei teilen, und
|
||||
# beide sollen als **einzelne Datei** genuegen: auf das Zielsystem legen,
|
||||
# ausfuehren, fertig -- auf einer 1.2.8 liegt nichts von diesem Projekt.
|
||||
#
|
||||
# Erwartet: $APP und $TAG, wahlweise $REPO (Vorgabe: $APP). Setzt: $TAG
|
||||
# (aufgeloest) und $SRC (entpacktes Release). Raeumt sein Arbeitsverzeichnis
|
||||
# beim Beenden auf.
|
||||
#
|
||||
# $REPO gibt es, weil Installationsname und Repositorium auseinanderfallen
|
||||
# koennen: die Master-Ausgabe des Lizenzservers liegt in
|
||||
# "tesm-license-master", die Installation heisst weiter "tesm-license".
|
||||
#
|
||||
# Zwei Fallen des Vorgaengers sind hier vermieden:
|
||||
# * Der Tag "latest" wird ueber die API aufgeloest, nie direkt in die
|
||||
# Download-URL geschrieben. Gitea liefert sonst mit HTTP 200 ein
|
||||
# automatisch erzeugtes Quell-Snapshot statt des echten Release-Anhangs.
|
||||
# * Nach dem Entpacken wird geprueft, dass ein install.sh existiert.
|
||||
BASE="${TESM_RELEASE_BASE:-https://gitea.int.eertmoed.net}"
|
||||
OWNER="${TESM_RELEASE_OWNER:-alientim}"
|
||||
|
||||
echo "==> Entpacke ..."
|
||||
tar -xzf "$WORK/$ASSET" -C "$WORK"
|
||||
# Drei Ebenen, nicht zwei: release.sh packt mit dem Praefix "<name>/", also
|
||||
# liegt das Skript unter <name>/deploy/install.sh. Mit "-maxdepth 2" fand die
|
||||
# Suche nie etwas -- und die Fehlermeldung schob es auf den Download.
|
||||
SRC="$(find "$WORK" -maxdepth 3 -name install.sh -path '*/deploy/*' -printf '%h/..\n' | head -1)"
|
||||
[[ -n "$SRC" ]] || { echo "Im Paket fehlt deploy/install.sh -- Download unbrauchbar." >&2; exit 1; }
|
||||
SRC="$(cd "$SRC" && pwd)"
|
||||
|
||||
echo "==> Pruefe auf CRLF (Windows-Buildmaschinen) ..."
|
||||
if hat_crlf "$SRC/deploy/install.sh"; then
|
||||
echo "Das Paket enthaelt CRLF-Zeilenenden. Beim Paketieren fehlte" >&2
|
||||
echo "'git -c core.autocrlf=false archive'." >&2
|
||||
exit 1
|
||||
# Ein privates Repositorium -- tesm-license-master -- gibt sein Release nur mit
|
||||
# Zugriffstoken heraus. Reihenfolge: Umgebungsvariable, dann eine Datei, die
|
||||
# der Betreiber selbst anlegt. Der Installer legt sie nie an: ob ein Token auf
|
||||
# dem Server liegt, ist keine Entscheidung eines Skripts.
|
||||
TOKEN="${TESM_RELEASE_TOKEN:-}"
|
||||
if [[ -z "$TOKEN" && -r /etc/tesm-release.token ]]; then
|
||||
TOKEN="$(tr -d '[:space:]' < /etc/tesm-release.token)"
|
||||
fi
|
||||
|
||||
read -r -p "Version $TAG fuer $APP${EXTRA[0]+ (${EXTRA[*]})} installieren? [j/N] " answer
|
||||
[[ "$answer" =~ ^[jJyY]$ ]] || exit 0
|
||||
# Ein Wagenruecklauf im Paket macht jedes Shell-Skript auf dem Ziel unbrauchbar
|
||||
# ("bad interpreter"). Die Pruefung darf dabei nicht selbst von einem
|
||||
# Zusatzpaket abhaengen: mit "file" -- das auf einem schlanken Server fehlt --
|
||||
# endete sie in "command not found", grep bekam nichts zu sehen, und das Skript
|
||||
# machte weiter, als waere alles in Ordnung.
|
||||
hat_crlf() {
|
||||
local cr
|
||||
cr="$(printf '\r')"
|
||||
# -U (binaer lesen) ist unter Linux ohne Wirkung, auf einer
|
||||
# Windows-Buildmaschine aber entscheidend: dort entfernt grep den
|
||||
# Wagenruecklauf beim Lesen und findet deshalb nie einen.
|
||||
LC_ALL=C grep -qU "$cr" "$1"
|
||||
}
|
||||
|
||||
exec sudo bash "$SRC/deploy/install.sh" --app "$APP" --yes ${EXTRA[0]+"${EXTRA[@]}"}
|
||||
BOOTSTRAP_WORK=""
|
||||
bootstrap_aufraeumen() {
|
||||
# Den Ausstiegscode zuerst festhalten: alles danach ueberschreibt ihn.
|
||||
local code=$?
|
||||
[[ -n "$BOOTSTRAP_WORK" ]] && rm -rf "$BOOTSTRAP_WORK"
|
||||
# Wer einen eigenen Abschluss braucht -- etwa einen Rueckweg nach einem
|
||||
# Fehlschlag --, definiert "beim_beenden". Ein zweiter "trap ... EXIT" waere
|
||||
# der falsche Weg: er wuerde diesen hier ersetzen, und das entpackte Release
|
||||
# bliebe unter /tmp liegen.
|
||||
declare -F beim_beenden >/dev/null 2>&1 && beim_beenden "$code"
|
||||
return "$code"
|
||||
}
|
||||
trap bootstrap_aufraeumen EXIT
|
||||
|
||||
release_holen() {
|
||||
local repo="${TESM_RELEASE_REPO:-${REPO:-$APP}}" asset
|
||||
# Leeres Array und "set -u" vertragen sich nur mit dieser Schreibweise.
|
||||
local -a kopf=()
|
||||
[[ -n "$TOKEN" ]] && kopf=(-H "Authorization: token $TOKEN")
|
||||
for werkzeug in curl tar python3 find; do
|
||||
command -v "$werkzeug" >/dev/null 2>&1 || {
|
||||
echo "Auf diesem System fehlt $werkzeug -- ohne das geht es nicht." >&2
|
||||
return 1
|
||||
}
|
||||
done
|
||||
BOOTSTRAP_WORK="$(mktemp -d /tmp/tesm-release-XXXXXX)"
|
||||
|
||||
if [[ "$TAG" == "latest" ]]; then
|
||||
echo "==> Ermittle aktuellen Release-Tag ..."
|
||||
TAG="$(curl -fsS ${kopf[0]+"${kopf[@]}"} "$BASE/api/v1/repos/$OWNER/$repo/releases/latest" \
|
||||
| python3 -c 'import json,sys; print(json.load(sys.stdin)["tag_name"])')" || {
|
||||
echo "Kein Release gefunden unter $BASE/$OWNER/$repo." >&2
|
||||
[[ -z "$TOKEN" ]] &&
|
||||
echo "Ist das Repositorium privat? Dann TESM_RELEASE_TOKEN setzen." >&2
|
||||
echo "Andere Quelle: TESM_RELEASE_BASE, TESM_RELEASE_OWNER, TESM_RELEASE_REPO." >&2
|
||||
return 1
|
||||
}
|
||||
echo " $TAG"
|
||||
fi
|
||||
|
||||
asset="$repo-$TAG.tar.gz"
|
||||
echo "==> Lade $asset ..."
|
||||
curl -fsSL ${kopf[0]+"${kopf[@]}"} -o "$BOOTSTRAP_WORK/$asset" \
|
||||
"$BASE/$OWNER/$repo/releases/download/$TAG/$asset" || {
|
||||
echo "Download fehlgeschlagen: $BASE/$OWNER/$repo/releases/download/$TAG/$asset" >&2
|
||||
return 1
|
||||
}
|
||||
|
||||
echo "==> Entpacke ..."
|
||||
tar -xzf "$BOOTSTRAP_WORK/$asset" -C "$BOOTSTRAP_WORK"
|
||||
# Drei Ebenen, nicht zwei: release.sh packt mit dem Praefix "<name>/", also
|
||||
# liegt das Skript unter <name>/deploy/install.sh. Mit "-maxdepth 2" fand die
|
||||
# Suche nie etwas -- und die Fehlermeldung schob es auf den Download.
|
||||
SRC="$(find "$BOOTSTRAP_WORK" -maxdepth 3 -name install.sh -path '*/deploy/*' \
|
||||
-printf '%h/..\n' | head -1)"
|
||||
[[ -n "$SRC" ]] || {
|
||||
echo "Im Paket fehlt deploy/install.sh -- Download unbrauchbar." >&2
|
||||
return 1
|
||||
}
|
||||
SRC="$(cd "$SRC" && pwd)"
|
||||
|
||||
echo "==> Pruefe auf CRLF (Windows-Buildmaschinen) ..."
|
||||
if hat_crlf "$SRC/deploy/install.sh"; then
|
||||
echo "Das Paket enthaelt CRLF-Zeilenenden. Beim Paketieren fehlte" >&2
|
||||
echo "'git -c core.autocrlf=false archive'." >&2
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
# --8<-- Ende Bootstrap ------------------------------------------------------
|
||||
|
||||
release_holen
|
||||
|
||||
# Schon installiert? Dann gar nicht erst fragen.
|
||||
#
|
||||
# Dieselbe Pruefung steht in install.sh und gilt dort endgueltig (sie
|
||||
# vergleicht auch den Kern). Hier steht sie noch einmal, weil ein Update, das
|
||||
# ohne Wirkung bleibt, gar nicht erst nach einer Bestaetigung fragen soll --
|
||||
# und weil "es lief durch" sonst wie eine Aktualisierung aussieht.
|
||||
ERZWINGEN=0
|
||||
for ((i = 0; i < ${#EXTRA[@]}; i++)); do
|
||||
[[ "${EXTRA[i]}" == "--force" ]] && ERZWINGEN=1
|
||||
done
|
||||
INSTALLATION="$APP"
|
||||
for ((i = 0; i < ${#EXTRA[@]}; i++)); do
|
||||
[[ "${EXTRA[i]}" == "--instance" ]] && INSTALLATION="$APP-${EXTRA[i + 1]:-}"
|
||||
done
|
||||
MODUL="tesm"
|
||||
[[ "$APP" == "tesm-license" ]] && MODUL="tesm_license"
|
||||
if [[ $ERZWINGEN -eq 0 && -x "/srv/$INSTALLATION/venv/bin/python" ]]; then
|
||||
LAEUFT="$("/srv/$INSTALLATION/venv/bin/python" -c \
|
||||
"import $MODUL; print($MODUL.__version__)" 2>/dev/null || true)"
|
||||
if [[ -n "$LAEUFT" && "v$LAEUFT" == "$TAG" ]]; then
|
||||
echo "==> $APP $LAEUFT laeuft hier bereits -- nichts zu tun."
|
||||
echo " Trotzdem neu einrichten: --force"
|
||||
exit 0
|
||||
fi
|
||||
fi
|
||||
|
||||
# --yes darf auch hier stehen, nicht nur bei install.sh und upgrade.sh. Es
|
||||
# bleibt in EXTRA: install.sh bekommt es ohnehin, doppelt schadet nicht.
|
||||
OHNE_RUECKFRAGE=0
|
||||
for ((i = 0; i < ${#EXTRA[@]}; i++)); do
|
||||
[[ "${EXTRA[i]}" == "--yes" || "${EXTRA[i]}" == "-y" ]] && OHNE_RUECKFRAGE=1
|
||||
done
|
||||
|
||||
if [[ $OHNE_RUECKFRAGE -eq 1 ]]; then
|
||||
echo "==> Version $TAG wird ohne Rueckfrage installiert."
|
||||
elif [[ -t 0 ]]; then
|
||||
read -r -p "Version $TAG fuer $APP${EXTRA[0]+ (${EXTRA[*]})} installieren? [j/N] " answer
|
||||
[[ "$answer" =~ ^[jJyY]$ ]] || exit 0
|
||||
else
|
||||
# Ohne Terminal las "read" hier EOF, die Antwort blieb leer, und das Skript
|
||||
# endete mit 0 -- wer es aus einem anderen Skript aufrief, hielt das Update
|
||||
# fuer erledigt. Lieber laut abbrechen.
|
||||
echo "Kein Terminal fuer die Rueckfrage, und --yes fehlt -- es wurde nichts" >&2
|
||||
echo "installiert. Aufruf mit --yes wiederholen." >&2
|
||||
exit 3
|
||||
fi
|
||||
|
||||
# Kein exec: sonst laeuft die Aufraeumfalle nie, und jedes Update liesse ein
|
||||
# entpacktes Release unter /tmp liegen.
|
||||
bash "$SRC/deploy/install.sh" --app "$APP" --yes ${EXTRA[0]+"${EXTRA[@]}"}
|
||||
+424
-25
@@ -2,10 +2,17 @@
|
||||
# =============================================================================
|
||||
# Upgrade vom Stand SONNET5 (Version 1.x) auf diese Fassung -- mit Datenuebernahme.
|
||||
#
|
||||
# Aufruf (aus dem entpackten Release-Verzeichnis):
|
||||
# Eine einzelne Datei genuegt -- sie holt sich das Release selbst:
|
||||
#
|
||||
# curl -fsSLO https://gitea.int.eertmoed.net/alientim/tesm/raw/branch/main/deploy/upgrade.sh
|
||||
# sudo bash upgrade.sh --app tesm
|
||||
#
|
||||
# Auf einer 1.2.8 liegt nichts von diesem Projekt; alles andere waere die
|
||||
# falsche Voraussetzung. Liegt das Skript in einem entpackten Release, wird
|
||||
# dessen install.sh benutzt statt eines Downloads:
|
||||
#
|
||||
# sudo bash deploy/upgrade.sh --app tesm
|
||||
# sudo bash deploy/upgrade.sh --app tesm-license
|
||||
# sudo bash deploy/upgrade.sh --app tesm --instance opus --source /srv/tesm
|
||||
# sudo bash deploy/upgrade.sh --app tesm --instance test --source /srv/tesm
|
||||
#
|
||||
# Der Ablauf ist die Vorgabe und braucht keine Schalter:
|
||||
#
|
||||
@@ -44,6 +51,25 @@ DRY_RUN=0
|
||||
WITH_DATA=1
|
||||
STOP_OLD=1
|
||||
ADOPT_KEY=1
|
||||
TAG="latest"
|
||||
ROLLBACK=""
|
||||
|
||||
#: Steht die Sicherung schon, und ist der Lauf durch? Daran entscheidet
|
||||
#: die EXIT-Falle, ob sie zurueckrollt.
|
||||
SICHERUNG_STEHT=0
|
||||
UPGRADE_FERTIG=0
|
||||
#: Namenszusatz, unter dem ``rename.sh`` eine verdraengte Installation der
|
||||
#: Fassung 1.x ablegt. Beide Skripte muessen denselben Wert benutzen; ein
|
||||
#: Hygienetest vergleicht sie.
|
||||
#:
|
||||
#: Auf einer echten 1.2.8 gibt es das **nicht** -- dort liegt die Installation
|
||||
#: unter ihrem Hauptnamen. Der Zusatz entsteht erst, wenn ``rename.sh`` den
|
||||
#: Hauptnamen fuer die neue Fassung braucht. Deshalb ist er der zweite Ort, an
|
||||
#: dem gesucht wird, nicht der erste.
|
||||
ARCHIV_SUFFIX="sonnet5"
|
||||
|
||||
#: Nur fuer die Ausgabe: wurde die Quelle selbst gefunden oder angegeben?
|
||||
info_quelle=""
|
||||
EXTRA=()
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
@@ -73,6 +99,12 @@ Verwendung: upgrade.sh --app <tesm|tesm-license> [Optionen]
|
||||
erzeugen, statt den alten zu uebernehmen. Dann muessen
|
||||
alle Kunden den Ausstellerwechsel bestaetigen -- die
|
||||
Vorgabe ist deshalb die Uebernahme.
|
||||
--tag <vX.Y.Z> Bestimmte Version installieren (Vorgabe: latest).
|
||||
Wird nur beachtet, wenn das Release geladen wird.
|
||||
--rollback [pfad] Einen fehlgeschlagenen Lauf zurueckdrehen: die alte
|
||||
Installation aus der Sicherung wiederherstellen und
|
||||
ihren Dienst wieder einschalten. Ohne Pfad wird die
|
||||
neueste Sicherung dieses Namens genommen.
|
||||
--dry-run Nur zeigen, was geschehen wuerde
|
||||
--yes Keine Rueckfrage
|
||||
-h, --help Diese Hilfe
|
||||
@@ -85,7 +117,7 @@ Beispiele:
|
||||
sudo bash deploy/upgrade.sh --app tesm
|
||||
|
||||
# Daneben, zum Vergleich, alte Installation bleibt unangetastet:
|
||||
sudo bash deploy/upgrade.sh --app tesm --instance opus --keep-old-running
|
||||
sudo bash deploy/upgrade.sh --app tesm --instance test --keep-old-running
|
||||
USAGE
|
||||
}
|
||||
|
||||
@@ -99,6 +131,12 @@ while [[ $# -gt 0 ]]; do
|
||||
--keep-old-running) STOP_OLD=0; shift ;;
|
||||
--new-key) ADOPT_KEY=0; shift ;;
|
||||
--adopt-key) ADOPT_KEY=1; shift ;; # Vorgabe, hier nur zur Deutlichkeit
|
||||
--tag) TAG="${2:?}"; shift 2 ;;
|
||||
--rollback)
|
||||
# Der Pfad ist optional: allein genommen heisst es "die neueste".
|
||||
if [[ -n "${2:-}" && "${2:-}" != --* ]]; then ROLLBACK="$2"; shift 2
|
||||
else ROLLBACK="auto"; shift; fi
|
||||
;;
|
||||
--dry-run) DRY_RUN=1; shift ;;
|
||||
--yes|-y) ASSUME_YES=1; shift ;;
|
||||
-h|--help) usage; exit 0 ;;
|
||||
@@ -108,37 +146,364 @@ done
|
||||
|
||||
[[ "$APP" == "tesm" || "$APP" == "tesm-license" ]] || { usage; fail "--app fehlt oder ist unbekannt"; }
|
||||
[[ "$(id -u)" -eq 0 ]] || fail "bitte mit sudo ausfuehren"
|
||||
[[ -f "$SCRIPT_DIR/install.sh" ]] || fail "install.sh fehlt neben diesem Skript"
|
||||
|
||||
[[ -n "$SOURCE" ]] || SOURCE="/srv/$APP"
|
||||
[[ -d "$SOURCE" ]] || fail "Die alte Installation $SOURCE gibt es nicht (--source setzen)"
|
||||
[[ -f "$SOURCE/sqlite.db" ]] || fail "In $SOURCE liegt keine sqlite.db -- das ist keine Installation der Vorgaengerfassung"
|
||||
|
||||
NAME="$APP"
|
||||
[[ -n "$INSTANCE" ]] && NAME="$APP-$INSTANCE"
|
||||
ADMIN="$NAME-admin"
|
||||
NEW_ROOT="/srv/$NAME"
|
||||
IN_PLACE=0
|
||||
[[ "$(cd "$SOURCE" && pwd)" == "$NEW_ROOT" ]] && IN_PLACE=1
|
||||
|
||||
STAMP="$(date +%Y%m%d-%H%M%S)"
|
||||
BACKUP="/srv/$NAME-upgrade-$STAMP"
|
||||
ALT_KOPIE="$BACKUP/alt"
|
||||
SYSTEM_KOPIE="$BACKUP/system"
|
||||
EXPORT_ALT="$BACKUP/export-alt.json"
|
||||
IMPORT_NEU="$BACKUP/import-neu.json"
|
||||
|
||||
# Alte Einheiten der Vorgaengerfassung. Der Timer gehoert dazu: nur den Dienst
|
||||
# zu stoppen liesse ihn beim naechsten Ablauf wieder anlaufen.
|
||||
if [[ "$APP" == "tesm" ]]; then
|
||||
ALTE_UNITS=(tesm.service tesm-check.timer tesm-check.service
|
||||
tesm-check-restart.timer tesm-check-restart.service)
|
||||
else
|
||||
ALTE_UNITS=(tesm-license.service)
|
||||
# -- Rueckweg ---------------------------------------------------------------- #
|
||||
# Alles, was install.sh unter demselben Namen ueberschreiben wuerde. Bei einem
|
||||
# Upgrade in dasselbe Verzeichnis sind das die Dateien der alten Fassung.
|
||||
SYSTEMDATEIEN=(
|
||||
"/etc/systemd/system/$NAME.service"
|
||||
"/etc/systemd/system/$NAME-monitor.service"
|
||||
"/etc/nginx/sites-available/$NAME"
|
||||
"/etc/logrotate.d/$NAME"
|
||||
"/etc/update-motd.d/50-$NAME"
|
||||
"/etc/tesm/$NAME.env"
|
||||
)
|
||||
|
||||
zurueckrollen() {
|
||||
local sicherung="$1" grund="${2:-}"
|
||||
local alt="$sicherung/alt" system="$sicherung/system"
|
||||
[[ -d "$alt" ]] || fail "In $sicherung fehlt das Verzeichnis 'alt' -- das ist keine Sicherung dieses Skripts."
|
||||
|
||||
# Quelle und Art des Umzugs stehen im Manifest. Aus dem laufenden Aufruf
|
||||
# abzuleiten waere falsch: ein Rueckweg kann Tage spaeter und mit anderen
|
||||
# Schaltern aufgerufen werden -- und beim automatischen Rueckweg steht
|
||||
# IN_PLACE zwar fest, beim spaeteren --rollback aber nicht.
|
||||
local ziel="${SOURCE:-}" gleiches_verzeichnis="${IN_PLACE:-0}" vermerkt
|
||||
if [[ -f "$system/manifest" ]]; then
|
||||
vermerkt="$(awk '$1=="quelle" {print $2}' "$system/manifest" | head -1)"
|
||||
[[ -n "$vermerkt" ]] && ziel="$vermerkt"
|
||||
vermerkt="$(awk '$1=="in-place" {print $2}' "$system/manifest" | head -1)"
|
||||
[[ -n "$vermerkt" ]] && gleiches_verzeichnis="$vermerkt"
|
||||
fi
|
||||
[[ -n "$ziel" ]] || fail "Im Manifest fehlt die Quelle -- bitte --source angeben."
|
||||
|
||||
[[ -n "$grund" ]] && warn "$grund"
|
||||
step "Rueckweg: alten Stand wiederherstellen"
|
||||
|
||||
# Der neue Dienst zuerst -- er haelt sonst Dateien offen, die gleich ersetzt
|
||||
# werden.
|
||||
for unit in "$NAME.service" "$NAME-monitor.service"; do
|
||||
if systemctl list-unit-files "$unit" --no-legend 2>/dev/null | grep -q .; then
|
||||
systemctl disable --now "$unit" >/dev/null 2>&1 || true
|
||||
info "$unit gestoppt"
|
||||
fi
|
||||
done
|
||||
|
||||
# Verzeichnis: nur bei gleichem Ziel -- bei einer eigenen Instanz war die
|
||||
# alte Installation nie angefasst.
|
||||
if [[ "$gleiches_verzeichnis" == "1" ]]; then
|
||||
rm -rf "$ziel"
|
||||
mkdir -p "$ziel"
|
||||
tar -C "$alt" -cf - . | tar -C "$ziel" -xf -
|
||||
info "$ziel aus der Sicherung wiederhergestellt"
|
||||
[[ -d "$ziel/venv" ]] || warn "Im Backup war kein venv -- der alte Dienst braucht dann noch 'python3 -m venv venv' und seine requirements.txt."
|
||||
else
|
||||
info "$ziel war nicht betroffen (eigene Instanz) -- bleibt, wie es ist"
|
||||
if [[ -d "$NEW_ROOT" && "$NEW_ROOT" != "$ziel" ]]; then
|
||||
rm -rf "$NEW_ROOT"
|
||||
info "$NEW_ROOT entfernt"
|
||||
fi
|
||||
fi
|
||||
|
||||
# Systemdateien zurueck, alles andere entfernen, was der Lauf angelegt hat.
|
||||
local site_wieder=0
|
||||
if [[ -f "$system/manifest" ]]; then
|
||||
while read -r zeile; do
|
||||
case "$zeile" in
|
||||
unit-enabled\ *|site-enabled\ *) continue ;;
|
||||
esac
|
||||
local name; name="$(basename "$zeile")"
|
||||
if [[ -e "$system/$name" ]]; then
|
||||
cp -a "$system/$name" "$zeile"
|
||||
info "wiederhergestellt: $zeile"
|
||||
[[ "$zeile" == /etc/nginx/* ]] && site_wieder=1
|
||||
fi
|
||||
done < <(grep -v '^\(unit\|site\)-enabled ' "$system/manifest" || true)
|
||||
fi
|
||||
|
||||
systemctl daemon-reload
|
||||
if [[ $site_wieder -eq 1 ]] || [[ -e "/etc/nginx/sites-enabled/$NAME" ]]; then
|
||||
nginx -t >/dev/null 2>&1 && systemctl reload nginx >/dev/null 2>&1 \
|
||||
&& info "nginx neu geladen" || warn "nginx meldet einen Fehler -- bitte 'nginx -t' ansehen"
|
||||
fi
|
||||
|
||||
# Und die Dienste wieder einschalten, die vorher liefen.
|
||||
if [[ -f "$system/manifest" ]]; then
|
||||
while read -r _ unit; do
|
||||
systemctl enable --now "$unit" >/dev/null 2>&1 \
|
||||
&& info "$unit wieder eingeschaltet" || warn "$unit liess sich nicht starten"
|
||||
done < <(grep '^unit-enabled ' "$system/manifest" || true)
|
||||
fi
|
||||
|
||||
step "Zurueckgerollt"
|
||||
info "Sicherung bleibt liegen: $sicherung"
|
||||
# Nicht ueber ALTE_UNITS: die Liste entsteht erst nach der Quellensuche, und
|
||||
# bei "--rollback" laeuft diese Funktion vorher. Das Manifest weiss es.
|
||||
local hinweis
|
||||
hinweis="$(awk '$1=="unit-enabled" {print $2; exit}' "$system/manifest" 2>/dev/null || true)"
|
||||
info "Zustand pruefen: systemctl status ${hinweis:-$APP.service}"
|
||||
}
|
||||
|
||||
# Wird von der EXIT-Falle des Bootstraps aufgerufen -- mit dem Ausstiegscode.
|
||||
# Damit gilt der Rueckweg fuer **jeden** Fehlschlag und nicht nur fuer den, an
|
||||
# den ich gedacht habe: Export, Uebersetzen, Einspielen, nginx, ein Ctrl-C.
|
||||
beim_beenden() {
|
||||
local code="${1:-0}"
|
||||
[[ "$code" == "0" ]] && return 0
|
||||
[[ $UPGRADE_FERTIG -eq 1 ]] && return 0
|
||||
if [[ $SICHERUNG_STEHT -eq 0 ]]; then
|
||||
warn "Abgebrochen (Code $code), bevor eine Sicherung stand -- nichts zurueckzurollen."
|
||||
return 0
|
||||
fi
|
||||
zurueckrollen "$BACKUP" "Abgebrochen (Code $code)."
|
||||
}
|
||||
|
||||
neueste_sicherung() {
|
||||
local kandidat
|
||||
kandidat="$(ls -1dt /srv/"$NAME"-upgrade-*/ 2>/dev/null | head -1)"
|
||||
[[ -n "$kandidat" ]] || return 1
|
||||
printf '%s' "${kandidat%/}"
|
||||
}
|
||||
|
||||
if [[ -n "$ROLLBACK" ]]; then
|
||||
if [[ "$ROLLBACK" == "auto" ]]; then
|
||||
ROLLBACK="$(neueste_sicherung)" || fail "Keine Sicherung unter /srv/$NAME-upgrade-* gefunden. Pfad angeben."
|
||||
info "Neueste Sicherung: $ROLLBACK"
|
||||
fi
|
||||
[[ -d "$ROLLBACK" ]] || fail "$ROLLBACK gibt es nicht"
|
||||
if [[ $DRY_RUN -eq 1 ]]; then
|
||||
step "--dry-run: es wuerde $ROLLBACK zurueckgerollt."
|
||||
exit 0
|
||||
fi
|
||||
if [[ $ASSUME_YES -eq 0 ]]; then
|
||||
read -r -p "Stand aus $ROLLBACK wiederherstellen? [j/N] " answer
|
||||
[[ "$answer" =~ ^[jJyY]$ ]] || { echo "Abgebrochen."; exit 0; }
|
||||
fi
|
||||
zurueckrollen "$ROLLBACK"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Ab hier wird wirklich installiert -- ein Rueckweg hat oben schon
|
||||
# beendet. Erst jetzt braucht es ein Release und eine Quelle.
|
||||
|
||||
# --8<-- Bootstrap: Release holen --------------------------------------------
|
||||
# Wortgleich in update.sh und upgrade.sh; ein Hygienetest vergleicht die beiden
|
||||
# Bloecke. Zwei eigenstaendige Skripte koennen sich keine Datei teilen, und
|
||||
# beide sollen als **einzelne Datei** genuegen: auf das Zielsystem legen,
|
||||
# ausfuehren, fertig -- auf einer 1.2.8 liegt nichts von diesem Projekt.
|
||||
#
|
||||
# Erwartet: $APP und $TAG, wahlweise $REPO (Vorgabe: $APP). Setzt: $TAG
|
||||
# (aufgeloest) und $SRC (entpacktes Release). Raeumt sein Arbeitsverzeichnis
|
||||
# beim Beenden auf.
|
||||
#
|
||||
# $REPO gibt es, weil Installationsname und Repositorium auseinanderfallen
|
||||
# koennen: die Master-Ausgabe des Lizenzservers liegt in
|
||||
# "tesm-license-master", die Installation heisst weiter "tesm-license".
|
||||
#
|
||||
# Zwei Fallen des Vorgaengers sind hier vermieden:
|
||||
# * Der Tag "latest" wird ueber die API aufgeloest, nie direkt in die
|
||||
# Download-URL geschrieben. Gitea liefert sonst mit HTTP 200 ein
|
||||
# automatisch erzeugtes Quell-Snapshot statt des echten Release-Anhangs.
|
||||
# * Nach dem Entpacken wird geprueft, dass ein install.sh existiert.
|
||||
BASE="${TESM_RELEASE_BASE:-https://gitea.int.eertmoed.net}"
|
||||
OWNER="${TESM_RELEASE_OWNER:-alientim}"
|
||||
|
||||
# Ein privates Repositorium -- tesm-license-master -- gibt sein Release nur mit
|
||||
# Zugriffstoken heraus. Reihenfolge: Umgebungsvariable, dann eine Datei, die
|
||||
# der Betreiber selbst anlegt. Der Installer legt sie nie an: ob ein Token auf
|
||||
# dem Server liegt, ist keine Entscheidung eines Skripts.
|
||||
TOKEN="${TESM_RELEASE_TOKEN:-}"
|
||||
if [[ -z "$TOKEN" && -r /etc/tesm-release.token ]]; then
|
||||
TOKEN="$(tr -d '[:space:]' < /etc/tesm-release.token)"
|
||||
fi
|
||||
|
||||
# Ein Wagenruecklauf im Paket macht jedes Shell-Skript auf dem Ziel unbrauchbar
|
||||
# ("bad interpreter"). Die Pruefung darf dabei nicht selbst von einem
|
||||
# Zusatzpaket abhaengen: mit "file" -- das auf einem schlanken Server fehlt --
|
||||
# endete sie in "command not found", grep bekam nichts zu sehen, und das Skript
|
||||
# machte weiter, als waere alles in Ordnung.
|
||||
hat_crlf() {
|
||||
local cr
|
||||
cr="$(printf '\r')"
|
||||
# -U (binaer lesen) ist unter Linux ohne Wirkung, auf einer
|
||||
# Windows-Buildmaschine aber entscheidend: dort entfernt grep den
|
||||
# Wagenruecklauf beim Lesen und findet deshalb nie einen.
|
||||
LC_ALL=C grep -qU "$cr" "$1"
|
||||
}
|
||||
|
||||
BOOTSTRAP_WORK=""
|
||||
bootstrap_aufraeumen() {
|
||||
# Den Ausstiegscode zuerst festhalten: alles danach ueberschreibt ihn.
|
||||
local code=$?
|
||||
[[ -n "$BOOTSTRAP_WORK" ]] && rm -rf "$BOOTSTRAP_WORK"
|
||||
# Wer einen eigenen Abschluss braucht -- etwa einen Rueckweg nach einem
|
||||
# Fehlschlag --, definiert "beim_beenden". Ein zweiter "trap ... EXIT" waere
|
||||
# der falsche Weg: er wuerde diesen hier ersetzen, und das entpackte Release
|
||||
# bliebe unter /tmp liegen.
|
||||
declare -F beim_beenden >/dev/null 2>&1 && beim_beenden "$code"
|
||||
return "$code"
|
||||
}
|
||||
trap bootstrap_aufraeumen EXIT
|
||||
|
||||
release_holen() {
|
||||
local repo="${TESM_RELEASE_REPO:-${REPO:-$APP}}" asset
|
||||
# Leeres Array und "set -u" vertragen sich nur mit dieser Schreibweise.
|
||||
local -a kopf=()
|
||||
[[ -n "$TOKEN" ]] && kopf=(-H "Authorization: token $TOKEN")
|
||||
for werkzeug in curl tar python3 find; do
|
||||
command -v "$werkzeug" >/dev/null 2>&1 || {
|
||||
echo "Auf diesem System fehlt $werkzeug -- ohne das geht es nicht." >&2
|
||||
return 1
|
||||
}
|
||||
done
|
||||
BOOTSTRAP_WORK="$(mktemp -d /tmp/tesm-release-XXXXXX)"
|
||||
|
||||
if [[ "$TAG" == "latest" ]]; then
|
||||
echo "==> Ermittle aktuellen Release-Tag ..."
|
||||
TAG="$(curl -fsS ${kopf[0]+"${kopf[@]}"} "$BASE/api/v1/repos/$OWNER/$repo/releases/latest" \
|
||||
| python3 -c 'import json,sys; print(json.load(sys.stdin)["tag_name"])')" || {
|
||||
echo "Kein Release gefunden unter $BASE/$OWNER/$repo." >&2
|
||||
[[ -z "$TOKEN" ]] &&
|
||||
echo "Ist das Repositorium privat? Dann TESM_RELEASE_TOKEN setzen." >&2
|
||||
echo "Andere Quelle: TESM_RELEASE_BASE, TESM_RELEASE_OWNER, TESM_RELEASE_REPO." >&2
|
||||
return 1
|
||||
}
|
||||
echo " $TAG"
|
||||
fi
|
||||
|
||||
asset="$repo-$TAG.tar.gz"
|
||||
echo "==> Lade $asset ..."
|
||||
curl -fsSL ${kopf[0]+"${kopf[@]}"} -o "$BOOTSTRAP_WORK/$asset" \
|
||||
"$BASE/$OWNER/$repo/releases/download/$TAG/$asset" || {
|
||||
echo "Download fehlgeschlagen: $BASE/$OWNER/$repo/releases/download/$TAG/$asset" >&2
|
||||
return 1
|
||||
}
|
||||
|
||||
echo "==> Entpacke ..."
|
||||
tar -xzf "$BOOTSTRAP_WORK/$asset" -C "$BOOTSTRAP_WORK"
|
||||
# Drei Ebenen, nicht zwei: release.sh packt mit dem Praefix "<name>/", also
|
||||
# liegt das Skript unter <name>/deploy/install.sh. Mit "-maxdepth 2" fand die
|
||||
# Suche nie etwas -- und die Fehlermeldung schob es auf den Download.
|
||||
SRC="$(find "$BOOTSTRAP_WORK" -maxdepth 3 -name install.sh -path '*/deploy/*' \
|
||||
-printf '%h/..\n' | head -1)"
|
||||
[[ -n "$SRC" ]] || {
|
||||
echo "Im Paket fehlt deploy/install.sh -- Download unbrauchbar." >&2
|
||||
return 1
|
||||
}
|
||||
SRC="$(cd "$SRC" && pwd)"
|
||||
|
||||
echo "==> Pruefe auf CRLF (Windows-Buildmaschinen) ..."
|
||||
if hat_crlf "$SRC/deploy/install.sh"; then
|
||||
echo "Das Paket enthaelt CRLF-Zeilenenden. Beim Paketieren fehlte" >&2
|
||||
echo "'git -c core.autocrlf=false archive'." >&2
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
# --8<-- Ende Bootstrap ------------------------------------------------------
|
||||
|
||||
# Woher kommt install.sh? Liegt dieses Skript in einem entpackten Release,
|
||||
# nimm dessen Verzeichnis -- wer ein Paket ausgepackt hat, will nicht, dass
|
||||
# ungefragt ein anderes geladen wird. Sonst hol das Release.
|
||||
if [[ -f "$SCRIPT_DIR/install.sh" ]]; then
|
||||
SRC="$REPO_DIR"
|
||||
RELEASE_HERKUNFT="$SRC (entpacktes Release neben diesem Skript)"
|
||||
else
|
||||
release_holen || fail "Das Release liess sich nicht holen."
|
||||
RELEASE_HERKUNFT="$TAG von $BASE/$OWNER"
|
||||
fi
|
||||
|
||||
# -- Wo liegt die alte Fassung? ---------------------------------------------- #
|
||||
# Zwei Orte kommen in Frage, und der zweite ist der Grund fuer diesen Block:
|
||||
# ``rename.sh`` schiebt eine Installation der Fassung 1.x nach
|
||||
# ``/srv/<app>-sonnet5``, sobald der Hauptname fuer die neue gebraucht wird.
|
||||
# Danach liegt unter ``/srv/<app>`` die **laufende** Installation, und die
|
||||
# Vorgabe zeigte ins Leere. Erkannt wird an ``sqlite.db`` im Wurzelverzeichnis
|
||||
# -- die neue Fassung legt ihre Datenbank unter ``data/app.db`` ab.
|
||||
ist_alte_fassung() { [[ -f "$1/sqlite.db" ]]; }
|
||||
|
||||
if [[ -z "$SOURCE" ]]; then
|
||||
for kandidat in "/srv/$APP" "/srv/$APP-$ARCHIV_SUFFIX"; do
|
||||
if ist_alte_fassung "$kandidat"; then SOURCE="$kandidat"; break; fi
|
||||
done
|
||||
if [[ -z "$SOURCE" ]]; then
|
||||
warn "Keine Installation der Fassung 1.x gefunden. Gesucht wurde in:"
|
||||
warn " /srv/$APP (dort liegt sie normalerweise)"
|
||||
warn " /srv/$APP-$ARCHIV_SUFFIX (dorthin verschiebt rename.sh sie, falls"
|
||||
warn " der Hauptname schon vergeben war)"
|
||||
[[ -f "/srv/$APP/data/app.db" ]] && warn "Unter /srv/$APP liegt die laufende Installation dieser Fassung."
|
||||
fail "Bitte --source setzen."
|
||||
fi
|
||||
info_quelle="gefunden: $SOURCE"
|
||||
fi
|
||||
[[ -d "$SOURCE" ]] || fail "Die alte Installation $SOURCE gibt es nicht (--source setzen)"
|
||||
ist_alte_fassung "$SOURCE" || fail "In $SOURCE liegt keine sqlite.db -- das ist keine Installation der Vorgaengerfassung"
|
||||
|
||||
IN_PLACE=0
|
||||
[[ "$(cd "$SOURCE" && pwd)" == "$NEW_ROOT" ]] && IN_PLACE=1
|
||||
|
||||
# Die Units der alten Fassung erkennt man daran, dass sie in genau dieses
|
||||
# Verzeichnis zeigen -- nicht am Namen der Anwendung. Nach einem rename.sh
|
||||
# heisst die *laufende* Installation "tesm", und eine Namensliste haette sie
|
||||
# gestoppt und deaktiviert, sobald jemand die archivierte 1.x in eine eigene
|
||||
# Instanz hochzieht.
|
||||
#
|
||||
# Timer und Hilfsdienste tragen keinen Pfad (der Restart-Timer ruft nur
|
||||
# "systemctl restart ..."). Sie kommen ueber den Namen der gefundenen Unit mit.
|
||||
alte_units_finden() {
|
||||
local quelle="$1" gefunden=() datei name runde
|
||||
shopt -s nullglob
|
||||
for datei in /etc/systemd/system/*.service; do
|
||||
grep -qs -- "$quelle/" "$datei" && gefunden+=("$(basename "$datei")")
|
||||
done
|
||||
# Zwei Runden: erst Timer und Helfer, die eine gefundene Unit nennen, dann
|
||||
# noch einmal fuer deren eigene Timer.
|
||||
for runde in 1 2; do
|
||||
for datei in /etc/systemd/system/*.service /etc/systemd/system/*.timer; do
|
||||
name="$(basename "$datei")"
|
||||
[[ " ${gefunden[*]-} " == *" $name "* ]] && continue
|
||||
for treffer in ${gefunden[*]-}; do
|
||||
if grep -qs -- "$treffer" "$datei"; then gefunden+=("$name"); break; fi
|
||||
done
|
||||
done
|
||||
done
|
||||
shopt -u nullglob
|
||||
printf '%s\n' ${gefunden[*]-}
|
||||
}
|
||||
|
||||
mapfile -t ALTE_UNITS < <(alte_units_finden "$SOURCE")
|
||||
if [[ ${#ALTE_UNITS[@]} -eq 0 ]]; then
|
||||
if [[ "$SOURCE" == "/srv/$APP" ]]; then
|
||||
# Kein Fund, aber die Quelle ist der Hauptpfad: die historischen Namen der
|
||||
# Fassung 1.x als Rueckfall.
|
||||
if [[ "$APP" == "tesm" ]]; then
|
||||
ALTE_UNITS=(tesm.service tesm-check.timer tesm-check.service
|
||||
tesm-check-restart.timer tesm-check-restart.service)
|
||||
else
|
||||
ALTE_UNITS=(tesm-license.service)
|
||||
fi
|
||||
else
|
||||
ALTE_UNITS=()
|
||||
fi
|
||||
fi
|
||||
|
||||
|
||||
step "Vorhaben"
|
||||
info "Anwendung : $APP${INSTANCE:+ (Instanz $INSTANCE)}"
|
||||
info "Alt : $SOURCE"
|
||||
info "Neue Fassung : $RELEASE_HERKUNFT"
|
||||
info "Alt : $SOURCE${info_quelle:+ (automatisch gefunden)}"
|
||||
info "Neu : $NEW_ROOT$([[ $IN_PLACE -eq 1 ]] && echo ' << dasselbe Verzeichnis')"
|
||||
info "Sicherung : $ALT_KOPIE"
|
||||
info "Datenuebernahme: $([[ $WITH_DATA -eq 1 ]] && echo ja || echo nein)"
|
||||
@@ -160,15 +525,45 @@ fi
|
||||
|
||||
# -- 1. Sichern -------------------------------------------------------------- #
|
||||
step "Sichern"
|
||||
mkdir -p "$ALT_KOPIE"
|
||||
mkdir -p "$ALT_KOPIE" "$SYSTEM_KOPIE"
|
||||
chmod 700 "$BACKUP"
|
||||
# -a: Rechte und Zeiten bleiben, damit die Kopie auch als Rueckweg taugt.
|
||||
# Das venv der alten Installation bleibt aussen vor -- es ist nachbaubar,
|
||||
# gross, und seine Shebangs zeigen ohnehin auf den alten Pfad.
|
||||
tar -C "$SOURCE" --exclude=venv --exclude=__pycache__ -cf - . | tar -C "$ALT_KOPIE" -xf -
|
||||
info "$(du -sh "$ALT_KOPIE" | cut -f1) kopiert (ohne venv)"
|
||||
|
||||
# Das venv kommt nur mit, wenn in dasselbe Verzeichnis installiert wird -- dort
|
||||
# ersetzt install.sh es, und ohne das alte startet der alte Dienst nach einem
|
||||
# Rueckweg nicht mehr. Bei einer eigenen Instanz bleibt die alte Installation
|
||||
# unangetastet, dann waere es nur Ballast (und seine Shebangs zeigen auf einen
|
||||
# anderen Pfad).
|
||||
if [[ $IN_PLACE -eq 1 ]]; then
|
||||
tar -C "$SOURCE" --exclude=__pycache__ -cf - . | tar -C "$ALT_KOPIE" -xf -
|
||||
info "$(du -sh "$ALT_KOPIE" | cut -f1) kopiert (mit venv, weil dasselbe Verzeichnis)"
|
||||
else
|
||||
tar -C "$SOURCE" --exclude=venv --exclude=__pycache__ -cf - . | tar -C "$ALT_KOPIE" -xf -
|
||||
info "$(du -sh "$ALT_KOPIE" | cut -f1) kopiert (ohne venv)"
|
||||
fi
|
||||
[[ -f "$ALT_KOPIE/sqlite.db" ]] || fail "In der Kopie fehlt die Datenbank -- Abbruch"
|
||||
|
||||
# Und die Systemdateien: bei gleichem Verzeichnis schreibt install.sh Unit,
|
||||
# nginx-Site, logrotate und Banner neu. Ohne Kopie liesse sich der alte Stand
|
||||
# nicht wiederherstellen -- das Verzeichnis allein genuegt nicht.
|
||||
: > "$SYSTEM_KOPIE/manifest"
|
||||
# Woher kam der Stand, und ging es in dasselbe Verzeichnis? Ein spaeterer
|
||||
# Rueckweg darf das nicht raten muessen -- er laeuft womoeglich Tage danach
|
||||
# und mit anderen Schaltern.
|
||||
printf 'quelle %s\n' "$SOURCE" >> "$SYSTEM_KOPIE/manifest"
|
||||
printf 'in-place %s\n' "$IN_PLACE" >> "$SYSTEM_KOPIE/manifest"
|
||||
for pfad in "${SYSTEMDATEIEN[@]}"; do
|
||||
[[ -e "$pfad" ]] || continue
|
||||
cp -a "$pfad" "$SYSTEM_KOPIE/$(basename "$pfad")"
|
||||
printf '%s\n' "$pfad" >> "$SYSTEM_KOPIE/manifest"
|
||||
done
|
||||
# Welche Units liefen, und war die Site eingeschaltet?
|
||||
for unit in "${ALTE_UNITS[@]}"; do
|
||||
systemctl is-enabled "$unit" >/dev/null 2>&1 && printf 'unit-enabled %s\n' "$unit" >> "$SYSTEM_KOPIE/manifest"
|
||||
done
|
||||
[[ -L "/etc/nginx/sites-enabled/$NAME" ]] && printf 'site-enabled %s\n' "$NAME" >> "$SYSTEM_KOPIE/manifest"
|
||||
info "$(grep -c . "$SYSTEM_KOPIE/manifest") Systemdatei(en) und Zustaende vermerkt"
|
||||
SICHERUNG_STEHT=1
|
||||
|
||||
# -- 2. Altes System abschalten ---------------------------------------------- #
|
||||
if [[ $STOP_OLD -eq 1 ]]; then
|
||||
step "Altes System abschalten"
|
||||
@@ -186,9 +581,12 @@ fi
|
||||
step "Neu installieren"
|
||||
INSTALL_ARGS=(--app "$APP" --yes)
|
||||
[[ -n "$INSTANCE" ]] && INSTALL_ARGS+=(--instance "$INSTANCE")
|
||||
bash "$SCRIPT_DIR/install.sh" "${INSTALL_ARGS[@]}" ${EXTRA[0]+"${EXTRA[@]}"}
|
||||
# Kein eigener Fehlerzweig mehr: die EXIT-Falle rollt bei jedem Fehlschlag
|
||||
# zurueck -- hier, beim Export, beim Einspielen oder bei einem Ctrl-C.
|
||||
bash "$SRC/deploy/install.sh" "${INSTALL_ARGS[@]}" ${EXTRA[0]+"${EXTRA[@]}"}
|
||||
|
||||
if [[ $WITH_DATA -eq 0 ]]; then
|
||||
UPGRADE_FERTIG=1
|
||||
step "Fertig -- ohne Datenuebernahme"
|
||||
info "Sicherung der alten Installation: $ALT_KOPIE"
|
||||
exit 0
|
||||
@@ -240,6 +638,7 @@ else
|
||||
fi
|
||||
|
||||
# -- Abschluss --------------------------------------------------------------- #
|
||||
UPGRADE_FERTIG=1
|
||||
step "Fertig"
|
||||
info "Sicherung der alten Installation : $ALT_KOPIE"
|
||||
if [[ "$APP" == "tesm" ]]; then
|
||||
|
||||
+65
-10
@@ -105,19 +105,34 @@ Eine bereits eingerichtete HTTPS-Konfiguration bleibt erhalten, auch ohne
|
||||
|
||||
### Ohne Quellbaum: `update.sh`
|
||||
|
||||
Liegt kein entpacktes Release vor, holt `update.sh` es selbst -- es lädt das
|
||||
Release-Asset, entpackt es und ruft dessen `install.sh` auf:
|
||||
`update.sh` genügt als **einzelne Datei**. Auf einem System, auf dem noch nichts
|
||||
von diesem Projekt liegt:
|
||||
|
||||
```bash
|
||||
sudo bash /opt/tesm/deploy/update.sh tesm
|
||||
sudo bash /opt/tesm-license/deploy/update.sh tesm-license
|
||||
curl -fsSLO https://gitea.int.eertmoed.net/alientim/tesm/raw/branch/main/deploy/update.sh
|
||||
sudo bash update.sh tesm
|
||||
```
|
||||
|
||||
Auf einer bestehenden Installation liegt sie schon bereit:
|
||||
|
||||
```bash
|
||||
sudo bash /srv/tesm/deploy/update.sh tesm
|
||||
sudo bash /srv/tesm-license/deploy/update.sh tesm-license
|
||||
```
|
||||
|
||||
**Läuft die Fassung schon, passiert nichts.** `update.sh` vergleicht den
|
||||
aufgelösten Tag mit dem, was installiert ist, und endet mit `0`, bevor es eine
|
||||
Rückfrage stellt; `install.sh` prüft es noch einmal genauer (Anwendung **und**
|
||||
Kern) und bricht vor dem ersten Eingriff ab. Ein zweiter Lauf auf dieselbe
|
||||
Fassung baut sonst die Python-Umgebung neu, kopiert dieselben Dateien und
|
||||
startet den Dienst neu -- ein paar Sekunden Ausfall ohne jede Wirkung. Wer das
|
||||
trotzdem will (eine beschädigte Installation etwa), hängt `--force` an.
|
||||
|
||||
Alles nach dem Tag geht unverändert an `install.sh` weiter -- das ist der Weg,
|
||||
eine **benannte Instanz** zu aktualisieren:
|
||||
|
||||
```bash
|
||||
sudo bash /opt/tesm-opus/deploy/update.sh tesm latest --instance opus
|
||||
sudo bash /srv/tesm/deploy/update.sh tesm
|
||||
```
|
||||
|
||||
Ohne `--instance` entstünde daneben eine zweite Installation namens `tesm`, mit
|
||||
@@ -147,11 +162,51 @@ Ein eigenes Skript, weil dabei mehr passiert als ein Dateiabgleich: die alte
|
||||
Fassung hat ein anderes Schema, andere Rechteschlüssel und ein anderes
|
||||
Exportformat.
|
||||
|
||||
Auch `upgrade.sh` genügt als **einzelne Datei** -- und das ist hier der
|
||||
Regelfall: auf einer 1.2.8 liegt nichts von dieser Fassung.
|
||||
|
||||
```bash
|
||||
curl -fsSLO https://gitea.int.eertmoed.net/alientim/tesm/raw/branch/main/deploy/upgrade.sh
|
||||
sudo bash upgrade.sh --app tesm
|
||||
```
|
||||
|
||||
Sie lädt das aktuelle Release, entpackt es und benutzt dessen `install.sh`.
|
||||
Liegt das Skript in einem entpackten Release, wird dieses benutzt statt eines
|
||||
Downloads -- wer ein Paket ausgepackt hat, will nicht, dass ungefragt ein
|
||||
anderes geladen wird:
|
||||
|
||||
```bash
|
||||
sudo bash deploy/upgrade.sh --app tesm
|
||||
sudo bash deploy/upgrade.sh --app tesm-license
|
||||
```
|
||||
|
||||
Eine bestimmte Version statt `latest`: `--tag v2.0.8`.
|
||||
|
||||
**Wenn etwas schiefgeht.** Schlägt `install.sh` fehl, dreht `upgrade.sh` den
|
||||
Lauf selbst zurück: der neue Dienst wird gestoppt, das Verzeichnis aus der
|
||||
Sicherung wiederhergestellt und der alte Dienst wieder eingeschaltet. Das
|
||||
Verzeichnis allein genügt dafür nicht -- bei einem Upgrade in dasselbe
|
||||
Verzeichnis schreibt `install.sh` auch systemd-Unit, nginx-Site, logrotate und
|
||||
Banner neu und ersetzt das venv. Die Sicherung enthält deshalb alles davon,
|
||||
und ein Manifest hält fest, woher der Stand kam.
|
||||
|
||||
Später von Hand zurückdrehen, etwa nach einem Lauf, der durchlief aber nicht
|
||||
gefallen hat:
|
||||
|
||||
```bash
|
||||
sudo bash upgrade.sh --app tesm --rollback # neueste Sicherung
|
||||
sudo bash upgrade.sh --app tesm --rollback /srv/tesm-upgrade-20260908-225428
|
||||
```
|
||||
|
||||
Die Sicherung bleibt danach liegen; gelöscht wird nichts.
|
||||
|
||||
Ohne `--source` wird die alte Fassung an zwei Stellen gesucht: unter
|
||||
`/srv/<app>` und unter `/srv/<app>-sonnet5`. Der zweite Ort ist der, an den
|
||||
`rename.sh` sie verschiebt, sobald der Hauptname für die neue Installation
|
||||
gebraucht wird -- danach liegt unter `/srv/<app>` die **laufende** Installation.
|
||||
Erkannt wird eine 1.x an der `sqlite.db` im Wurzelverzeichnis; die neue Fassung
|
||||
legt ihre Datenbank unter `data/app.db` ab.
|
||||
|
||||
Der Ablauf ist die Vorgabe und braucht keine Schalter:
|
||||
|
||||
| | |
|
||||
@@ -177,7 +232,7 @@ Export des Vorgängers und taugt deshalb auch als Rückweg.
|
||||
und die alte bleibt vollständig liegen:
|
||||
|
||||
```bash
|
||||
sudo bash deploy/upgrade.sh --app tesm --instance opus --keep-old-running --port 5100 --http-port 8080
|
||||
sudo bash deploy/upgrade.sh --app tesm --instance test --keep-old-running --port 5100 --http-port 8080
|
||||
```
|
||||
|
||||
**Was nicht mitkommt** -- und warum:
|
||||
@@ -273,7 +328,7 @@ die niemand mehr füllte.
|
||||
Von Hand prüfen:
|
||||
|
||||
```bash
|
||||
sudo /usr/local/lib/tesm/tesm-motd --app tesm --root /srv/tesm-opus
|
||||
sudo /usr/local/lib/tesm/tesm-motd --app tesm --root /srv/tesm-test
|
||||
```
|
||||
|
||||
Der Aufruf im Banner ist mit `timeout 3` und `|| true` abgesichert: ein
|
||||
@@ -288,8 +343,8 @@ entfernt -- nur dieses, nichts anderes in dem Verzeichnis.
|
||||
Aus einer Instanz die Hauptinstallation machen (oder umgekehrt):
|
||||
|
||||
```bash
|
||||
sudo bash deploy/rename.sh --app tesm --from tesm-opus --to tesm
|
||||
sudo bash deploy/rename.sh --app tesm-license --from tesm-license-opus --to tesm-license
|
||||
sudo bash deploy/rename.sh --app tesm --from tesm-test --to tesm
|
||||
sudo bash deploy/rename.sh --app tesm-license --from tesm-license-test --to tesm-license
|
||||
```
|
||||
|
||||
Der Name steckt an mehr Stellen als im Verzeichnisnamen, und drei davon sind
|
||||
@@ -399,7 +454,7 @@ Unverzichtbar:
|
||||
**Die Anwendung antwortet nicht.**
|
||||
```bash
|
||||
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.
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@ Was geschützt wird, womit, und wo bewusst Grenzen gezogen sind.
|
||||
| Speicherung | Argon2id (argon2-cffi) |
|
||||
| Altbestand | bcrypt wird noch geprüft und bei der nächsten erfolgreichen Anmeldung stillschweigend auf Argon2id umgestellt |
|
||||
| Zweiter Faktor | TOTP, optional erzwingbar für Administratoren |
|
||||
| Rechte | Eine Zeile je Funktion; das Administratorkennzeichen gibt den Verwaltungsbereich, nicht alles |
|
||||
| Sperre je Konto | nach `LOGIN_MAX_ATTEMPTS` Versuchen |
|
||||
| Sperre je Adresse | zusätzlich `LOGIN_MAX_ATTEMPTS_PER_IP` -- sonst lässt sich die Kontosperre umgehen, indem man viele Konten je einmal probiert |
|
||||
| Verzeichnisdienst | LDAP nur über TLS, Zertifikat wird geprüft. Ohne gültiges Zertifikat keine Anmeldung -- kein Schalter, der das abschaltet |
|
||||
@@ -171,6 +172,10 @@ braucht der Dienst kein `--workers 1` mehr.
|
||||
* **Kein CSRF-Freibrief für cookie-autorisierte Routen.**
|
||||
* **Kein `unsafe-inline` für Skripte**, auch nicht "vorübergehend".
|
||||
* **Keine Rechteausweitung**, auch nicht für Administratoren untereinander.
|
||||
Ein echter Administrator darf sich Funktionsrechte über eine Gruppe geben --
|
||||
das ist gewollt und hinterlässt eine Zeile im Änderungsprotokoll. Was es
|
||||
nicht gibt, ist der stille Weg: ein Kennzeichen, das ohne Eintrag alles
|
||||
aufschließt.
|
||||
* **Kein Hostname im Lizenz-Fingerabdruck** -- Umbenennen darf die Bindung
|
||||
nicht brechen.
|
||||
* **Keine von Hand gepflegte nginx-Site.** Sie wird immer neu erzeugt.
|
||||
|
||||
+181
@@ -0,0 +1,181 @@
|
||||
#!/usr/bin/env bash
|
||||
# =============================================================================
|
||||
# Installation -- der kurze Weg.
|
||||
#
|
||||
# Diese Datei liegt oben im Paket und ruft den Installer unter deploy/ auf:
|
||||
#
|
||||
# tar xzf tesm-vX.Y.Z.tar.gz && cd tesm
|
||||
# sudo bash install.sh --app tesm --https
|
||||
#
|
||||
# Sie genuegt aber auch **allein**: liegt kein Paket um sie herum, holt sie
|
||||
# sich das aktuelle Release selbst.
|
||||
#
|
||||
# curl -fsSLO https://gitea.int.eertmoed.net/alientim/tesm/raw/branch/main/install.sh
|
||||
# sudo bash install.sh --app tesm --https
|
||||
#
|
||||
# Die Master-Ausgabe des Lizenzservers liegt im privaten Repositorium
|
||||
# tesm-license-master. Allein-Betrieb dafuer:
|
||||
#
|
||||
# export TESM_RELEASE_REPO=tesm-license-master
|
||||
# export TESM_RELEASE_TOKEN=<Gitea-Token>
|
||||
# sudo -E bash install.sh --app tesm-license --https
|
||||
#
|
||||
# Aus einem entpackten Paket braucht es das nicht: dort entscheidet, welches
|
||||
# Paket es ist. Die Master-Ausgabe legt beim Installieren Signaturschluessel
|
||||
# und eigene Master-Lizenz selbst an.
|
||||
#
|
||||
# Alle Argumente gehen unveraendert an deploy/install.sh weiter; dessen Hilfe:
|
||||
#
|
||||
# bash install.sh --help
|
||||
# =============================================================================
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
|
||||
# --app wird nur gelesen, nicht verbraucht: der Bootstrap braucht es fuer den
|
||||
# Namen des Repositoriums, der Installer bekommt die Argumente unveraendert.
|
||||
APP=""
|
||||
TAG="latest"
|
||||
ARGUMENTE=("$@")
|
||||
for ((i = 0; i < $#; i++)); do
|
||||
case "${ARGUMENTE[i]}" in
|
||||
--app) APP="${ARGUMENTE[i + 1]:-}" ;;
|
||||
--tag) TAG="${ARGUMENTE[i + 1]:-latest}" ;;
|
||||
esac
|
||||
done
|
||||
|
||||
# Ohne Paket drumherum brauchen wir den Namen, um das richtige Release zu
|
||||
# finden -- die beiden Anwendungen liegen in getrennten Repositorien.
|
||||
if [[ ! -f "$SCRIPT_DIR/deploy/install.sh" ]]; then
|
||||
[[ "$APP" == "tesm" || "$APP" == "tesm-license" ]] || {
|
||||
echo "Diese Datei liegt allein hier und muss sich das Release holen --" >&2
|
||||
echo "dafuer braucht sie --app <tesm|tesm-license>." >&2
|
||||
echo "Verwendung: install.sh --app <tesm|tesm-license> [weitere Optionen]" >&2
|
||||
exit 2
|
||||
}
|
||||
fi
|
||||
|
||||
# --8<-- Bootstrap: Release holen --------------------------------------------
|
||||
# Wortgleich in update.sh und upgrade.sh; ein Hygienetest vergleicht die beiden
|
||||
# Bloecke. Zwei eigenstaendige Skripte koennen sich keine Datei teilen, und
|
||||
# beide sollen als **einzelne Datei** genuegen: auf das Zielsystem legen,
|
||||
# ausfuehren, fertig -- auf einer 1.2.8 liegt nichts von diesem Projekt.
|
||||
#
|
||||
# Erwartet: $APP und $TAG, wahlweise $REPO (Vorgabe: $APP). Setzt: $TAG
|
||||
# (aufgeloest) und $SRC (entpacktes Release). Raeumt sein Arbeitsverzeichnis
|
||||
# beim Beenden auf.
|
||||
#
|
||||
# $REPO gibt es, weil Installationsname und Repositorium auseinanderfallen
|
||||
# koennen: die Master-Ausgabe des Lizenzservers liegt in
|
||||
# "tesm-license-master", die Installation heisst weiter "tesm-license".
|
||||
#
|
||||
# Zwei Fallen des Vorgaengers sind hier vermieden:
|
||||
# * Der Tag "latest" wird ueber die API aufgeloest, nie direkt in die
|
||||
# Download-URL geschrieben. Gitea liefert sonst mit HTTP 200 ein
|
||||
# automatisch erzeugtes Quell-Snapshot statt des echten Release-Anhangs.
|
||||
# * Nach dem Entpacken wird geprueft, dass ein install.sh existiert.
|
||||
BASE="${TESM_RELEASE_BASE:-https://gitea.int.eertmoed.net}"
|
||||
OWNER="${TESM_RELEASE_OWNER:-alientim}"
|
||||
|
||||
# Ein privates Repositorium -- tesm-license-master -- gibt sein Release nur mit
|
||||
# Zugriffstoken heraus. Reihenfolge: Umgebungsvariable, dann eine Datei, die
|
||||
# der Betreiber selbst anlegt. Der Installer legt sie nie an: ob ein Token auf
|
||||
# dem Server liegt, ist keine Entscheidung eines Skripts.
|
||||
TOKEN="${TESM_RELEASE_TOKEN:-}"
|
||||
if [[ -z "$TOKEN" && -r /etc/tesm-release.token ]]; then
|
||||
TOKEN="$(tr -d '[:space:]' < /etc/tesm-release.token)"
|
||||
fi
|
||||
|
||||
# Ein Wagenruecklauf im Paket macht jedes Shell-Skript auf dem Ziel unbrauchbar
|
||||
# ("bad interpreter"). Die Pruefung darf dabei nicht selbst von einem
|
||||
# Zusatzpaket abhaengen: mit "file" -- das auf einem schlanken Server fehlt --
|
||||
# endete sie in "command not found", grep bekam nichts zu sehen, und das Skript
|
||||
# machte weiter, als waere alles in Ordnung.
|
||||
hat_crlf() {
|
||||
local cr
|
||||
cr="$(printf '\r')"
|
||||
# -U (binaer lesen) ist unter Linux ohne Wirkung, auf einer
|
||||
# Windows-Buildmaschine aber entscheidend: dort entfernt grep den
|
||||
# Wagenruecklauf beim Lesen und findet deshalb nie einen.
|
||||
LC_ALL=C grep -qU "$cr" "$1"
|
||||
}
|
||||
|
||||
BOOTSTRAP_WORK=""
|
||||
bootstrap_aufraeumen() {
|
||||
# Den Ausstiegscode zuerst festhalten: alles danach ueberschreibt ihn.
|
||||
local code=$?
|
||||
[[ -n "$BOOTSTRAP_WORK" ]] && rm -rf "$BOOTSTRAP_WORK"
|
||||
# Wer einen eigenen Abschluss braucht -- etwa einen Rueckweg nach einem
|
||||
# Fehlschlag --, definiert "beim_beenden". Ein zweiter "trap ... EXIT" waere
|
||||
# der falsche Weg: er wuerde diesen hier ersetzen, und das entpackte Release
|
||||
# bliebe unter /tmp liegen.
|
||||
declare -F beim_beenden >/dev/null 2>&1 && beim_beenden "$code"
|
||||
return "$code"
|
||||
}
|
||||
trap bootstrap_aufraeumen EXIT
|
||||
|
||||
release_holen() {
|
||||
local repo="${TESM_RELEASE_REPO:-${REPO:-$APP}}" asset
|
||||
# Leeres Array und "set -u" vertragen sich nur mit dieser Schreibweise.
|
||||
local -a kopf=()
|
||||
[[ -n "$TOKEN" ]] && kopf=(-H "Authorization: token $TOKEN")
|
||||
for werkzeug in curl tar python3 find; do
|
||||
command -v "$werkzeug" >/dev/null 2>&1 || {
|
||||
echo "Auf diesem System fehlt $werkzeug -- ohne das geht es nicht." >&2
|
||||
return 1
|
||||
}
|
||||
done
|
||||
BOOTSTRAP_WORK="$(mktemp -d /tmp/tesm-release-XXXXXX)"
|
||||
|
||||
if [[ "$TAG" == "latest" ]]; then
|
||||
echo "==> Ermittle aktuellen Release-Tag ..."
|
||||
TAG="$(curl -fsS ${kopf[0]+"${kopf[@]}"} "$BASE/api/v1/repos/$OWNER/$repo/releases/latest" \
|
||||
| python3 -c 'import json,sys; print(json.load(sys.stdin)["tag_name"])')" || {
|
||||
echo "Kein Release gefunden unter $BASE/$OWNER/$repo." >&2
|
||||
[[ -z "$TOKEN" ]] &&
|
||||
echo "Ist das Repositorium privat? Dann TESM_RELEASE_TOKEN setzen." >&2
|
||||
echo "Andere Quelle: TESM_RELEASE_BASE, TESM_RELEASE_OWNER, TESM_RELEASE_REPO." >&2
|
||||
return 1
|
||||
}
|
||||
echo " $TAG"
|
||||
fi
|
||||
|
||||
asset="$repo-$TAG.tar.gz"
|
||||
echo "==> Lade $asset ..."
|
||||
curl -fsSL ${kopf[0]+"${kopf[@]}"} -o "$BOOTSTRAP_WORK/$asset" \
|
||||
"$BASE/$OWNER/$repo/releases/download/$TAG/$asset" || {
|
||||
echo "Download fehlgeschlagen: $BASE/$OWNER/$repo/releases/download/$TAG/$asset" >&2
|
||||
return 1
|
||||
}
|
||||
|
||||
echo "==> Entpacke ..."
|
||||
tar -xzf "$BOOTSTRAP_WORK/$asset" -C "$BOOTSTRAP_WORK"
|
||||
# Drei Ebenen, nicht zwei: release.sh packt mit dem Praefix "<name>/", also
|
||||
# liegt das Skript unter <name>/deploy/install.sh. Mit "-maxdepth 2" fand die
|
||||
# Suche nie etwas -- und die Fehlermeldung schob es auf den Download.
|
||||
SRC="$(find "$BOOTSTRAP_WORK" -maxdepth 3 -name install.sh -path '*/deploy/*' \
|
||||
-printf '%h/..\n' | head -1)"
|
||||
[[ -n "$SRC" ]] || {
|
||||
echo "Im Paket fehlt deploy/install.sh -- Download unbrauchbar." >&2
|
||||
return 1
|
||||
}
|
||||
SRC="$(cd "$SRC" && pwd)"
|
||||
|
||||
echo "==> Pruefe auf CRLF (Windows-Buildmaschinen) ..."
|
||||
if hat_crlf "$SRC/deploy/install.sh"; then
|
||||
echo "Das Paket enthaelt CRLF-Zeilenenden. Beim Paketieren fehlte" >&2
|
||||
echo "'git -c core.autocrlf=false archive'." >&2
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
# --8<-- Ende Bootstrap ------------------------------------------------------
|
||||
|
||||
if [[ -f "$SCRIPT_DIR/deploy/install.sh" ]]; then
|
||||
# Im Paket: der Installer liegt daneben.
|
||||
exec bash "$SCRIPT_DIR/deploy/install.sh" ${ARGUMENTE[0]+"${ARGUMENTE[@]}"}
|
||||
fi
|
||||
|
||||
# Allein: Release holen und dessen Installer benutzen. Kein exec -- sonst
|
||||
# laeuft die Aufraeumfalle nie und das entpackte Release bliebe unter /tmp.
|
||||
release_holen || { echo "Das Release liess sich nicht holen." >&2; exit 1; }
|
||||
bash "$SRC/deploy/install.sh" ${ARGUMENTE[0]+"${ARGUMENTE[@]}"}
|
||||
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "tesm-core"
|
||||
version = "2.0.7"
|
||||
version = "2.8.17"
|
||||
description = "Gemeinsames Anwendungsgeruest (Auth, RBAC, Navigation, Design-System, Migrationen) fuer TESM und TESM-Lizenzserver"
|
||||
requires-python = ">=3.11"
|
||||
dependencies = [
|
||||
|
||||
@@ -13,10 +13,12 @@ import secrets
|
||||
from datetime import datetime, timezone
|
||||
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 . import audit, logging_setup, ui
|
||||
from . import audit, legacy_urls, logging_setup, ui
|
||||
from .config import CoreConfig
|
||||
from .core_schema import CORE_MIGRATIONS
|
||||
from .db import Database
|
||||
@@ -31,6 +33,7 @@ from .security import csrf
|
||||
from .security import sessions as session_store
|
||||
from .security.sessions import SqliteSessionInterface
|
||||
from .settings_store import SettingSpec, SettingsStore
|
||||
from . import uploads
|
||||
from .sysops import SysOps
|
||||
from .auth import directory
|
||||
from .auth import routes as auth_routes
|
||||
@@ -56,18 +59,29 @@ CORE_SETTINGS: tuple[SettingSpec, ...] = (
|
||||
30,
|
||||
"int",
|
||||
"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",
|
||||
minimum=1,
|
||||
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(
|
||||
"session_idle_minutes",
|
||||
30,
|
||||
"int",
|
||||
"Automatische Abmeldung nach Leerlauf (Minuten)",
|
||||
hint="Jede Anfrage setzt den Zaehler zurueck -- gezaehlt wird nur Untaetigkeit. "
|
||||
"Gilt auch fuer die eingebundenen Dateifreigaben: mit der Sitzung endet die "
|
||||
hint="Jede Anfrage setzt den Zähler zurück – gezählt wird nur Untätigkeit. "
|
||||
"Gilt auch für die eingebundenen Dateifreigaben: mit der Sitzung endet die "
|
||||
"Einbindung.",
|
||||
section="system",
|
||||
minimum=5,
|
||||
@@ -88,6 +102,14 @@ CORE_SETTINGS: tuple[SettingSpec, ...] = (
|
||||
),
|
||||
# -- Webserver/TLS (Host-Ebene, in beiden Anwendungen gleich) ----------- #
|
||||
# Lizenzserver: eigenes Zertifikat statt abgeschalteter Pruefung.
|
||||
SettingSpec(
|
||||
"modules_disabled",
|
||||
[],
|
||||
"json",
|
||||
"Vor Ort abgeschaltete Module",
|
||||
hint="Die Lizenz sagt, was erlaubt ist; das hier sagt, was benutzt wird.",
|
||||
section="system",
|
||||
),
|
||||
SettingSpec(
|
||||
"license_server_ca",
|
||||
"",
|
||||
@@ -100,8 +122,8 @@ CORE_SETTINGS: tuple[SettingSpec, ...] = (
|
||||
SettingSpec("web_hsts_enabled", False, "bool", "HSTS senden", 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_key_path", "", "str", "Privater Schluessel (Pfad)", section="web"),
|
||||
SettingSpec("web_acme_email", "", "str", "E-Mail fuer Let's Encrypt", section="web"),
|
||||
SettingSpec("web_key_path", "", "str", "Privater Schlüssel (Pfad)", 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"),
|
||||
# Abweichende Ports braucht nur eine Testinstanz neben der Produktion --
|
||||
# im Regelbetrieb bleibt es bei 80 und 443.
|
||||
@@ -112,7 +134,7 @@ CORE_SETTINGS: tuple[SettingSpec, ...] = (
|
||||
"web_https_port", 443, "int", "HTTPS-Port", section="web", minimum=1, maximum=65535
|
||||
),
|
||||
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,
|
||||
),
|
||||
*directory.SETTINGS,
|
||||
@@ -134,7 +156,11 @@ def create_base_app(
|
||||
#: Wortmarke, ohne Zeile darunter; die Marke selbst zeigen beide
|
||||
#: Anwendungen.
|
||||
auth_tagline: str = "",
|
||||
app_version: str = "2.0.7",
|
||||
#: Beide Anwendungen geben ihre eigene Paketversion mit -- und die laeuft
|
||||
#: absichtlich auseinander: was sich nicht geaendert hat, bekommt keine
|
||||
#: neue Nummer. Eine feste Zahl an dieser Stelle wuerde irgendwann eine
|
||||
#: falsche Version in die Fussnote schreiben.
|
||||
app_version: str = "unbekannt",
|
||||
built_in_trusted_keys: tuple[str, ...] = (),
|
||||
enable_license: bool = True,
|
||||
template_folder: str | None = None,
|
||||
@@ -146,6 +172,10 @@ def create_base_app(
|
||||
static_folder=static_folder or "static",
|
||||
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
|
||||
# werden ueber einen eigenen Blueprint bereitgestellt. Dadurch kann jede
|
||||
@@ -163,6 +193,10 @@ def create_base_app(
|
||||
config.data_dir.mkdir(parents=True, exist_ok=True)
|
||||
config.log_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Die Groessengrenze einer Anfrage kommt aus der Einstellung, nicht aus
|
||||
# der Konfiguration beim Start.
|
||||
app.request_class = uploads.UploadLimitRequest
|
||||
|
||||
app.config.update(
|
||||
SECRET_KEY=load_or_create_secret_key(config.secret_key_file),
|
||||
# site_key statt app_key: Cookies unterscheiden keine Ports. Zwei
|
||||
@@ -172,6 +206,9 @@ def create_base_app(
|
||||
SESSION_COOKIE_HTTPONLY=True,
|
||||
SESSION_COOKIE_SECURE=config.security.cookie_secure,
|
||||
SESSION_COOKIE_SAMESITE=config.security.cookie_samesite,
|
||||
# Nur die aeussere Schranke aus der Umgebung. Die eigentliche Grenze
|
||||
# steht in der Einstellung und wird pro Anfrage gelesen (uploads.py) --
|
||||
# sonst braeuchte jede Aenderung einen Neustart aller Arbeiter.
|
||||
MAX_CONTENT_LENGTH=config.security.max_content_length,
|
||||
JSON_SORT_KEYS=False,
|
||||
TRAP_HTTP_EXCEPTIONS=False,
|
||||
@@ -222,12 +259,21 @@ def create_base_app(
|
||||
wert = 0
|
||||
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(
|
||||
database,
|
||||
cookie_name=app.config["SESSION_COOKIE_NAME"],
|
||||
idle_minutes=config.security.session_idle_minutes,
|
||||
absolute_hours=config.security.session_absolute_hours,
|
||||
idle_lookup=_leerlauf,
|
||||
absolute_lookup=_gesamtdauer,
|
||||
)
|
||||
|
||||
# -- Schema und Startzustand ------------------------------------------ #
|
||||
@@ -328,6 +374,26 @@ def create_base_app(
|
||||
# -- Fehlerseiten ------------------------------------------------------ #
|
||||
@app.errorhandler(HTTPException)
|
||||
def _http_error(error: HTTPException): # type: ignore[no-untyped-def]
|
||||
if error.code == 404:
|
||||
# Vor der Fehlerseite: war das eine Adresse aus der Zeit vor 2.5.0?
|
||||
# Erst hier gefragt, damit die Tabelle nie eine bestehende Route
|
||||
# verdeckt -- auf dem Lizenzserver ist /lizenz/<ticket> echt.
|
||||
ziel = legacy_urls.neue_adresse(request.path)
|
||||
if ziel is not None:
|
||||
parameter = legacy_urls.neue_parameter(request.args.to_dict(flat=True))
|
||||
if parameter:
|
||||
ziel = ziel + "?" + urlencode(parameter)
|
||||
return redirect(ziel)
|
||||
if error.code == 413:
|
||||
# "Request Entity Too Large" allein hilft niemandem weiter: die
|
||||
# Zahl steht in einer Einstellung, und wer hochlaedt, soll sie
|
||||
# sehen. Vorher stand hier nur der nackte Status.
|
||||
grenze = uploads.limit_mb()
|
||||
error.description = (
|
||||
f"Diese Datei ist größer als erlaubt. Zurzeit sind {grenze} MB je Upload "
|
||||
"eingestellt; ändern kann das ein Administrator unter "
|
||||
'"Einstellungen -> Webserver und TLS".'
|
||||
)
|
||||
if _wants_json():
|
||||
return {
|
||||
"error": error.name,
|
||||
@@ -381,7 +447,7 @@ def create_base_app(
|
||||
with app.app_context():
|
||||
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
|
||||
|
||||
|
||||
@@ -28,7 +28,7 @@ import sqlite3
|
||||
from collections import defaultdict
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any, Iterable
|
||||
from typing import Any, Collection, Iterable, Mapping, Sequence
|
||||
|
||||
from .db import Database
|
||||
|
||||
@@ -116,6 +116,49 @@ def log(
|
||||
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]:
|
||||
"""Prueft die Hash-Kette. Rueckgabe: ``{"ok", "checked", "broken_at", "reason"}``."""
|
||||
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,
|
||||
"checked": checked,
|
||||
"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"]:
|
||||
return {
|
||||
"ok": False,
|
||||
"checked": checked,
|
||||
"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"])
|
||||
checked += 1
|
||||
|
||||
@@ -26,6 +26,7 @@ import re
|
||||
import socket
|
||||
import sqlite3
|
||||
import ssl
|
||||
import time
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any, Literal
|
||||
@@ -59,20 +60,20 @@ SETTINGS: tuple[SettingSpec, ...] = (
|
||||
section="ldap",
|
||||
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_allow_insecure",
|
||||
False,
|
||||
"bool",
|
||||
"Unverschluesselte Verbindung ausdruecklich erlauben",
|
||||
hint="Nur fuer Testumgebungen. Das Passwort geht dabei im Klartext ueber das Netz.",
|
||||
"Unverschlüsselte Verbindung ausdrücklich erlauben",
|
||||
hint="Nur für Testumgebungen. Das Passwort geht dabei im Klartext über das Netz.",
|
||||
section="ldap",
|
||||
),
|
||||
SettingSpec("ldap_base_dn", "", "str", "Basis-DN (leer = automatisch)", section="ldap"),
|
||||
SettingSpec("ldap_user_attribute", "sAMAccountName", "str", "Anmeldeattribut", 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),
|
||||
)
|
||||
|
||||
@@ -123,7 +124,7 @@ def bind_credentials(conn: sqlite3.Connection) -> tuple[str, str]:
|
||||
try:
|
||||
secret = core().keystore.decrypt(encrypted, aad=AAD_LDAP_BIND)
|
||||
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 ""), secret
|
||||
|
||||
@@ -162,19 +163,82 @@ def _tls_object(config: dict[str, Any]):
|
||||
|
||||
|
||||
def _server(config: dict[str, Any]):
|
||||
"""Das Server-Objekt -- so sparsam wie moeglich.
|
||||
|
||||
``get_info`` entscheidet, was ldap3 beim Verbinden **zusaetzlich** holt.
|
||||
Mit ``ALL`` ist das die Beschreibung des Verzeichnisses *und sein ganzes
|
||||
Schema*: bei einem Active Directory sind das Tausende Attribut- und
|
||||
Klassendefinitionen, mehrere Megabyte, bei jeder Verbindung neu. Fuer eine
|
||||
Anmeldung braucht davon nichts -- gelesen wird hoechstens der
|
||||
``defaultNamingContext``, wenn kein Basis-DN eingetragen ist, und dafuer
|
||||
genuegt ``DSA`` (nur die Serverinformation, ein Datensatz).
|
||||
|
||||
Liegt der Domain-Controller in einem anderen Netz, ist genau das der
|
||||
Unterschied zwischen "geht sofort" und "dauert manchmal".
|
||||
"""
|
||||
from ldap3 import Server
|
||||
|
||||
security = str(config["ldap_security"])
|
||||
braucht_basis = not str(config.get("ldap_base_dn") or "").strip()
|
||||
return Server(
|
||||
str(config["ldap_host"]),
|
||||
port=int(config["ldap_port"]),
|
||||
use_ssl=security == "ldaps",
|
||||
tls=_tls_object(config) if security in {"ldaps", "starttls"} else None,
|
||||
get_info="ALL",
|
||||
get_info="DSA" if braucht_basis else "NO_INFO",
|
||||
connect_timeout=int(config["ldap_timeout"]),
|
||||
)
|
||||
|
||||
|
||||
#: Fehler, die nur "gerade jetzt nicht" bedeuten -- nicht "falsches Passwort".
|
||||
#:
|
||||
#: Die Liste stammt aus der Fassung 1.2.8, wo dieses Problem schon einmal
|
||||
#: geloest war. Der Kommentar dort beschreibt es genauer, als ich es koennte:
|
||||
#: ein Domain-Controller in einem anderen, langsameren Netzsegment -- die
|
||||
#: allererste Verbindung dorthin ist oft spuerbar langsamer als jede folgende,
|
||||
#: und der erste Login schlug mit "Benutzer/Passwort ungueltig" fehl, waehrend
|
||||
#: ein sofortiger zweiter Versuch klappte.
|
||||
TRANSIENTE_FEHLER: tuple[type[Exception], ...] = ()
|
||||
|
||||
|
||||
def _transiente_fehler() -> tuple[type[Exception], ...]:
|
||||
"""Die Ausnahmeklassen von ldap3, die einen Wiederholversuch verdienen."""
|
||||
global TRANSIENTE_FEHLER
|
||||
if TRANSIENTE_FEHLER:
|
||||
return TRANSIENTE_FEHLER
|
||||
from ldap3.core.exceptions import (
|
||||
LDAPResponseTimeoutError,
|
||||
LDAPSocketOpenError,
|
||||
LDAPSocketReceiveError,
|
||||
LDAPSocketSendError,
|
||||
)
|
||||
|
||||
TRANSIENTE_FEHLER = (
|
||||
LDAPSocketOpenError,
|
||||
LDAPSocketReceiveError,
|
||||
LDAPSocketSendError,
|
||||
LDAPResponseTimeoutError,
|
||||
)
|
||||
return TRANSIENTE_FEHLER
|
||||
|
||||
|
||||
def mit_wiederholung(aufbau: Any) -> Any:
|
||||
"""Genau ein zweiter Versuch -- und nur bei einem Netzfehler.
|
||||
|
||||
Nicht bei einer abgelehnten Anmeldung: ein Tippfehler im Passwort wuerde
|
||||
sonst den Sperrzaehler des Kontos zweimal statt einmal hochzaehlen. Das
|
||||
ist die Regel aus 1.2.8, und sie ist richtig.
|
||||
"""
|
||||
try:
|
||||
return aufbau()
|
||||
except _transiente_fehler() as fehler:
|
||||
logger.info(
|
||||
"Verzeichnisdienst antwortete nicht (%s) -- ein zweiter Versuch",
|
||||
type(fehler).__name__,
|
||||
)
|
||||
return aufbau()
|
||||
|
||||
|
||||
def _connect(config: dict[str, Any], user: str, password: str):
|
||||
"""Baut eine Verbindung auf und bindet. Wirft bei Netz-/TLS-Problemen."""
|
||||
from ldap3 import Connection
|
||||
@@ -182,7 +246,7 @@ def _connect(config: dict[str, Any], user: str, password: str):
|
||||
security = str(config["ldap_security"])
|
||||
if security == "none" and not config["ldap_allow_insecure"]:
|
||||
raise PermissionError(
|
||||
"Unverschluesselte Verzeichnisverbindungen sind deaktiviert. "
|
||||
"Unverschlüsselte Verzeichnisverbindungen sind deaktiviert. "
|
||||
"Nutzen Sie LDAPS oder StartTLS."
|
||||
)
|
||||
connection = Connection(
|
||||
@@ -192,6 +256,12 @@ def _connect(config: dict[str, Any], user: str, password: str):
|
||||
auto_bind=False,
|
||||
raise_exceptions=False,
|
||||
receive_timeout=int(config["ldap_timeout"]),
|
||||
# Ein Active Directory antwortet auf eine Suche ab der Domaenenwurzel
|
||||
# mit Verweisen auf andere Controller. ldap3 folgt ihnen von sich aus
|
||||
# -- und baut dafuer neue Verbindungen auf, womoeglich in ein anderes
|
||||
# Netz. Fuer eine Anmeldung ist das nicht nur unnoetig, sondern die
|
||||
# Quelle genau der Wartezeiten, die niemand erklaeren kann.
|
||||
auto_referrals=False,
|
||||
)
|
||||
if security == "starttls":
|
||||
if not connection.open():
|
||||
@@ -249,12 +319,23 @@ def authenticate(conn: sqlite3.Connection, username: str, password: str) -> Dire
|
||||
bind_user, bind_password = bind_credentials(conn)
|
||||
warnings: list[str] = []
|
||||
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"]:
|
||||
warnings.append("Serverzertifikat des Verzeichnisdienstes wird nicht geprueft")
|
||||
warnings.append("Serverzertifikat des Verzeichnisdienstes wird nicht geprüft")
|
||||
|
||||
begonnen = time.monotonic()
|
||||
zeiten: list[str] = []
|
||||
|
||||
def gemessen(name: str, seit: float) -> float:
|
||||
"""Haelt eine Stufe mit ihrer Dauer fest und gibt die neue Marke."""
|
||||
jetzt = time.monotonic()
|
||||
zeiten.append(f"{name} {int((jetzt - seit) * 1000)}ms")
|
||||
return jetzt
|
||||
|
||||
try:
|
||||
service_conn, bound = _connect(config, bind_user, bind_password)
|
||||
service_conn, bound = mit_wiederholung(
|
||||
lambda: _connect(config, bind_user, bind_password)
|
||||
)
|
||||
except (PermissionError, ConnectionError) as exc:
|
||||
logger.error("Verzeichnisdienst: %s", exc)
|
||||
return DirectoryResult("unavailable", message=str(exc), warnings=tuple(warnings))
|
||||
@@ -271,8 +352,10 @@ def authenticate(conn: sqlite3.Connection, username: str, password: str) -> Dire
|
||||
warnings=tuple(warnings),
|
||||
)
|
||||
|
||||
marke = gemessen("Dienstbindung", begonnen)
|
||||
try:
|
||||
base = _base_dn(service_conn, config)
|
||||
marke = gemessen("Basis-DN", marke)
|
||||
if not base:
|
||||
return DirectoryResult(
|
||||
"unavailable", message="Basis-DN konnte nicht ermittelt werden.", warnings=tuple(warnings)
|
||||
@@ -288,8 +371,10 @@ def authenticate(conn: sqlite3.Connection, username: str, password: str) -> Dire
|
||||
attributes=list(dict.fromkeys(wanted)),
|
||||
size_limit=2,
|
||||
)
|
||||
marke = gemessen("Benutzersuche", marke)
|
||||
entries = list(service_conn.entries)
|
||||
if len(entries) != 1:
|
||||
logger.info("Verzeichnisanmeldung %s: %s", username, ", ".join(zeiten))
|
||||
return DirectoryResult("invalid", warnings=tuple(warnings))
|
||||
|
||||
entry = entries[0]
|
||||
@@ -300,9 +385,18 @@ def authenticate(conn: sqlite3.Connection, username: str, password: str) -> Dire
|
||||
|
||||
# Zweite, unabhaengige Verbindung mit den Benutzerdaten -- die eigentliche
|
||||
# Passwortpruefung. Niemals ueber die Dienstkonto-Verbindung rebinden.
|
||||
user_conn, user_bound = _connect(config, dn, password)
|
||||
#
|
||||
# Mit Wiederholversuch, und genau hier zeigte sich das Symptom: bei
|
||||
# einem Domain-Controller in einem anderen Netz schlug der erste
|
||||
# Versuch mit "Benutzer/Passwort ungueltig" fehl, der zweite gleich
|
||||
# darauf klappte. Wiederholt wird nur bei einem Netzfehler -- eine
|
||||
# abgelehnte Anmeldung bleibt abgelehnt, sonst zaehlt ein Tippfehler
|
||||
# den Sperrzaehler des Kontos zweimal hoch.
|
||||
user_conn, user_bound = mit_wiederholung(lambda: _connect(config, dn, password))
|
||||
marke = gemessen("Passwortpruefung", marke)
|
||||
try:
|
||||
if not user_bound:
|
||||
logger.info("Verzeichnisanmeldung %s: %s", username, ", ".join(zeiten))
|
||||
return DirectoryResult("invalid", warnings=tuple(warnings))
|
||||
finally:
|
||||
try:
|
||||
@@ -313,6 +407,7 @@ def authenticate(conn: sqlite3.Connection, username: str, password: str) -> Dire
|
||||
group_dns: tuple[str, ...] = ()
|
||||
if config["ldap_group_sync"]:
|
||||
group_dns = _resolve_groups(service_conn, base, dn)
|
||||
marke = gemessen("Gruppen", marke)
|
||||
|
||||
profile = DirectoryProfile(
|
||||
username=_attr(entry, attribute) or username,
|
||||
@@ -322,6 +417,15 @@ def authenticate(conn: sqlite3.Connection, username: str, password: str) -> Dire
|
||||
email=_attr(entry, email_attribute),
|
||||
group_dns=group_dns,
|
||||
)
|
||||
# Eine Zeile je Anmeldung mit den Dauern der Stufen. Aus "dauert
|
||||
# manchmal laenger" wird damit eine Zahl -- und man sieht, ob es die
|
||||
# Verbindung ist, die Suche oder die Gruppen.
|
||||
logger.info(
|
||||
"Verzeichnisanmeldung %s: %s, gesamt %dms",
|
||||
username,
|
||||
", ".join(zeiten),
|
||||
int((time.monotonic() - begonnen) * 1000),
|
||||
)
|
||||
return DirectoryResult("ok", profile=profile, warnings=tuple(warnings))
|
||||
finally:
|
||||
try:
|
||||
@@ -359,7 +463,7 @@ def _resolve_groups(connection: Any, base: str, user_dn: str) -> tuple[str, ...]
|
||||
)
|
||||
return tuple(str(entry.entry_dn) for entry in connection.entries)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
logger.warning("Gruppenaufloesung fehlgeschlagen: %s", exc)
|
||||
logger.warning("Gruppenauflösung fehlgeschlagen: %s", exc)
|
||||
return ()
|
||||
|
||||
|
||||
@@ -423,18 +527,24 @@ def _apply_group_mappings(
|
||||
zugewiesene Gruppen bleiben erhalten, wenn sie kein Verzeichnis-Mapping
|
||||
haben -- sonst waere jede lokale Zuweisung nach dem naechsten Login weg.
|
||||
"""
|
||||
if not group_dns:
|
||||
return
|
||||
mappings = Database.all(
|
||||
conn, "SELECT ad_group_dn, app_group_id FROM ldap_group_mappings"
|
||||
conn, "SELECT ad_group_dn, app_group_id, grants_admin FROM ldap_group_mappings"
|
||||
)
|
||||
if not mappings:
|
||||
return
|
||||
mapped_group_ids = {int(row["app_group_id"]) for row in mappings}
|
||||
lowered = {dn.lower() for dn in group_dns}
|
||||
# Das Kennzeichen zuerst -- es haengt an keiner Gruppe und muss auch dann
|
||||
# ausgewertet werden, wenn das Konto in keiner zugeordneten Gruppe steht.
|
||||
_kennzeichen_setzen(conn, user_id, mappings, lowered)
|
||||
if not group_dns:
|
||||
return
|
||||
gruppenzuordnungen = [row for row in mappings if row["app_group_id"] is not None]
|
||||
if not gruppenzuordnungen:
|
||||
return
|
||||
mapped_group_ids = {int(row["app_group_id"]) for row in gruppenzuordnungen}
|
||||
wanted = {
|
||||
int(row["app_group_id"])
|
||||
for row in mappings
|
||||
for row in gruppenzuordnungen
|
||||
if str(row["ad_group_dn"]).lower() in lowered
|
||||
}
|
||||
|
||||
@@ -452,6 +562,71 @@ def _apply_group_mappings(
|
||||
)
|
||||
|
||||
|
||||
def _kennzeichen_setzen(
|
||||
conn: sqlite3.Connection,
|
||||
user_id: int,
|
||||
mappings: list[dict[str, Any]],
|
||||
gruppen: set[str],
|
||||
) -> None:
|
||||
"""Setzt oder entzieht das Administratorkennzeichen nach dem Verzeichnis.
|
||||
|
||||
Wirkung wie bei Gruppen: **ersetzend**. Wer aus der zugeordneten
|
||||
AD-Gruppe herausfaellt, verliert das Kennzeichen bei der naechsten
|
||||
Anmeldung -- sonst waere ein Austritt im Verzeichnis eine Aenderung, die
|
||||
hier nie ankommt.
|
||||
|
||||
Zwei Grenzen, beide absichtlich:
|
||||
|
||||
* Nur **Verzeichniskonten**. Ein lokales Konto (etwa das, das
|
||||
``tesm-admin create-admin`` anlegt) bleibt unberuehrt -- es ist der
|
||||
Notausgang, wenn das Verzeichnis nicht erreichbar ist.
|
||||
* Nie den letzten entsperrten Administrator herunterstufen. Ein
|
||||
Tippfehler im DN wuerde sonst beim naechsten Login die Anlage
|
||||
verschliessen, und niemand koennte ihn korrigieren.
|
||||
"""
|
||||
zuordnungen = [row for row in mappings if int(row["grants_admin"] or 0) == 1]
|
||||
if not zuordnungen:
|
||||
return
|
||||
soll = any(str(row["ad_group_dn"]).lower() in gruppen for row in zuordnungen)
|
||||
zeile = Database.one(
|
||||
conn, "SELECT is_admin, auth_source FROM users WHERE id=?", (user_id,)
|
||||
)
|
||||
if zeile is None or str(zeile["auth_source"]) != "ldap":
|
||||
return
|
||||
ist = bool(zeile["is_admin"])
|
||||
if ist == soll:
|
||||
return
|
||||
if not soll:
|
||||
uebrig = Database.value(
|
||||
conn,
|
||||
"SELECT COUNT(*) FROM users WHERE is_admin=1 AND is_locked=0 "
|
||||
"AND deleted_at IS NULL AND id<>?",
|
||||
(user_id,),
|
||||
default=0,
|
||||
)
|
||||
if not uebrig:
|
||||
logger.warning(
|
||||
"Administratorkennzeichen bleibt bei Benutzer %s: es waere das letzte.",
|
||||
user_id,
|
||||
)
|
||||
return
|
||||
conn.execute(
|
||||
"UPDATE users SET is_admin=?, updated_at=? WHERE id=?", (int(soll), _now(), user_id)
|
||||
)
|
||||
audit.log(
|
||||
conn,
|
||||
actor="system",
|
||||
action="auth.admin_flag_changed",
|
||||
target=str(user_id),
|
||||
detail=(
|
||||
"Administrator: "
|
||||
+ ("nein -> ja" if soll else "ja -> nein")
|
||||
+ " (aus der Verzeichniszuordnung)"
|
||||
),
|
||||
severity="warning",
|
||||
)
|
||||
|
||||
|
||||
def _user_group_ids(conn: sqlite3.Connection, user_id: int) -> list[dict[str, Any]]:
|
||||
return Database.all(conn, "SELECT group_id AS id FROM user_groups WHERE user_id=?", (user_id,))
|
||||
|
||||
|
||||
@@ -77,25 +77,40 @@ class User:
|
||||
|
||||
@property
|
||||
def effective_permissions(self) -> frozenset[str]:
|
||||
"""Rechte nach Anwendung der Bereichs-Kill-Switches."""
|
||||
"""Rechte nach Anwendung der Bereichs-Kill-Switches.
|
||||
|
||||
Ein echter Administrator hat den Verwaltungsbereich immer dabei -- auch
|
||||
ohne Gruppe und ohne dass jemand ihn angehakt haette. Sonst koennte
|
||||
sich das letzte Administratorkonto aus der eigenen Anlage aussperren,
|
||||
indem es seine Gruppe verliert.
|
||||
"""
|
||||
if self._effective is None:
|
||||
tree = self._tree
|
||||
self._effective = (
|
||||
frozenset(tree.effective(self.granted)) if tree is not None else frozenset()
|
||||
)
|
||||
if tree is None:
|
||||
self._effective = frozenset()
|
||||
else:
|
||||
eigene = set(tree.effective(self.granted))
|
||||
if self.is_admin:
|
||||
eigene |= set(tree.verwaltungsrechte())
|
||||
self._effective = frozenset(eigene)
|
||||
return self._effective
|
||||
|
||||
def has(self, *permissions: str) -> bool:
|
||||
"""True, wenn **alle** angegebenen Rechte greifen. Admins duerfen immer alles."""
|
||||
if self.is_admin:
|
||||
return True
|
||||
"""True, wenn **alle** angegebenen Rechte greifen.
|
||||
|
||||
Das Administratorkennzeichen gibt seit 2.8.0 **den Verwaltungsbereich**
|
||||
frei und nicht mehr jede Pruefung: Benutzer, Gruppen, Einstellungen,
|
||||
Sicherung, Papierkorb, Diagnose. Funktionsrechte -- Geraete, Neustarts,
|
||||
DHCP, Freigaben -- kommen ueber Gruppen, und die darf sich ein
|
||||
Administrator selbst zuweisen. Der Unterschied ist nicht, *ob* er sie
|
||||
bekommen kann, sondern dass es dann eine Zeile im
|
||||
Aenderungsprotokoll gibt und in der Matrix zu sehen ist.
|
||||
"""
|
||||
if not permissions:
|
||||
return True
|
||||
return all(permission in self.effective_permissions for permission in permissions)
|
||||
|
||||
def has_any(self, permissions: Iterable[str]) -> bool:
|
||||
if self.is_admin:
|
||||
return True
|
||||
effective = self.effective_permissions
|
||||
return any(permission in effective for permission in permissions)
|
||||
|
||||
@@ -220,6 +235,37 @@ def login_required(view: Callable[..., Any]) -> Callable[..., Any]:
|
||||
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]:
|
||||
"""Erzwingt Rechte serverseitig.
|
||||
|
||||
@@ -237,7 +283,15 @@ def permission_required(*permissions: str, mode: str = "all") -> Callable[..., A
|
||||
else current_user.has(*permissions)
|
||||
)
|
||||
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)
|
||||
|
||||
wrapper._required_permissions = permissions # type: ignore[attr-defined]
|
||||
@@ -279,7 +333,7 @@ def reauth_required(view: Callable[..., Any]) -> Callable[..., Any]:
|
||||
return _deny("Anmeldung erforderlich.")
|
||||
if not reauth_is_fresh():
|
||||
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 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():
|
||||
conn = db()
|
||||
user_row = service.pending_totp_user(conn)
|
||||
@@ -116,7 +116,7 @@ def logout_get():
|
||||
return redirect(url_for("auth.login"))
|
||||
|
||||
|
||||
@bp.route("/bestaetigen", methods=["GET", "POST"])
|
||||
@bp.route("/confirm", methods=["GET", "POST"])
|
||||
@login_required
|
||||
def reauth():
|
||||
target = _safe_next(session.get("_after_reauth"))
|
||||
|
||||
@@ -151,7 +151,7 @@ def authenticate(
|
||||
if ip_verdict.blocked:
|
||||
return AuthOutcome(
|
||||
"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,
|
||||
)
|
||||
|
||||
@@ -165,7 +165,7 @@ def authenticate(
|
||||
if user_verdict.blocked:
|
||||
return AuthOutcome(
|
||||
"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,
|
||||
)
|
||||
|
||||
@@ -248,7 +248,7 @@ def _verify_credentials(
|
||||
return AuthOutcome(
|
||||
"directory_unavailable",
|
||||
"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:
|
||||
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)
|
||||
|
||||
|
||||
|
||||
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(
|
||||
conn: sqlite3.Connection,
|
||||
user_row: dict[str, Any],
|
||||
@@ -275,21 +293,26 @@ def complete_login(
|
||||
"""
|
||||
from ..security import csrf
|
||||
|
||||
# Vor dem UPDATE lesen -- danach steht ueberall ein Zeitstempel.
|
||||
erstanmeldung = _ist_erstanmeldung(user_row)
|
||||
|
||||
with core().database.transaction(conn):
|
||||
session_store.rotate(conn, session, reason="login") # type: ignore[arg-type]
|
||||
conn.execute(
|
||||
"UPDATE users SET last_login_at=? WHERE id=?", (_iso(_now()), user_row["id"])
|
||||
)
|
||||
audit.log(
|
||||
conn,
|
||||
actor=str(user_row["username"]),
|
||||
actor_id=int(user_row["id"]),
|
||||
action="auth.login",
|
||||
target=str(user_row["username"]),
|
||||
detail=f"Quelle: {user_row.get('auth_source') or 'local'}",
|
||||
ip=ip,
|
||||
request_id=getattr(g, "request_id", ""),
|
||||
)
|
||||
if erstanmeldung:
|
||||
audit.log(
|
||||
conn,
|
||||
actor=str(user_row["username"]),
|
||||
actor_id=int(user_row["id"]),
|
||||
action="auth.first_login",
|
||||
target=str(user_row["username"]),
|
||||
detail=f"Erste Anmeldung | Quelle: {user_row.get('auth_source') or 'local'}",
|
||||
ip=ip,
|
||||
request_id=getattr(g, "request_id", ""),
|
||||
severity="notice",
|
||||
)
|
||||
|
||||
session.clear()
|
||||
session[SESSION_USER_KEY] = int(user_row["id"])
|
||||
@@ -336,15 +359,11 @@ def logout(conn: sqlite3.Connection) -> None:
|
||||
current_app.logger.exception(
|
||||
"Abmeldehaken %r fehlgeschlagen", getattr(hook, "__name__", hook)
|
||||
)
|
||||
with core().database.transaction(conn):
|
||||
audit.log(
|
||||
conn,
|
||||
actor=user.username,
|
||||
actor_id=user.id,
|
||||
action="auth.logout",
|
||||
target=user.username,
|
||||
ip=client_ip(),
|
||||
)
|
||||
# Kein Protokolleintrag fuer das Abmelden: eine Abmeldung aendert
|
||||
# nichts. Sie stand als "Info" in jeder zweiten Zeile und verdraengte
|
||||
# die Eintraege, um die es geht. Wer wissen will, wer wann da war,
|
||||
# liest die Sitzungsuebersicht; fehlgeschlagene Anmeldungen und
|
||||
# Sperren stehen weiterhin jedes Mal im Protokoll.
|
||||
session.destroy() if hasattr(session, "destroy") else session.clear() # type: ignore[attr-defined]
|
||||
g.user = ANONYMOUS
|
||||
|
||||
|
||||
@@ -42,6 +42,8 @@ def _config_from_settings(extension: Any, conn: Any) -> webserver.WebConfig:
|
||||
],
|
||||
)
|
||||
return webserver.WebConfig(
|
||||
# Die neue Schreibweise, sobald der Webserver sie kennt.
|
||||
http2_directive=webserver.nginx_kann_http2(),
|
||||
app_key=extension.config.site_key,
|
||||
upstream=str(extension.registry.get("upstream", "127.0.0.1:5000")),
|
||||
static_root=str(
|
||||
@@ -83,7 +85,7 @@ def run_web_setup(app: Any, extension: Any, args: argparse.Namespace) -> int:
|
||||
port = getattr(args, flag, None)
|
||||
if port:
|
||||
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
|
||||
values[key] = int(port)
|
||||
|
||||
@@ -93,11 +95,11 @@ def run_web_setup(app: Any, extension: Any, args: argparse.Namespace) -> int:
|
||||
|
||||
# -- Zertifikat --------------------------------------------------- #
|
||||
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 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
|
||||
cert = webserver.request_certificate(
|
||||
sysops,
|
||||
@@ -118,7 +120,7 @@ def run_web_setup(app: Any, extension: Any, args: argparse.Namespace) -> int:
|
||||
|
||||
if args.self_signed and not args.acme:
|
||||
if 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
|
||||
suggested = webserver.local_names()
|
||||
cert = webserver.create_self_signed(
|
||||
@@ -144,7 +146,7 @@ def run_web_setup(app: Any, extension: Any, args: argparse.Namespace) -> int:
|
||||
if args.https:
|
||||
if not (config.cert_path and config.key_path):
|
||||
print(
|
||||
"HTTPS verlangt Zertifikat und Schluessel -- bitte --self-signed "
|
||||
"HTTPS verlangt Zertifikat und Schlüssel – bitte --self-signed "
|
||||
"oder --acme angeben.",
|
||||
file=sys.stderr,
|
||||
)
|
||||
@@ -172,7 +174,7 @@ def run_web_setup(app: Any, extension: Any, args: argparse.Namespace) -> int:
|
||||
return 0
|
||||
|
||||
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
|
||||
result = webserver.apply_config(sysops, rendered)
|
||||
print(result["message"])
|
||||
@@ -194,7 +196,7 @@ def add_web_setup(sub: Any, runner: Any) -> None:
|
||||
help="nginx und TLS einrichten (Port 80/443)",
|
||||
description=(
|
||||
"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."
|
||||
),
|
||||
)
|
||||
@@ -207,20 +209,20 @@ def add_web_setup(sub: Any, runner: Any) -> None:
|
||||
parser.add_argument(
|
||||
"--acme",
|
||||
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("--acme-email", help="Kontaktadresse fuer Let's Encrypt")
|
||||
parser.add_argument("--domain", help="Öffentlicher Name dieser Installation")
|
||||
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(
|
||||
"--no-redirect",
|
||||
action="store_true",
|
||||
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("--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(
|
||||
"--print-config", action="store_true", help="Nur die erzeugte Datei ausgeben"
|
||||
)
|
||||
|
||||
@@ -76,7 +76,7 @@ class CoreConfig:
|
||||
"""Technischer Kurzname, z. B. ``tesm`` -- Namensraum fuer Pfade und Units."""
|
||||
|
||||
site_key: str
|
||||
"""Name *dieser Installation* auf dem Host, z. B. ``tesm-opus``.
|
||||
"""Name *dieser Installation* auf dem Host, z. B. ``tesm-test``.
|
||||
|
||||
Weicht vom ``app_key`` ab, sobald neben der Hauptinstallation eine zweite
|
||||
Instanz laeuft. Alles, was ausserhalb von ``/srv/<name>`` liegt und damit
|
||||
@@ -175,11 +175,11 @@ def load_core_config(
|
||||
|
||||
bind = (_env(env_prefix, "BIND") or default_bind).strip()
|
||||
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()
|
||||
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(
|
||||
app_key=app_key,
|
||||
|
||||
@@ -141,11 +141,16 @@ CREATE TABLE IF NOT EXISTS service_accounts (
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
-- Eine Verzeichnisgruppe zeigt entweder auf eine lokale Gruppe **oder** auf
|
||||
-- das Administratorkennzeichen (grants_admin). Darum ist app_group_id
|
||||
-- nullbar: "Domänen-Admins" soll echte Administratoren erzeugen koennen und
|
||||
-- nicht nur Mitglieder der Gruppe "Administration".
|
||||
CREATE TABLE IF NOT EXISTS ldap_group_mappings (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
ad_group_dn TEXT NOT NULL UNIQUE,
|
||||
ad_group_name TEXT NOT NULL,
|
||||
app_group_id INTEGER NOT NULL REFERENCES groups(id) ON DELETE CASCADE,
|
||||
app_group_id INTEGER REFERENCES groups(id) ON DELETE CASCADE,
|
||||
grants_admin INTEGER NOT NULL DEFAULT 0,
|
||||
created_at TEXT NOT NULL
|
||||
);
|
||||
"""
|
||||
@@ -186,8 +191,145 @@ CREATE INDEX IF NOT EXISTS idx_jobs_kind_state ON jobs(kind, state);
|
||||
CREATE INDEX IF NOT EXISTS idx_jobs_created ON jobs(created_at);
|
||||
"""
|
||||
|
||||
def benenne_rechte_um(conn) -> None: # type: ignore[no-untyped-def]
|
||||
"""Schreibt bestehende Gruppenrechte auf das neue Vokabular um.
|
||||
|
||||
``devices.view`` wird ``devices.read``, ``devices.edit`` wird
|
||||
``devices.write``. Ohne diesen Schritt stehen in den Gruppen nur noch
|
||||
Schluessel, die der Rechtebaum nicht kennt -- und
|
||||
:meth:`PermissionTree.sanitize` wirft Unbekanntes **still** weg. Nach dem
|
||||
Update waeren alle Gruppen leer und niemand kaeme mehr an seine Seiten:
|
||||
kein Fehler, den man sieht, sondern einer, den man erst merkt, wenn jemand
|
||||
anruft.
|
||||
|
||||
Im Kern und nicht in den Anwendungen, weil die Tabelle beiden gehoert.
|
||||
Idempotent: was schon neu heisst, wird nicht angefasst.
|
||||
"""
|
||||
from .rbac.legacy_permissions import UMBENANNTE_AKTIONEN
|
||||
|
||||
for alt, neu in UMBENANNTE_AKTIONEN.items():
|
||||
# Erst die Dubletten weg, die beim Umschreiben entstehen wuerden: haette
|
||||
# eine Gruppe (aus welchem Grund auch immer) beide Schluessel, brach der
|
||||
# eindeutige Index. Lieber vorher aufraeumen als in der Migration
|
||||
# scheitern.
|
||||
conn.execute(
|
||||
"DELETE FROM group_permissions WHERE permission LIKE ? AND EXISTS ("
|
||||
" SELECT 1 FROM group_permissions AS andere"
|
||||
" WHERE andere.group_id = group_permissions.group_id"
|
||||
" AND andere.permission = substr(group_permissions.permission, 1,"
|
||||
" length(group_permissions.permission) - ?) || ?)",
|
||||
(f"%.{alt}", len(alt), neu),
|
||||
)
|
||||
conn.execute(
|
||||
"UPDATE group_permissions"
|
||||
" SET permission = substr(permission, 1, length(permission) - ?) || ?"
|
||||
" WHERE permission LIKE ?",
|
||||
(len(alt), neu, f"%.{alt}"),
|
||||
)
|
||||
|
||||
|
||||
def zuordnung_auf_kennzeichen(conn) -> None: # type: ignore[no-untyped-def]
|
||||
"""Erlaubt einer Verzeichnisgruppe, das Administratorkennzeichen zu setzen.
|
||||
|
||||
Bisher konnte eine Zuordnung nur auf eine lokale Gruppe zeigen. "Domänen-
|
||||
Admins" landete deshalb in der Gruppe *Administration* -- und wurde damit
|
||||
gerade **kein** echter Administrator: die Gruppe pflegt Benutzer und
|
||||
Rechte, hat aber kein Kennzeichen und kann sich keines geben.
|
||||
|
||||
Zwei Aenderungen an der Tabelle, und SQLite kann Spalten nicht
|
||||
umdefinieren: also neu anlegen und umkopieren. ``app_group_id`` wird
|
||||
nullbar (eine Zuordnung auf das Kennzeichen zeigt auf keine Gruppe), und
|
||||
``grants_admin`` sagt, dass genau das gemeint ist.
|
||||
"""
|
||||
spalten = {
|
||||
str(zeile[1]) for zeile in conn.execute("PRAGMA table_info(ldap_group_mappings)")
|
||||
}
|
||||
if "grants_admin" in spalten:
|
||||
return
|
||||
conn.execute(
|
||||
"""
|
||||
CREATE TABLE ldap_group_mappings_neu (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
ad_group_dn TEXT NOT NULL UNIQUE,
|
||||
ad_group_name TEXT NOT NULL,
|
||||
app_group_id INTEGER REFERENCES groups(id) ON DELETE CASCADE,
|
||||
grants_admin INTEGER NOT NULL DEFAULT 0,
|
||||
created_at TEXT NOT NULL
|
||||
)
|
||||
"""
|
||||
)
|
||||
conn.execute(
|
||||
"INSERT INTO ldap_group_mappings_neu "
|
||||
"(id, ad_group_dn, ad_group_name, app_group_id, grants_admin, created_at) "
|
||||
"SELECT id, ad_group_dn, ad_group_name, app_group_id, 0, created_at "
|
||||
" FROM ldap_group_mappings"
|
||||
)
|
||||
conn.execute("DROP TABLE ldap_group_mappings")
|
||||
conn.execute("ALTER TABLE ldap_group_mappings_neu RENAME TO ldap_group_mappings")
|
||||
|
||||
|
||||
def teile_rechte_auf(conn) -> None: # type: ignore[no-untyped-def]
|
||||
"""Verteilt ein altes Sammelrecht auf die Zeilen, die daraus wurden.
|
||||
|
||||
Additiv: wer ``dhcp.edit`` hatte, bekommt alle fuenf DHCP-Zeilen, und das
|
||||
alte Recht verschwindet. Ohne diesen Schritt stuende in der Gruppe ein
|
||||
Schluessel, den der Baum nicht kennt -- und weg waere er still, denn genau
|
||||
das tut :meth:`PermissionTree.sanitize` mit Unbekanntem.
|
||||
|
||||
Laeuft nach ``benenne_rechte_um``: die Tabelle nennt beide Schreibweisen,
|
||||
wo es darauf ankommt, und ein zweiter Lauf findet nichts mehr zu tun.
|
||||
"""
|
||||
from .rbac.legacy_permissions import (
|
||||
ERWEITERTE_RECHTE,
|
||||
GETEILTE_RECHTE,
|
||||
neue_schluessel,
|
||||
)
|
||||
|
||||
def gruppen_mit(recht: str) -> list[int]:
|
||||
return [
|
||||
int(zeile[0])
|
||||
for zeile in conn.execute(
|
||||
"SELECT DISTINCT group_id FROM group_permissions WHERE permission=?",
|
||||
(recht,),
|
||||
).fetchall()
|
||||
]
|
||||
|
||||
# Geteilt: das alte Recht gibt es nicht mehr, also ersetzen.
|
||||
for altes, neue in GETEILTE_RECHTE.items():
|
||||
gruppen = gruppen_mit(altes)
|
||||
if not gruppen:
|
||||
continue
|
||||
conn.executemany(
|
||||
"INSERT OR IGNORE INTO group_permissions (group_id, permission) VALUES (?,?)",
|
||||
[(gid, recht) for gid in gruppen for recht in neue],
|
||||
)
|
||||
conn.execute("DELETE FROM group_permissions WHERE permission=?", (altes,))
|
||||
|
||||
# Erweitert: das Recht bleibt und bekommt dazu, was bisher an ihm hing.
|
||||
# Loeschen waere hier falsch -- es ist keines von gestern.
|
||||
for recht, dazu in ERWEITERTE_RECHTE.items():
|
||||
gruppen = gruppen_mit(recht)
|
||||
if not gruppen:
|
||||
continue
|
||||
conn.executemany(
|
||||
"INSERT OR IGNORE INTO group_permissions (group_id, permission) VALUES (?,?)",
|
||||
[(gid, weiteres) for gid in gruppen for weiteres in dazu],
|
||||
)
|
||||
assert neue_schluessel # in derselben Tabelle nachgeschlagen wie beim Import
|
||||
|
||||
|
||||
CORE_MIGRATIONS: tuple[Migration, ...] = (
|
||||
Migration(version=1, name="baseline", sql=V1_BASELINE),
|
||||
Migration(
|
||||
version=1,
|
||||
name="baseline",
|
||||
sql=V1_BASELINE,
|
||||
geaendert_weil=(
|
||||
"Das Grundschema legt ldap_group_mappings jetzt mit grants_admin und nullbarer app_group_id an -- eine neue Installation soll nicht erst core/6 brauchen, um die Tabelle wieder umzubauen."
|
||||
),
|
||||
),
|
||||
Migration(version=2, name="trusted-issuer-keys", sql=V2_TRUSTED_KEYS),
|
||||
Migration(version=3, name="jobs", sql=V3_JOBS),
|
||||
Migration(version=4, name="rechte-read-write", fn=benenne_rechte_um),
|
||||
Migration(version=5, name="rechte-je-funktion", fn=teile_rechte_auf),
|
||||
Migration(version=6, name="ldap-kennzeichen", fn=zuordnung_auf_kennzeichen),
|
||||
)
|
||||
@@ -80,8 +80,8 @@ class Database:
|
||||
"""Oeffnet eine neue Verbindung. Nicht aufrufen, solange eine Transaktion laeuft."""
|
||||
if self._depth() > 0:
|
||||
raise NestedConnectionError(
|
||||
"Es laeuft bereits eine Schreibtransaktion auf dieser Datenbank. "
|
||||
"Reiche die bestehende Verbindung durch, statt eine neue zu oeffnen "
|
||||
"Es läuft bereits eine Schreibtransaktion auf dieser Datenbank. "
|
||||
"Reiche die bestehende Verbindung durch, statt eine neue zu öffnen "
|
||||
"(sonst blockiert SQLite sich selbst)."
|
||||
)
|
||||
return self._raw_connect()
|
||||
@@ -119,6 +119,29 @@ class Database:
|
||||
finally:
|
||||
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
|
||||
# ------------------------------------------------------------------ #
|
||||
|
||||
@@ -79,7 +79,7 @@ def core(app: Flask | None = None) -> TesmCore:
|
||||
try:
|
||||
return target.extensions["tesm"] # type: ignore[return-value]
|
||||
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:
|
||||
|
||||
@@ -78,7 +78,7 @@ def check_private_file(path: Path) -> list[str]:
|
||||
except OSError:
|
||||
return problems
|
||||
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
|
||||
|
||||
|
||||
@@ -110,11 +110,11 @@ class Keystore:
|
||||
created_at=str(entry.get("created_at", "")),
|
||||
)
|
||||
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._active = str(raw.get("active") or "")
|
||||
if self._active not in self._keys:
|
||||
raise ValueError("Aktiver Schluessel fehlt im Schluesselbund")
|
||||
raise ValueError("Aktiver Schlüssel fehlt im Schlüsselbund")
|
||||
return
|
||||
self._active = "1"
|
||||
self._keys = {
|
||||
@@ -160,7 +160,7 @@ class Keystore:
|
||||
def drop_key(self, key_id: str) -> None:
|
||||
"""Entfernt einen alten Schluessel -- erst nach vollstaendiger Neuverschluesselung."""
|
||||
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._persist()
|
||||
|
||||
@@ -183,14 +183,14 @@ class Keystore:
|
||||
raise DecryptionError(f"Unbekannte Chiffrat-Version: {prefix!r}")
|
||||
key = self._keys.get(key_id)
|
||||
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:
|
||||
plain = AESGCM(key.material).decrypt(
|
||||
_b64d(nonce_b64), _b64d(ct_b64), aad.encode("utf-8")
|
||||
)
|
||||
except (InvalidTag, ValueError) as exc:
|
||||
raise DecryptionError(
|
||||
"Chiffrat konnte nicht entschluesselt werden (falscher Schluessel, "
|
||||
"Chiffrat konnte nicht entschlüsselt werden (falscher Schlüssel, "
|
||||
"manipulierte Daten oder falscher Verwendungszweck)"
|
||||
) from exc
|
||||
return plain.decode("utf-8")
|
||||
|
||||
@@ -0,0 +1,242 @@
|
||||
"""Alte, deutsche Adressen auf die neuen englischen umlenken.
|
||||
|
||||
Mit 2.5.0 folgen die Adressen der Navigation und sind englisch:
|
||||
``/monitoring/host-keys`` statt ``/hostschluessel``. Das ist eine gute
|
||||
Aenderung und eine unangenehme zugleich -- Lesezeichen, offene Browsertabs und
|
||||
ein Link in einer Mail zeigen weiter auf die alte Adresse. Ein offener Tab ist
|
||||
dabei der unangenehmste Fall: die Statusuebersicht fragt alle 20 Sekunden ihre
|
||||
Kacheln ab und bekam nach dem Update jedes Mal einen 404.
|
||||
|
||||
Deshalb diese Tabelle. Sie greift **nur, wenn sonst ein 404 herauskaeme** --
|
||||
also nie vor einer bestehenden Route. Das ist keine Feinheit: auf dem
|
||||
Lizenzserver ist ``/lizenz/<ticket>`` eine echte, weiterhin gueltige Adresse
|
||||
(sie steht in verschickten Mails), waehrend dieselbe Wurzel in TESM zur
|
||||
Lizenzseite fuehrte. Wer zuerst das Routing fragt, muss sich nicht zwischen
|
||||
beidem entscheiden.
|
||||
|
||||
Die Tabelle ist bewusst vollstaendig und nicht nur auf Wurzeln gekuerzt: eine
|
||||
Umleitung von ``/dateifreigaben/x/vorschau`` auf eine Uebersichtsseite waere
|
||||
zwar kein 404 mehr, aber auch nicht das, was jemand angeklickt hat.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Mapping
|
||||
|
||||
#: Alte Wurzel -> (neue Wurzel, Uebersetzung der Segmente darunter).
|
||||
WURZELN: dict[str, tuple[str, Mapping[str, str]]] = {
|
||||
"/dateifreigaben": (
|
||||
"/fileshares",
|
||||
{
|
||||
"herunterladen": "download",
|
||||
"vorschau": "preview",
|
||||
"hochladen": "upload",
|
||||
"formular": "form",
|
||||
"ordner": "folder",
|
||||
"umbenennen": "rename",
|
||||
"loeschen": "delete",
|
||||
"freigeben": "release",
|
||||
"zuordnungen": "mappings",
|
||||
},
|
||||
),
|
||||
"/hostschluessel": (
|
||||
"/monitoring/host-keys",
|
||||
{"pruefen": "probe", "freigeben": "trust", "verwerfen": "forget", "verwaist": "orphaned"},
|
||||
),
|
||||
"/zugangsdaten": (
|
||||
"/monitoring/credentials",
|
||||
{"neu": "new", "loeschen": "delete", "anzeigen": "reveal"},
|
||||
),
|
||||
"/switche": (
|
||||
"/monitoring/switches",
|
||||
{
|
||||
"neu": "new",
|
||||
"loeschen": "delete",
|
||||
"hostkey": "host-key",
|
||||
"pruefen": "probe",
|
||||
"freigeben": "trust",
|
||||
"verwerfen": "forget",
|
||||
"kommando": "command",
|
||||
"konsole": "console",
|
||||
},
|
||||
),
|
||||
"/clients": (
|
||||
"/monitoring/clients",
|
||||
{
|
||||
"neu": "new",
|
||||
"aktiv": "active",
|
||||
"loeschen": "delete",
|
||||
"neustart": "restart",
|
||||
"hostkey": "host-key",
|
||||
"pruefen": "probe",
|
||||
"freigeben": "trust",
|
||||
"verwerfen": "forget",
|
||||
"konsole": "console",
|
||||
},
|
||||
),
|
||||
"/wartung": (
|
||||
"/maintenance",
|
||||
{
|
||||
"paket-nachinstallieren": "install-package",
|
||||
"auftrag": "job",
|
||||
"ausgabe": "output",
|
||||
"aktualisieren": "update",
|
||||
"neustarten": "restart",
|
||||
},
|
||||
),
|
||||
"/protokolle": (
|
||||
"/logs",
|
||||
{
|
||||
"kea": "dhcp",
|
||||
"neustarts": "restarts",
|
||||
"laufend": "live",
|
||||
"inhalt": "content",
|
||||
"verlauf": "history",
|
||||
"datei": "file",
|
||||
"archiv": "archive",
|
||||
"archiv-export": "archive-export",
|
||||
"archiv-loeschen": "archive-delete",
|
||||
"aenderungen": "audit",
|
||||
},
|
||||
),
|
||||
"/verwaltung": (
|
||||
"/administration",
|
||||
{
|
||||
"benutzer": "users",
|
||||
"neu": "new",
|
||||
"loeschen": "delete",
|
||||
"sitzungen-beenden": "end-sessions",
|
||||
"verzeichnis-suche": "directory-search",
|
||||
"verzeichnis-uebernehmen": "directory-import",
|
||||
"gruppen": "groups",
|
||||
"vorlage-anwenden": "apply-preset",
|
||||
"sitzungen": "sessions",
|
||||
"beenden": "end",
|
||||
},
|
||||
),
|
||||
"/einstellungen": (
|
||||
"/administration",
|
||||
{
|
||||
"zeitzone": "timezone",
|
||||
"pruefung-jetzt": "check-now",
|
||||
"netzwerk": "network",
|
||||
"bestaetigen": "confirm",
|
||||
"webserver": "web",
|
||||
"uebernehmen": "apply",
|
||||
"zertifikat": "certificate",
|
||||
"selbstsigniert": "self-signed",
|
||||
"verzeichnisdienst": "directory",
|
||||
"gruppensuche": "group-search",
|
||||
"zuordnung": "mapping",
|
||||
"loeschen": "delete",
|
||||
"anbieter": "vendor",
|
||||
"logo-entfernen": "remove-logo",
|
||||
},
|
||||
),
|
||||
"/sicherung": (
|
||||
"/administration/backup",
|
||||
{"vorschau": "preview", "uebernehmen": "apply", "verwerfen": "discard"},
|
||||
),
|
||||
"/papierkorb": (
|
||||
"/administration/trash",
|
||||
{"wiederherstellen": "restore", "endgueltig-loeschen": "purge", "leeren": "empty"},
|
||||
),
|
||||
"/diagnose": (
|
||||
"/administration/diagnostics",
|
||||
{"aufraeumen": "cleanup", "schluessel-rotieren": "rotate-keys"},
|
||||
),
|
||||
"/eigene-lizenz": (
|
||||
"/own-license",
|
||||
{
|
||||
"einspielen": "install",
|
||||
"aktivieren": "activate",
|
||||
"deaktivieren": "deactivate",
|
||||
"offline-antwort": "offline-response",
|
||||
"signaturschluessel": "signing-key",
|
||||
},
|
||||
),
|
||||
"/lizenzen": (
|
||||
"/licenses",
|
||||
{
|
||||
"aus-ticket": "from-ticket",
|
||||
"datei": "file",
|
||||
"neu-signieren": "resign",
|
||||
"widerrufen": "revoke",
|
||||
"loeschen": "delete",
|
||||
"versenden": "send",
|
||||
"sub-lizenzserver": "sub-servers",
|
||||
},
|
||||
),
|
||||
"/lizenz": (
|
||||
"/administration/license",
|
||||
{
|
||||
"einspielen": "install",
|
||||
"aktivieren": "activate",
|
||||
"deaktivieren": "deactivate",
|
||||
"offline-antwort": "offline-response",
|
||||
"serverzertifikat": "server-certificate",
|
||||
"aussteller": "issuer",
|
||||
"entfernen": "remove",
|
||||
},
|
||||
),
|
||||
"/kunden": ("/customers", {"neu": "new", "loeschen": "delete"}),
|
||||
"/konto": (
|
||||
"/account",
|
||||
{
|
||||
"stammdaten": "profile",
|
||||
"profilbild": "avatar",
|
||||
"sicherheit": "security",
|
||||
"passwort": "password",
|
||||
"zwei-faktor": "two-factor",
|
||||
"starten": "start",
|
||||
"bestaetigen": "confirm",
|
||||
"deaktivieren": "disable",
|
||||
"codes-erneuern": "recovery-codes",
|
||||
"sitzungen": "sessions",
|
||||
"beenden": "end",
|
||||
"alle-beenden": "end-all",
|
||||
},
|
||||
),
|
||||
"/uebersicht": ("/overview", {"kacheln": "tiles"}),
|
||||
"/gesundheit": ("/health", {}),
|
||||
"/bestaetigen": ("/confirm", {}),
|
||||
}
|
||||
|
||||
#: Abfrageparameter, die mit umbenannt wurden.
|
||||
PARAMETER = {
|
||||
"freigabe": "share",
|
||||
"pfad": "path",
|
||||
"aktion": "action",
|
||||
"benutzer": "actor",
|
||||
"stufe": "severity",
|
||||
"seite": "page",
|
||||
"methode": "method",
|
||||
"kunde": "customer",
|
||||
"freischalten": "unlocked",
|
||||
}
|
||||
|
||||
|
||||
#: Wurzeln, unter denen eine unbekannte Unterseite **nicht** umgelenkt wird.
|
||||
#: ``/lizenz/<ticket>`` ist auf dem Lizenzserver eine echte Adresse; ein
|
||||
#: abgelaufenes oder falsches Ticket gehoert dort auf die Fehlerseite und nicht
|
||||
#: in die Verwaltung.
|
||||
STRENG = {"/lizenz"}
|
||||
|
||||
|
||||
def neue_adresse(pfad: str) -> str | None:
|
||||
"""Uebersetzt eine alte Adresse -- oder ``None``, wenn es keine ist."""
|
||||
for alt, (neu, segmente) in WURZELN.items():
|
||||
if pfad != alt and not pfad.startswith(alt + "/"):
|
||||
continue
|
||||
rest = pfad[len(alt) :]
|
||||
teile = rest.split("/")
|
||||
if alt in STRENG:
|
||||
erstes = next((teil for teil in teile if teil), "")
|
||||
if erstes and erstes not in segmente:
|
||||
return None
|
||||
return neu + "/".join(segmente.get(teil, teil) for teil in teile)
|
||||
return None
|
||||
|
||||
|
||||
def neue_parameter(args: Mapping[str, str]) -> dict[str, str]:
|
||||
return {PARAMETER.get(name, name): wert for name, wert in args.items()}
|
||||
@@ -34,7 +34,7 @@ import urllib.request
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any, Callable
|
||||
from typing import Any, Callable, Iterable
|
||||
|
||||
import tesm_licensing as lic
|
||||
|
||||
@@ -54,6 +54,21 @@ SETTING_HEARTBEAT_ERROR = "license_heartbeat_error"
|
||||
EXPIRY_HINT_DAYS = 90
|
||||
SETTING_EXPORT_GRACE = "license_export_grace"
|
||||
|
||||
#: Module, die der Betreiber vor Ort abgeschaltet hat (Liste von Namen).
|
||||
#:
|
||||
#: Die Lizenz sagt, *was erlaubt ist*; sie sagt nicht, was ein Haus auch
|
||||
#: benutzen will. Wer kein DHCP ueber TESM fahren will, weil ein anderer Server
|
||||
#: das tut, soll den Punkt nicht wegdruecken muessen -- und vor allem soll
|
||||
#: niemand versehentlich eine fremde Kea-Konfiguration ueberschreiben koennen.
|
||||
#: Abschalten ist deshalb mehr als Aufraeumen der Navigation: die Sperre sitzt
|
||||
#: an derselben Stelle wie die Lizenzpruefung, also vor jeder Route.
|
||||
#: Bewusst **ohne** das Praefix ``license_``: Sicherungen lassen alles mit
|
||||
#: diesem Praefix draussen, weil Lizenz- und Aktivierungszustand zur Maschine
|
||||
#: gehoeren und nicht in ein Backup. Der Schalter ist aber keine Lizenzangabe,
|
||||
#: sondern eine Entscheidung des Hauses -- die soll eine Wiederherstellung
|
||||
#: mitbringen.
|
||||
SETTING_MODULES_OFF = "modules_disabled"
|
||||
|
||||
STATUS_INACTIVE = "inactive"
|
||||
STATUS_PENDING = "pending"
|
||||
STATUS_ACTIVE = "active"
|
||||
@@ -68,10 +83,10 @@ API_PATHS = {
|
||||
#: die einzige Wahrheit -- Templates lesen sie, Routen erzwingen sie.
|
||||
LICENSED_FEATURES: dict[str, str] = {
|
||||
"directory": "Verzeichnisanmeldung (LDAP/AD) konfigurieren",
|
||||
"nginx": "Webserver-Einstellungen aendern",
|
||||
"nginx": "Webserver-Einstellungen ändern",
|
||||
"export": "Daten exportieren",
|
||||
"trash_restore": "Papierkorb wiederherstellen und endgueltig loeschen",
|
||||
"monitor_interval": "Pruefintervall der Ueberwachung aendern",
|
||||
"trash_restore": "Papierkorb wiederherstellen und endgültig löschen",
|
||||
"monitor_interval": "Prüfintervall der Überwachung ändern",
|
||||
"auto_poe_restart": "Automatischer PoE-Neustart bei Ausfall",
|
||||
}
|
||||
|
||||
@@ -85,7 +100,7 @@ def describe_transport_error(exc: BaseException, endpoint: str) -> str:
|
||||
"""
|
||||
text = str(exc)
|
||||
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:
|
||||
detail = "Der Lizenzserver benutzt ein selbstsigniertes Zertifikat"
|
||||
return (
|
||||
@@ -94,7 +109,7 @@ def describe_transport_error(exc: BaseException, endpoint: str) -> str:
|
||||
"einer anerkannten Stelle."
|
||||
)
|
||||
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:
|
||||
return f"{endpoint} nimmt keine Verbindung an."
|
||||
if "timed out" in text or "timeout" in text.lower():
|
||||
@@ -121,6 +136,8 @@ class LicenseState:
|
||||
last_heartbeat_at: str = ""
|
||||
heartbeat_error: str = ""
|
||||
pending_action: str = ""
|
||||
#: Vor Ort abgeschaltet -- unabhaengig davon, was die Lizenz erlaubt.
|
||||
modules_off: frozenset[str] = frozenset()
|
||||
|
||||
@property
|
||||
def role(self) -> str:
|
||||
@@ -128,8 +145,14 @@ class LicenseState:
|
||||
|
||||
@property
|
||||
def modules(self) -> frozenset[str]:
|
||||
"""Was die Lizenz freischaltet -- ohne Ruecksicht auf den Schalter."""
|
||||
return self.status.modules if self.status else frozenset()
|
||||
|
||||
@property
|
||||
def modules_in_use(self) -> frozenset[str]:
|
||||
"""Was tatsaechlich laeuft: lizenziert **und** nicht abgeschaltet."""
|
||||
return self.modules - self.modules_off
|
||||
|
||||
@property
|
||||
def customer_name(self) -> str:
|
||||
return str((self.status.customer if self.status else {}).get("name") or "")
|
||||
@@ -179,6 +202,7 @@ class LicenseManager:
|
||||
"""Liest Lizenzdatei und Status neu ein."""
|
||||
with self._lock:
|
||||
state = LicenseState()
|
||||
state.modules_off = self.abgeschaltete_module(conn)
|
||||
state.last_heartbeat_at = str(self.settings.get(conn, SETTING_LAST_HEARTBEAT) or "")
|
||||
state.heartbeat_error = str(self.settings.get(conn, SETTING_HEARTBEAT_ERROR) or "")
|
||||
pending = self._pending(conn)
|
||||
@@ -239,9 +263,51 @@ class LicenseManager:
|
||||
)
|
||||
|
||||
def module_active(self, name: str) -> bool:
|
||||
"""Ist ein Vollmodul (dhcp/fileshare/maintenance) freigeschaltet?"""
|
||||
"""Ist ein Vollmodul (dhcp/fileshare/maintenance) in Betrieb?
|
||||
|
||||
Zwei Bedingungen, und beide muessen gelten: die Lizenz muss es
|
||||
freischalten, und der Betreiber darf es nicht abgeschaltet haben. Diese
|
||||
eine Funktion fragen die Navigation, jede Route und die Hintergrund-
|
||||
aufgaben -- darum sitzt der Schalter hier und nicht in einer Vorlage.
|
||||
"""
|
||||
return (
|
||||
self.active()
|
||||
and name in self._state.modules
|
||||
and name not in self._state.modules_off
|
||||
)
|
||||
|
||||
def module_licensed(self, name: str) -> bool:
|
||||
"""Schaltet die Lizenz das Modul frei? (Ohne den Schalter vor Ort.)"""
|
||||
return self.active() and name in self._state.modules
|
||||
|
||||
def abgeschaltete_module(self, conn: sqlite3.Connection) -> frozenset[str]:
|
||||
"""Liest die Liste aus den Einstellungen."""
|
||||
gespeichert = self.settings.get(conn, SETTING_MODULES_OFF) or []
|
||||
if isinstance(gespeichert, str): # Altbestand: ein einzelner Name
|
||||
gespeichert = [gespeichert]
|
||||
return frozenset(str(name) for name in gespeichert)
|
||||
|
||||
def module_abschalten(
|
||||
self, conn: sqlite3.Connection, namen: Iterable[str], *, actor: str = ""
|
||||
) -> tuple[frozenset[str], frozenset[str]]:
|
||||
"""Setzt die Liste der abgeschalteten Module neu.
|
||||
|
||||
Gibt ``(eingeschaltet, abgeschaltet)`` zurueck -- also nur das, was sich
|
||||
wirklich geaendert hat; der Aufrufer schreibt daraus seinen
|
||||
Protokolleintrag.
|
||||
|
||||
Der Zustand wird hier nicht neu eingelesen, sondern nur an dieser einen
|
||||
Stelle nachgezogen: ein voller Neuaufbau wuerde die Lizenzdatei erneut
|
||||
pruefen, obwohl sich an ihr nichts geaendert hat -- und der Schalter ist
|
||||
das Einzige, was jetzt anders ist.
|
||||
"""
|
||||
vorher = self.abgeschaltete_module(conn)
|
||||
nachher = frozenset(str(name) for name in namen)
|
||||
self.settings.set(conn, SETTING_MODULES_OFF, sorted(nachher), actor=actor)
|
||||
with self._lock:
|
||||
self._state.modules_off = nachher
|
||||
return vorher - nachher, nachher - vorher
|
||||
|
||||
def feature_allowed(self, feature: str) -> bool:
|
||||
"""Lizenzpflichtige Einzelfunktion.
|
||||
|
||||
@@ -257,8 +323,8 @@ class LicenseManager:
|
||||
def require(self, feature: str) -> None:
|
||||
if not self.feature_allowed(feature):
|
||||
raise LicenseError(
|
||||
f"{LICENSED_FEATURES.get(feature, feature)} ist ohne gueltige, "
|
||||
"aktivierte Lizenz nicht moeglich."
|
||||
f"{LICENSED_FEATURES.get(feature, feature)} ist ohne gültige, "
|
||||
"aktivierte Lizenz nicht möglich."
|
||||
)
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
@@ -284,7 +350,7 @@ class LicenseManager:
|
||||
try:
|
||||
payload = json.loads(text)
|
||||
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:
|
||||
document, client_key = lic.split_bundle(payload)
|
||||
@@ -294,20 +360,20 @@ class LicenseManager:
|
||||
try:
|
||||
lic.verify_license(document) # innere Konsistenz
|
||||
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 "")
|
||||
known = self.trusted_keys(conn)
|
||||
if known and master_pubkey not in set(known) and not allow_new_issuer:
|
||||
raise LicenseError(
|
||||
"Diese Lizenz wurde von einem anderen Lizenzserver ausgestellt "
|
||||
f"(Schluessel {lic.key_id(master_pubkey)}). Ein Administrator muss den "
|
||||
"Wechsel des Ausstellers ausdruecklich bestaetigen."
|
||||
f"(Schlüssel {lic.key_id(master_pubkey)}). Ein Administrator muss den "
|
||||
"Wechsel des Ausstellers ausdrücklich bestätigen."
|
||||
)
|
||||
|
||||
if not client_key and not self.key_path.is_file():
|
||||
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."
|
||||
)
|
||||
|
||||
@@ -332,7 +398,7 @@ class LicenseManager:
|
||||
actor=actor,
|
||||
action="license.issuer_pinned",
|
||||
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",
|
||||
)
|
||||
|
||||
@@ -397,11 +463,11 @@ class LicenseManager:
|
||||
self, action: str, *, hostname: str = "", report: dict[str, Any] | None = None
|
||||
) -> dict[str, Any]:
|
||||
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()
|
||||
if not key:
|
||||
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(
|
||||
action, self._state.document, key, hostname=hostname, report=report
|
||||
@@ -442,8 +508,8 @@ class LicenseManager:
|
||||
)
|
||||
else:
|
||||
reason = (
|
||||
"In dieser Lizenz ist kein Lizenzserver hinterlegt -- sie kann sich nur "
|
||||
"ueber den Offline-Weg aktivieren."
|
||||
"In dieser Lizenz ist kein Lizenzserver hinterlegt – sie kann sich nur "
|
||||
"über den Offline-Weg aktivieren."
|
||||
)
|
||||
|
||||
# Offline-Weg: Anfrage merken -- **mit** dem Code. Zwischen Anfrage und
|
||||
@@ -475,7 +541,7 @@ class LicenseManager:
|
||||
"code": code,
|
||||
"message": (
|
||||
(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."
|
||||
),
|
||||
}
|
||||
@@ -527,7 +593,7 @@ class LicenseManager:
|
||||
current_seq=current_seq,
|
||||
)
|
||||
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 "")
|
||||
detail = str(response.get("detail") or "")
|
||||
@@ -597,7 +663,7 @@ class LicenseManager:
|
||||
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)
|
||||
# Ein erfolgreicher Heartbeat laeuft alle paar Minuten und ist kein
|
||||
@@ -648,7 +714,7 @@ class LicenseManager:
|
||||
url: str, payload: dict[str, Any], *, timeout: int, ca_pem: str = ""
|
||||
) -> dict[str, Any]:
|
||||
if not url.startswith(("https://", "http://")):
|
||||
raise ValueError("Ungueltiger Endpunkt")
|
||||
raise ValueError("Ungültiger Endpunkt")
|
||||
data = json.dumps(payload).encode("utf-8")
|
||||
request = urllib.request.Request( # noqa: S310 - Schema oben geprueft
|
||||
url,
|
||||
@@ -748,7 +814,7 @@ class LicenseManager:
|
||||
}
|
||||
if not state.valid:
|
||||
return {
|
||||
"label": "Lizenz ungueltig",
|
||||
"label": "Lizenz ungültig",
|
||||
"css_class": "status-pill--danger",
|
||||
"dot": "1",
|
||||
"dot_class": "status-pill__dot--pulse",
|
||||
@@ -773,29 +839,29 @@ class LicenseManager:
|
||||
"dot": "1",
|
||||
"dot_class": "status-pill__dot--pulse",
|
||||
"title": (
|
||||
"Die Kulanzfrist laeuft noch, Funktionen bleiben vorerst verfuegbar."
|
||||
"Die Kulanzfrist läuft noch, Funktionen bleiben vorerst verfügbar."
|
||||
if status.grace_active
|
||||
else "Lizenzpflichtige Funktionen sind abgeschaltet."
|
||||
),
|
||||
}
|
||||
if status.expiring_soon:
|
||||
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",
|
||||
"dot": "1",
|
||||
"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
|
||||
# eine Verlaengerung im Voraus plant, will das Datum sehen, ohne dass es
|
||||
# nach einem Problem aussieht.
|
||||
if status.days_left is not None and 0 < status.days_left <= EXPIRY_HINT_DAYS:
|
||||
return {
|
||||
"label": f"Lizenz laeuft in {status.days_left} Tagen ab",
|
||||
"label": f"Lizenz läuft in {status.days_left} Tagen ab",
|
||||
"css_class": "",
|
||||
"dot": "1",
|
||||
"dot_class": "",
|
||||
"title": "Nur ein Hinweis -- es ist nichts eingeschraenkt.",
|
||||
"title": "Nur ein Hinweis – es ist nichts eingeschränkt.",
|
||||
}
|
||||
if state.last_heartbeat_at:
|
||||
try:
|
||||
@@ -809,6 +875,6 @@ class LicenseManager:
|
||||
"css_class": "status-pill--warning",
|
||||
"dot": "1",
|
||||
"dot_class": "",
|
||||
"title": "Rein informativ -- es wird nichts abgeschaltet.",
|
||||
"title": "Rein informativ – es wird nichts abgeschaltet.",
|
||||
}
|
||||
return None
|
||||
@@ -94,7 +94,7 @@ def init_app(app: Flask, *, log_dir: Path, debug: bool, json_logs: bool = False)
|
||||
except OSError:
|
||||
pass
|
||||
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.
|
||||
access = logging.getLogger(ACCESS_LOGGER)
|
||||
|
||||
@@ -50,6 +50,12 @@ class Migration:
|
||||
sql: str = ""
|
||||
fn: MigrationFn | None = None
|
||||
allow_integrity_error: bool = False
|
||||
#: Begruendung, falls der Text einer bereits angewendeten Migration
|
||||
#: absichtlich geaendert wurde -- etwa weil eine frische Installation
|
||||
#: mit der neuen Schreibweise starten muss. Ohne Begruendung warnt der
|
||||
#: Lauf, und das soll er auch: ein stiller Umbau einer angewendeten
|
||||
#: Migration ist fast immer ein Versehen.
|
||||
geaendert_weil: str = ""
|
||||
tags: tuple[str, ...] = field(default=())
|
||||
|
||||
@property
|
||||
@@ -175,17 +181,33 @@ class MigrationRunner:
|
||||
record = known.get(migration.version)
|
||||
if record is not None:
|
||||
if record["checksum"] != migration.checksum:
|
||||
logger.warning(
|
||||
"Migration %s/%s (%s) wurde nachtraeglich geaendert "
|
||||
"(Pruefsumme %s != %s). Bereits angewendete Migrationen werden "
|
||||
"nicht erneut ausgefuehrt -- neue Aenderungen brauchen eine neue "
|
||||
"Versionsnummer.",
|
||||
component,
|
||||
migration.version,
|
||||
migration.name,
|
||||
record["checksum"],
|
||||
migration.checksum,
|
||||
)
|
||||
if migration.geaendert_weil:
|
||||
# Mit Begruendung ist es eine Notiz, keine Warnung:
|
||||
# manchmal muss der Text einer laengst angewendeten
|
||||
# Migration mit, damit eine **frische** Installation
|
||||
# denselben Stand erreicht. Wer das Protokoll liest,
|
||||
# soll den Unterschied zu einem Versehen sehen.
|
||||
logger.info(
|
||||
"Migration %s/%s (%s) wurde absichtlich geändert: %s "
|
||||
"Auf dieser Installation ist sie längst angewendet und "
|
||||
"wird nicht erneut ausgeführt.",
|
||||
component,
|
||||
migration.version,
|
||||
migration.name,
|
||||
migration.geaendert_weil,
|
||||
)
|
||||
else:
|
||||
logger.warning(
|
||||
"Migration %s/%s (%s) wurde nachträglich geändert "
|
||||
"(Prüfsumme %s != %s). Bereits angewendete Migrationen werden "
|
||||
"nicht erneut ausgeführt – neue Änderungen brauchen eine neue "
|
||||
"Versionsnummer.",
|
||||
component,
|
||||
migration.version,
|
||||
migration.name,
|
||||
record["checksum"],
|
||||
migration.checksum,
|
||||
)
|
||||
continue
|
||||
|
||||
try:
|
||||
@@ -210,7 +232,7 @@ class MigrationRunner:
|
||||
raise
|
||||
logger.error(
|
||||
"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.",
|
||||
component,
|
||||
migration.version,
|
||||
|
||||
@@ -16,10 +16,10 @@ A = Action
|
||||
USERS = Resource(
|
||||
key="users",
|
||||
label="Benutzer",
|
||||
actions=(A.VIEW, A.CREATE, A.EDIT, A.DELETE),
|
||||
actions=(A.READ, A.CREATE, A.WRITE, A.DELETE),
|
||||
hint="Konten anlegen, sperren, Gruppen zuordnen",
|
||||
action_hints={
|
||||
A.EDIT: "Stammdaten, Sperre und Gruppenzuordnung aendern.",
|
||||
A.WRITE: "Stammdaten, Sperre und Gruppenzuordnung ändern.",
|
||||
A.DELETE: "Konto in den Papierkorb legen (Verzeichniskonten sofort entfernen).",
|
||||
},
|
||||
)
|
||||
@@ -27,35 +27,46 @@ USERS = Resource(
|
||||
GROUPS = Resource(
|
||||
key="groups",
|
||||
label="Gruppen und Rechte",
|
||||
actions=(A.VIEW, A.CREATE, A.EDIT, A.DELETE),
|
||||
actions=(A.READ, A.CREATE, A.WRITE, A.DELETE),
|
||||
hint="Rechtematrix pflegen",
|
||||
action_hints={
|
||||
A.EDIT: "Rechte vergeben -- nur Rechte, die man selbst besitzt.",
|
||||
A.WRITE: "Rechte vergeben – nur Rechte, die man selbst besitzt.",
|
||||
},
|
||||
)
|
||||
|
||||
SETTINGS_SYSTEM = Resource(
|
||||
key="settings_system",
|
||||
label="Systemeinstellungen",
|
||||
actions=(A.VIEW, A.EDIT, A.EXECUTE),
|
||||
hint="Hostname, Zeitzone, Protokollrotation, Navigation",
|
||||
action_hints={A.EXECUTE: "Systemaktionen ausloesen (Dienste, Netzwerk uebernehmen)."},
|
||||
actions=(A.READ, A.WRITE, A.EXECUTE),
|
||||
hint="Hostname, Zeitzone, Protokollrotation, Sitzungsdauer",
|
||||
action_hints={A.EXECUTE: "Systemaktionen auslösen (Dienste, Netzwerk übernehmen)."},
|
||||
)
|
||||
|
||||
#: Die Reihenfolge des Menues lief unter "Systemaktionen auslösen" mit -- also
|
||||
#: unter demselben Recht wie das Aendern des Hostnamens. Es ist aber das
|
||||
#: harmloseste, was man in der Verwaltung tun kann, und das haeufigste.
|
||||
SETTINGS_NAV = Resource(
|
||||
key="settings_nav",
|
||||
parent="settings_system",
|
||||
label="Navigation",
|
||||
actions=(A.READ, A.WRITE),
|
||||
hint="Reihenfolge der Menüpunkte und ihrer Unterpunkte",
|
||||
)
|
||||
|
||||
SETTINGS_LICENSE = Resource(
|
||||
key="settings_license",
|
||||
label="Lizenz",
|
||||
actions=(A.VIEW, A.EDIT),
|
||||
actions=(A.READ, A.WRITE),
|
||||
hint="Lizenzdatei einspielen, aktivieren, deaktivieren",
|
||||
)
|
||||
|
||||
SETTINGS_DIRECTORY = Resource(
|
||||
key="settings_directory",
|
||||
label="Verzeichnisdienst (LDAP/AD)",
|
||||
actions=(A.VIEW, A.EDIT, A.EXECUTE, A.SECRETS),
|
||||
actions=(A.READ, A.WRITE, A.EXECUTE, A.SECRETS),
|
||||
hint="Anbindung und Gruppen-Zuordnung",
|
||||
action_hints={
|
||||
A.EXECUTE: "Verbindungstest ausfuehren.",
|
||||
A.EXECUTE: "Verbindungstest ausführen.",
|
||||
A.SECRETS: "Bind-Passwort des Dienstkontos setzen.",
|
||||
},
|
||||
)
|
||||
@@ -63,67 +74,78 @@ SETTINGS_DIRECTORY = Resource(
|
||||
SETTINGS_WEB = Resource(
|
||||
key="settings_web",
|
||||
label="Webserver und TLS",
|
||||
actions=(A.VIEW, A.EDIT, A.EXECUTE),
|
||||
actions=(A.READ, A.WRITE, A.EXECUTE),
|
||||
hint="Domain, Zertifikat, HSTS",
|
||||
action_hints={A.EXECUTE: "Konfiguration uebernehmen, Zertifikat anfordern."},
|
||||
action_hints={A.EXECUTE: "Konfiguration übernehmen, Zertifikat anfordern."},
|
||||
)
|
||||
|
||||
SETTINGS_NETWORK = Resource(
|
||||
key="settings_network",
|
||||
label="Netzwerkeinstellungen",
|
||||
actions=(A.VIEW, A.EDIT),
|
||||
actions=(A.READ, A.WRITE),
|
||||
hint="IP-Adresse, Gateway, DNS des Hosts",
|
||||
)
|
||||
|
||||
BACKUP = Resource(
|
||||
key="backup",
|
||||
label="Sicherung und Uebertragung",
|
||||
actions=(A.VIEW, A.EXECUTE, A.EXPORT, A.SECRETS),
|
||||
hint="Verschluesselter Export und Import",
|
||||
#: Sichern und Einspielen sind zwei Zeilen, weil es zwei Vorgaenge mit sehr
|
||||
#: verschiedenen Folgen sind: eine Sicherung zu erzeugen aendert nichts, eine
|
||||
#: einzuspielen aendert alles. Sie liefen unter einem Recht, und wer eine
|
||||
#: Sicherung ziehen durfte, durfte damit auch eine fremde einspielen.
|
||||
BACKUP_EXPORT = Resource(
|
||||
key="backup_export",
|
||||
label="Sicherung erstellen",
|
||||
actions=(A.READ, A.EXPORT, A.SECRETS),
|
||||
hint="Verschlüsselter Export des Datenstands",
|
||||
action_hints={
|
||||
A.EXECUTE: "Eine Sicherung einspielen.",
|
||||
A.EXPORT: "Sicherung erzeugen und herunterladen.",
|
||||
A.SECRETS: "Zugangsdaten in die Sicherung einschliessen.",
|
||||
A.SECRETS: "Zugangsdaten in die Sicherung einschließen.",
|
||||
},
|
||||
)
|
||||
|
||||
BACKUP_IMPORT = Resource(
|
||||
key="backup_import",
|
||||
label="Sicherung einspielen",
|
||||
actions=(A.READ, A.EXECUTE),
|
||||
hint="Prüfen, übernehmen, verwerfen",
|
||||
action_hints={A.EXECUTE: "Eine geprüfte Sicherung anwenden."},
|
||||
)
|
||||
|
||||
TRASH = Resource(
|
||||
key="trash",
|
||||
label="Papierkorb",
|
||||
actions=(A.VIEW, A.EDIT, A.DELETE),
|
||||
hint="Geloeschtes wiederherstellen oder endgueltig entfernen",
|
||||
actions=(A.READ, A.WRITE, A.DELETE),
|
||||
hint="Gelöschtes wiederherstellen oder endgültig entfernen",
|
||||
action_hints={
|
||||
A.EDIT: "Eintraege wiederherstellen.",
|
||||
A.DELETE: "Eintraege endgueltig entfernen.",
|
||||
A.WRITE: "Einträge wiederherstellen.",
|
||||
A.DELETE: "Einträge endgültig entfernen.",
|
||||
},
|
||||
)
|
||||
|
||||
DIAGNOSTICS = Resource(
|
||||
key="diagnostics",
|
||||
label="Diagnose",
|
||||
actions=(A.VIEW,),
|
||||
hint="Schemastand, Protokollintegritaet, Schluesselrechte",
|
||||
actions=(A.READ,),
|
||||
hint="Schemastand, Protokollintegrität, Schlüsselrechte",
|
||||
)
|
||||
|
||||
LOGS_LIVE = Resource(
|
||||
key="logs_live",
|
||||
label="Laufendes Protokoll",
|
||||
actions=(A.VIEW,),
|
||||
label="Live-Protokoll",
|
||||
actions=(A.READ,),
|
||||
)
|
||||
|
||||
LOGS_HISTORY = Resource(
|
||||
key="logs_history",
|
||||
label="Protokollverlauf",
|
||||
actions=(A.VIEW, A.DELETE, A.EXPORT),
|
||||
actions=(A.READ, A.DELETE, A.EXPORT),
|
||||
hint="Rotierte und archivierte Protokolle",
|
||||
action_hints={A.DELETE: "Archivierte Tage vom Server entfernen."},
|
||||
)
|
||||
|
||||
LOGS_AUDIT = Resource(
|
||||
key="logs_audit",
|
||||
label="Aenderungsprotokoll",
|
||||
actions=(A.VIEW, A.EXPORT),
|
||||
hint="Wer hat wann was geaendert",
|
||||
label="Änderungsprotokoll",
|
||||
actions=(A.READ, A.EXPORT),
|
||||
hint="Wer hat wann was geändert",
|
||||
)
|
||||
|
||||
|
||||
@@ -139,11 +161,13 @@ def administration_area(*extra: Resource) -> Area:
|
||||
USERS,
|
||||
GROUPS,
|
||||
SETTINGS_SYSTEM,
|
||||
SETTINGS_NAV,
|
||||
SETTINGS_LICENSE,
|
||||
SETTINGS_DIRECTORY,
|
||||
SETTINGS_WEB,
|
||||
SETTINGS_NETWORK,
|
||||
BACKUP,
|
||||
BACKUP_EXPORT,
|
||||
BACKUP_IMPORT,
|
||||
TRASH,
|
||||
DIAGNOSTICS,
|
||||
*extra,
|
||||
|
||||
@@ -0,0 +1,171 @@
|
||||
"""Alte Rechteschluessel auf neue -- an einer Stelle, nicht an dreien.
|
||||
|
||||
Mit 2.8.0 heisst das Aktionsvokabular durchgehend wie das, was es tut:
|
||||
``view`` wurde ``read``, ``edit`` wurde ``write``. Danach steht in der Matrix
|
||||
"Write" und im Aenderungsprotokoll ``write``, nicht zweierlei.
|
||||
|
||||
Drei Stellen brauchen die Zuordnung, und darum steht sie hier und nicht dort:
|
||||
|
||||
* die **Migration**, die bestehende Gruppenzeilen umschreibt,
|
||||
* der **Import**, dem eine Sicherung aus 2.7.x untergeschoben wird -- ohne die
|
||||
Zuordnung landen dort lauter unbekannte Schluessel, und
|
||||
:meth:`PermissionTree.sanitize` wirft Unbekanntes absichtlich **still** weg.
|
||||
Aus einer Sicherung wuerden Gruppen ohne Rechte.
|
||||
* der **Test**, der prueft, dass kein alter Schluessel mehr im Code steht.
|
||||
|
||||
Warum nicht dreimal dieselbe Ersetzung tippen: weil die dritte Kopie beim
|
||||
naechsten Umbau vergessen wird. Genau so entstand das Muster bei den Adressen
|
||||
(:mod:`tesm_core.legacy_urls`).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
#: Aktion alt -> neu. Die Ressource bleibt, wie sie heisst.
|
||||
UMBENANNTE_AKTIONEN: dict[str, str] = {
|
||||
"view": "read",
|
||||
"edit": "write",
|
||||
}
|
||||
|
||||
#: Ein altes Recht, das in mehrere neue zerfaellt -- ein Recht je Funktion.
|
||||
#:
|
||||
#: Die Aufteilung ist **additiv**: wer das alte Recht hatte, bekommt alle
|
||||
#: neuen. Niemand verliert etwas; ab jetzt laesst sich jedes Stueck einzeln
|
||||
#: vergeben. Der umgekehrte Weg waere auch denkbar gewesen -- nur das
|
||||
#: Harmloseste uebernehmen --, waere aber ein Update, das Leuten Rechte
|
||||
#: wegnimmt, ohne dass jemand es entschieden hat.
|
||||
#:
|
||||
#: Die Schluessel stehen fuer beide Anwendungen in einer Tabelle. Auf einer
|
||||
#: TESM-Datenbank tut die Zeile fuer ``licenses.write`` nichts, weil sie dort
|
||||
#: in keiner Gruppe steht -- und umgekehrt. Zwei getrennte Tabellen waeren
|
||||
#: zwei Stellen, die man pflegen muss.
|
||||
GETEILTE_RECHTE: dict[str, tuple[str, ...]] = {
|
||||
# Drei Neustartwege mit drei verschiedenen Folgen.
|
||||
"devices.execute": (
|
||||
"device_restart_poe.read",
|
||||
"device_restart_poe.execute",
|
||||
"device_restart_ssh.read",
|
||||
"device_restart_ssh.execute",
|
||||
"device_restart_rpc.read",
|
||||
"device_restart_rpc.execute",
|
||||
),
|
||||
# Die Switch-Konsole war "Switch ausfuehren".
|
||||
"switches.execute": ("switch_console.read", "switch_console.execute"),
|
||||
# Vier Funktionen: aktualisieren, neu starten, Werkzeug installieren --
|
||||
# und die Client-Konsole, die jetzt bei den Clients steht.
|
||||
"maintenance.read": (
|
||||
"servicing_update.read",
|
||||
"servicing_reboot.read",
|
||||
"servicing_tool.read",
|
||||
"device_console.read",
|
||||
),
|
||||
"maintenance.execute": (
|
||||
"servicing_update.read",
|
||||
"servicing_update.execute",
|
||||
"servicing_reboot.read",
|
||||
"servicing_reboot.execute",
|
||||
"servicing_tool.read",
|
||||
"servicing_tool.execute",
|
||||
"device_console.read",
|
||||
"device_console.execute",
|
||||
),
|
||||
# Elf Funktionen an einem Recht.
|
||||
"dhcp.read": (
|
||||
"dhcp_subnets.read",
|
||||
"dhcp_options.read",
|
||||
"dhcp_reservations.read",
|
||||
"dhcp_config.read",
|
||||
"dhcp_service.read",
|
||||
),
|
||||
"dhcp.write": (
|
||||
"dhcp_subnets.read",
|
||||
"dhcp_subnets.create",
|
||||
"dhcp_subnets.write",
|
||||
"dhcp_subnets.delete",
|
||||
"dhcp_options.read",
|
||||
"dhcp_options.write",
|
||||
"dhcp_reservations.read",
|
||||
"dhcp_reservations.create",
|
||||
"dhcp_reservations.write",
|
||||
"dhcp_reservations.delete",
|
||||
"dhcp_config.read",
|
||||
"dhcp_config.execute",
|
||||
),
|
||||
"dhcp.execute": ("dhcp_service.read", "dhcp_service.execute"),
|
||||
# Sichern und Einspielen.
|
||||
"backup.read": ("backup_export.read", "backup_import.read"),
|
||||
"backup.export": ("backup_export.read", "backup_export.export"),
|
||||
"backup.secrets": ("backup_export.read", "backup_export.secrets"),
|
||||
"backup.execute": ("backup_import.read", "backup_import.execute"),
|
||||
# Lizenzserver: "aendern" hiess widerrufen **oder** erneuern -- zwei
|
||||
# Eingriffe mit entgegengesetzter Wirkung unter einem Haken.
|
||||
"licenses.create": ("license_issue.read", "license_issue.execute"),
|
||||
"licenses.write": (
|
||||
"license_renew.read",
|
||||
"license_renew.execute",
|
||||
"license_revoke.read",
|
||||
"license_revoke.execute",
|
||||
),
|
||||
"licenses.execute": ("license_activation.read", "license_activation.execute"),
|
||||
}
|
||||
|
||||
|
||||
#: Ein Recht, das **bleibt** und bisher noch etwas anderes mitgab.
|
||||
#:
|
||||
#: Der Unterschied zu :data:`GETEILTE_RECHTE` ist keine Formsache: dort ist der
|
||||
#: alte Schluessel verschwunden und muss ersetzt werden, hier gilt er weiter
|
||||
#: und bekommt nur Gesellschaft. Beides in eine Tabelle zu werfen hiesse, in
|
||||
#: der Migration ein Recht zu loeschen, das es noch gibt -- und im Test nicht
|
||||
#: mehr pruefen zu koennen, welcher Fall gemeint war.
|
||||
ERWEITERTE_RECHTE: dict[str, tuple[str, ...]] = {
|
||||
# Die Navigationsreihenfolge lief unter "Systemaktionen ausloesen" mit.
|
||||
"settings_system.execute": ("settings_nav.read", "settings_nav.write"),
|
||||
# Einen Host-Schluessel freizugeben hing an den Stammdaten.
|
||||
"devices.write": ("hostkeys.read", "hostkeys.execute"),
|
||||
"switches.write": ("hostkeys.read", "hostkeys.execute"),
|
||||
# Umbenennen war auch "Freigaben zuordnen".
|
||||
"fileshare.write": (
|
||||
"fileshare_mappings.read",
|
||||
"fileshare_mappings.create",
|
||||
"fileshare_mappings.write",
|
||||
"fileshare_mappings.delete",
|
||||
),
|
||||
# Ausgefuellt ablegen war auch "unterschreiben".
|
||||
"fileshare_pdf.create": ("fileshare_signature.read", "fileshare_signature.execute"),
|
||||
}
|
||||
|
||||
|
||||
def neue_schluessel(alt: str) -> tuple[str, ...]:
|
||||
"""Was aus einem alten Rechteschluessel wird -- eins oder mehrere.
|
||||
|
||||
Unbekanntes kommt unveraendert zurueck: ob ein Schluessel gilt, entscheidet
|
||||
der Rechtebaum, nicht diese Tabelle. Sie benennt nur um.
|
||||
"""
|
||||
if alt in GETEILTE_RECHTE:
|
||||
return GETEILTE_RECHTE[alt]
|
||||
ressource, punkt, aktion = alt.rpartition(".")
|
||||
if not punkt:
|
||||
return (alt,)
|
||||
neu = UMBENANNTE_AKTIONEN.get(aktion)
|
||||
selbst = f"{ressource}.{neu}" if neu else alt
|
||||
# Ein Recht, das bleibt, nimmt mit, was es bisher mitgab -- sonst verliert
|
||||
# jemand beim Update genau die Funktion, die vorher an diesem Haken hing.
|
||||
return (selbst, *ERWEITERTE_RECHTE.get(selbst, ()))
|
||||
|
||||
|
||||
def umgeschrieben(rechte: object) -> list[str]:
|
||||
"""Eine ganze Liste umschreiben, ohne Dubletten und in fester Reihenfolge."""
|
||||
if not isinstance(rechte, (list, tuple, set, frozenset)):
|
||||
return []
|
||||
ergebnis: set[str] = set()
|
||||
for recht in rechte:
|
||||
ergebnis.update(neue_schluessel(str(recht)))
|
||||
return sorted(ergebnis)
|
||||
|
||||
|
||||
__all__ = [
|
||||
"UMBENANNTE_AKTIONEN",
|
||||
"GETEILTE_RECHTE",
|
||||
"ERWEITERTE_RECHTE",
|
||||
"neue_schluessel",
|
||||
"umgeschrieben",
|
||||
]
|
||||
@@ -1,6 +1,14 @@
|
||||
"""Rechtemodell: Bereiche, Ressourcen, Aktionen.
|
||||
|
||||
Das Vorgaengermodell kannte fuenf Spalten (R/W/E/D/X), wobei ``E`` ("Aendern")
|
||||
Das Vokabular heisst seit 2.8.0 durchgehend wie das, was es tut: **Read,
|
||||
Create, Write, Delete, Execute, Export, Secrets**. Bis dahin hiessen zwei
|
||||
Aktionen anders (``view``, ``edit``), waehrend die Matrix "Bearbeiten" zeigte
|
||||
und die Kurzfassung "U" -- dieselbe Aktion in drei Schreibweisen. Der
|
||||
Kill-Switch eines Bereichs heisst entsprechend ``<bereich>.read``.
|
||||
:mod:`tesm_core.rbac.legacy_permissions` schreibt alte Schluessel um; die
|
||||
Migration ``core/4`` tut das fuer bestehende Gruppen.
|
||||
|
||||
Das Modell vor 2.0 kannte fuenf Spalten (R/W/E/D/X), wobei ``E`` ("Aendern")
|
||||
das Loeschen **mit abdeckte** und ``D``/``X`` je nach Zeile etwas voellig anderes
|
||||
bedeuteten (Dashboard-Neustart bzw. Export). Das war kompakt, aber:
|
||||
|
||||
@@ -15,7 +23,7 @@ Aktionen eine Ressource ueberhaupt kennt, entscheidet die Ressource; die Matrix
|
||||
rendert nur die Spalten, die im jeweiligen Bereich wirklich vorkommen.
|
||||
|
||||
Beibehalten wird der bewaehrte **Kill-Switch**: jeder Bereich hat ein
|
||||
``<bereich>.view``-Recht. Ohne dieses greift kein einziges Recht innerhalb des
|
||||
``<bereich>.read``-Recht. Ohne dieses greift kein einziges Recht innerhalb des
|
||||
Bereichs -- unabhaengig davon, was einzeln angehakt ist. Das erlaubt es, einer
|
||||
Gruppe einen ganzen Bereich mit einem Klick zu entziehen, ohne die fein
|
||||
eingestellten Einzelrechte zu verlieren.
|
||||
@@ -29,38 +37,68 @@ from typing import Iterable, Mapping, Sequence
|
||||
|
||||
|
||||
class Action(str, Enum):
|
||||
"""Aktionsvokabular. Bedeutung ist ueberall dieselbe -- keine Ausnahmen."""
|
||||
"""Aktionsvokabular. Bedeutung ist ueberall dieselbe -- keine Ausnahmen.
|
||||
|
||||
VIEW = "view"
|
||||
Die Namen sind die englischen, weil sie in jedem Rechtesystem dieselben
|
||||
sind und keiner Uebersetzung bedarf: wer ``write`` liest, weiss es. Zwei
|
||||
hiessen bis 2.7.x anders (``view``, ``edit``) -- in der Matrix stand
|
||||
"Bearbeiten" und im Protokoll ``edit``, also zweierlei fuer dasselbe.
|
||||
:mod:`tesm_core.rbac.legacy_permissions` schreibt die alten Schluessel um.
|
||||
"""
|
||||
|
||||
READ = "read"
|
||||
CREATE = "create"
|
||||
EDIT = "edit"
|
||||
WRITE = "write"
|
||||
DELETE = "delete"
|
||||
EXECUTE = "execute"
|
||||
EXPORT = "export"
|
||||
SECRETS = "secrets"
|
||||
|
||||
|
||||
#: Kuerzel und Erklaerung je Aktion -- Kopfzeile der Rechtematrix.
|
||||
ACTION_META: dict[Action, tuple[str, str, str]] = {
|
||||
Action.VIEW: ("R", "Ansehen", "Datensaetze und Seiten dieses Bereichs lesen."),
|
||||
Action.CREATE: ("C", "Anlegen", "Neue Datensaetze erstellen."),
|
||||
Action.EDIT: ("U", "Bearbeiten", "Bestehende Datensaetze aendern -- ohne Loeschen."),
|
||||
Action.DELETE: ("D", "Loeschen", "Datensaetze loeschen bzw. endgueltig entfernen."),
|
||||
Action.EXECUTE: ("X", "Ausfuehren", "Aktionen ausloesen (Neustart, Dienste, Jobs, Import)."),
|
||||
Action.EXPORT: ("E", "Exportieren", "Daten aus dem System heraustragen (Datei-Download)."),
|
||||
#: Kuerzel, englischer Name, deutsche Beschriftung, Erklaerung.
|
||||
#:
|
||||
#: Beides hat seinen Ort: die Matrix fuehrt die Spalte unter ihrem englischen
|
||||
#: Namen (das ist das Vokabular, und es steht so auch im Schluessel), die
|
||||
#: Kleinschrift darunter und jeder Satz in der Oberflaeche bleiben deutsch.
|
||||
ACTION_META: dict[Action, tuple[str, str, str, str]] = {
|
||||
Action.READ: ("R", "Read", "ansehen", "Datensätze und Seiten dieses Bereichs ansehen."),
|
||||
Action.CREATE: ("C", "Create", "anlegen", "Neue Datensätze anlegen."),
|
||||
Action.WRITE: (
|
||||
"W",
|
||||
"Write",
|
||||
"ändern",
|
||||
"Bestehende Datensätze ändern – ohne Anlegen, ohne Löschen.",
|
||||
),
|
||||
Action.DELETE: ("D", "Delete", "löschen", "Datensätze löschen bzw. endgültig entfernen."),
|
||||
Action.EXECUTE: (
|
||||
"X",
|
||||
"Execute",
|
||||
"auslösen",
|
||||
"Aktionen auslösen (Neustart, Dienste, Aufträge, Import).",
|
||||
),
|
||||
Action.EXPORT: (
|
||||
"E",
|
||||
"Export",
|
||||
"mitnehmen",
|
||||
"Daten aus dem System heraustragen (Datei-Download).",
|
||||
),
|
||||
Action.SECRETS: (
|
||||
"S",
|
||||
"Geheimnisse",
|
||||
"Passwoerter und Schluessel im Klartext sehen oder mitnehmen.",
|
||||
"Secrets",
|
||||
"Klartext",
|
||||
"Passwörter und Schlüssel im Klartext sehen oder mitnehmen.",
|
||||
),
|
||||
}
|
||||
|
||||
#: Aktionen, die zusaetzlich eine frische Passworteingabe verlangen (sudo-Modus).
|
||||
SENSITIVE_ACTIONS: frozenset[Action] = frozenset({Action.SECRETS, Action.EXPORT})
|
||||
|
||||
#: ``view`` ist Voraussetzung fuer alles andere in derselben Zeile.
|
||||
#: ``read`` ist Voraussetzung fuer alles andere in derselben Zeile. Umgekehrt
|
||||
#: gilt nichts: wer anlegen darf, darf damit nicht aendern, und wer aendern
|
||||
#: darf, nicht anlegen. Das ist die Trennung, die man in einer
|
||||
#: Verwaltungsanwendung am haeufigsten braucht.
|
||||
IMPLIED_BY: dict[Action, tuple[Action, ...]] = {
|
||||
action: (Action.VIEW,) for action in Action if action is not Action.VIEW
|
||||
action: (Action.READ,) for action in Action if action is not Action.READ
|
||||
}
|
||||
|
||||
|
||||
@@ -74,6 +112,32 @@ class Resource:
|
||||
hint: str = ""
|
||||
#: Abweichende Erklaerung einzelner Aktionen (z. B. was "Ausfuehren" hier meint).
|
||||
action_hints: Mapping[Action, str] = field(default_factory=dict)
|
||||
#: Zu welcher Zeile diese gehoert -- rein fuer die Darstellung.
|
||||
#:
|
||||
#: Aus einer Zeile "Clients" sind sechs geworden (Stammdaten, drei
|
||||
#: Neustartwege, Konsole, Host-Schluessel). Untereinander und alle gleich
|
||||
#: eingerueckt liest man sie als sechs unabhaengige Dinge; eingerueckt
|
||||
#: unter ihrer Hauptzeile sieht man, dass sie zusammengehoeren. Auf die
|
||||
#: Rechte hat das keine Wirkung: jede Zeile bleibt einzeln vergebbar, und
|
||||
#: die Hauptzeile ist **nicht** Voraussetzung fuer ihre Unterzeilen.
|
||||
parent: str = ""
|
||||
#: Aktionen, die hier **keine** frische Passwortbestaetigung verlangen,
|
||||
#: obwohl sie in SENSITIVE_ACTIONS stehen. Bewusst hier und nicht als
|
||||
#: stilles Weglassen in der Route: eine Ausnahme von einer
|
||||
#: Sicherheitsregel gehoert dorthin, wo man sie sieht und begruenden muss.
|
||||
ohne_wiederanmeldung: tuple[Action, ...] = ()
|
||||
|
||||
@property
|
||||
def short_label(self) -> str:
|
||||
"""Beschriftung ohne den Namen der Hauptzeile.
|
||||
|
||||
Unter "Clients" steht "Neustart über PoE" und nicht "Clients: Neustart
|
||||
über PoE" -- das Wort stand sonst siebenmal untereinander. Ausserhalb
|
||||
der Matrix (Protokoll, Meldungen) bleibt der vollstaendige Name.
|
||||
"""
|
||||
if self.parent and ": " in self.label:
|
||||
return self.label.split(": ", 1)[1]
|
||||
return self.label
|
||||
|
||||
def permission(self, action: Action) -> str:
|
||||
return f"{self.key}.{action.value}"
|
||||
@@ -96,8 +160,9 @@ class Area:
|
||||
hint: str = ""
|
||||
|
||||
@property
|
||||
def view_key(self) -> str:
|
||||
return f"{self.key}.view"
|
||||
def read_key(self) -> str:
|
||||
"""Der Kill-Switch des Bereichs. Ohne ihn greift keine Zeile darunter."""
|
||||
return f"{self.key}.read"
|
||||
|
||||
def columns(self) -> tuple[Action, ...]:
|
||||
"""Nur die Aktionen, die in diesem Bereich tatsaechlich vorkommen."""
|
||||
@@ -123,45 +188,92 @@ class PermissionTree:
|
||||
self._parent: dict[str, str] = {}
|
||||
self._all: list[str] = []
|
||||
self._resources: dict[str, tuple[Area, Resource]] = {}
|
||||
self._verwaltung: frozenset[str] | None = None
|
||||
|
||||
seen_keys: set[str] = set()
|
||||
for area in self.areas:
|
||||
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)
|
||||
self._by_key[area.key] = area
|
||||
self._all.append(area.view_key)
|
||||
self._all.append(area.read_key)
|
||||
for resource in area.resources:
|
||||
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)
|
||||
if Action.VIEW not in resource.actions:
|
||||
if Action.READ not in resource.actions:
|
||||
raise ValueError(
|
||||
f"Ressource {resource.key} muss die Aktion 'view' anbieten -- "
|
||||
"sie ist Voraussetzung fuer jede andere Aktion."
|
||||
f"Ressource {resource.key} muss die Aktion 'read' anbieten – "
|
||||
"sie ist Voraussetzung für jede andere Aktion."
|
||||
)
|
||||
self._resources[resource.key] = (area, resource)
|
||||
for permission in resource.permissions():
|
||||
self._all.append(permission)
|
||||
self._parent[permission] = area.view_key
|
||||
self._parent[permission] = area.read_key
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
|
||||
def all_keys(self) -> tuple[str, ...]:
|
||||
return tuple(self._all)
|
||||
|
||||
#: Der Bereich, den ein echter Administrator immer ganz hat.
|
||||
#:
|
||||
#: Nicht "alles": das Kennzeichen ``is_admin`` gab bisher jede Pruefung
|
||||
#: frei, auch Geraete, DHCP und Dateifreigaben. Ein Administrator ist aber
|
||||
#: jemand, der die **Anlage** verwaltet -- Benutzer, Rechte,
|
||||
#: Einstellungen --, und nicht automatisch jemand, der Maschinen neu
|
||||
#: startet oder in Freigaben liest. Diese Rechte holt er sich ueber eine
|
||||
#: Gruppe, und dann steht es im Aenderungsprotokoll.
|
||||
VERWALTUNGSBEREICH = "administration"
|
||||
|
||||
def verwaltungsrechte(self) -> frozenset[str]:
|
||||
"""Alle Rechte des Verwaltungsbereichs, samt seinem Kill-Switch.
|
||||
|
||||
Einmal gerechnet und gemerkt: die Antwort haengt nur am Baum, und der
|
||||
entsteht beim Import.
|
||||
"""
|
||||
if self._verwaltung is None:
|
||||
bereich = self._by_key.get(self.VERWALTUNGSBEREICH)
|
||||
if bereich is None:
|
||||
self._verwaltung = frozenset()
|
||||
else:
|
||||
rechte = {bereich.read_key}
|
||||
for resource in bereich.resources:
|
||||
rechte.update(resource.permissions())
|
||||
self._verwaltung = frozenset(rechte)
|
||||
return self._verwaltung
|
||||
|
||||
def area(self, key: str) -> Area | None:
|
||||
return self._by_key.get(key)
|
||||
|
||||
def parent_view_key(self, permission: str) -> str | None:
|
||||
def parent_read_key(self, permission: str) -> str | None:
|
||||
return self._parent.get(permission)
|
||||
|
||||
def resource(self, key: str) -> tuple[Area, Resource] | None:
|
||||
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:
|
||||
"""Menschenlesbare Beschreibung eines Rechteschluessels -- fuer Audit-Logs."""
|
||||
if permission.endswith(".view") and permission[:-5] in self._by_key:
|
||||
if permission.endswith(".read") and permission[:-5] in self._by_key:
|
||||
return f"Bereich {self._by_key[permission[:-5]].label}: Zugang"
|
||||
resource_key, _, action_value = permission.rpartition(".")
|
||||
entry = self._resources.get(resource_key)
|
||||
@@ -172,7 +284,10 @@ class PermissionTree:
|
||||
action = Action(action_value)
|
||||
except ValueError:
|
||||
return permission
|
||||
return f"{resource.label}: {ACTION_META[action][1]}"
|
||||
# Die deutsche Beschriftung, nicht der englische Name: dieser Text
|
||||
# landet in Saetzen ("Sie koennen kein Recht vergeben ...") und im
|
||||
# Aenderungsprotokoll, und die sind deutsch.
|
||||
return f"{resource.label}: {ACTION_META[action][2]}"
|
||||
|
||||
def sanitize(self, permissions: Iterable[str]) -> list[str]:
|
||||
"""Wirft unbekannte Rechte weg und ergaenzt implizierte.
|
||||
@@ -180,7 +295,7 @@ class PermissionTree:
|
||||
Regeln:
|
||||
* Ein Recht, das nicht im Baum steht, wird verworfen (kein stilles
|
||||
Weiterschleppen von Altrechten nach einem Refactoring).
|
||||
* Jede Aktion impliziert ``view`` derselben Zeile.
|
||||
* Jede Aktion impliziert ``read`` derselben Zeile -- aber nur diese.
|
||||
* Existiert irgendein Recht eines Bereichs, wird der Kill-Switch des
|
||||
Bereichs mitgesetzt -- sonst waere die Auswahl wirkungslos und der
|
||||
Bearbeiter wuerde sich wundern.
|
||||
@@ -200,13 +315,13 @@ class PermissionTree:
|
||||
result.add(candidate)
|
||||
for area in self.areas:
|
||||
if any(p in result for resource in area.resources for p in resource.permissions()):
|
||||
result.add(area.view_key)
|
||||
result.add(area.read_key)
|
||||
return sorted(result)
|
||||
|
||||
def effective(self, permissions: Iterable[str]) -> set[str]:
|
||||
"""Rechte, die nach Anwendung der Kill-Switches tatsaechlich greifen."""
|
||||
granted = set(permissions)
|
||||
active = {key for key in granted if key.endswith(".view") and key[:-5] in self._by_key}
|
||||
active = {key for key in granted if key.endswith(".read") and key[:-5] in self._by_key}
|
||||
return {
|
||||
key
|
||||
for key in granted
|
||||
|
||||
@@ -29,7 +29,7 @@ from typing import Any, Iterable, Sequence
|
||||
|
||||
from ..db import Database
|
||||
from ..security import passwords
|
||||
from .model import Action, PermissionTree, RolePreset
|
||||
from .model import ACTION_META, Action, PermissionTree, RolePreset
|
||||
|
||||
|
||||
class PermissionDenied(Exception):
|
||||
@@ -124,7 +124,20 @@ def group_permissions(conn: sqlite3.Connection, group_id: int) -> list[str]:
|
||||
def guard_no_amplification(
|
||||
actor: Any, tree: PermissionTree, added: Iterable[str], *, what: str = "Recht"
|
||||
) -> None:
|
||||
"""Verhindert, dass jemand Rechte vergibt, die er selbst nicht besitzt."""
|
||||
"""Verhindert, dass jemand Rechte vergibt, die er selbst nicht besitzt.
|
||||
|
||||
Ein echter Administrator darf es -- und das ist seit 2.8.0 die bewusst
|
||||
offene Tuer: sein Kennzeichen gibt ihm den Verwaltungsbereich, die
|
||||
Funktionen holt er sich ueber eine Gruppe, auch fuer sich selbst. Der
|
||||
Unterschied zu vorher ist nicht, *ob* er an die Funktionen kommt, sondern
|
||||
dass es dann eine Zeile im Aenderungsprotokoll gibt und in der Matrix zu
|
||||
sehen ist, statt unsichtbar im Kennzeichen zu stecken.
|
||||
|
||||
Fuer alle anderen bleibt die Regel dicht -- auch fuer die Gruppe
|
||||
"Administration", die Benutzer und Gruppen pflegt: sie kann keiner Gruppe
|
||||
ein Recht geben, das sie nicht selbst hat, und sich damit nicht ueber eine
|
||||
neue Gruppe hochstufen.
|
||||
"""
|
||||
if getattr(actor, "is_admin", False):
|
||||
return
|
||||
missing = sorted(p for p in added if not actor.has(p))
|
||||
@@ -132,7 +145,7 @@ def guard_no_amplification(
|
||||
labels = ", ".join(tree.label_for(p) for p in missing[:6])
|
||||
more = "" if len(missing) <= 6 else f" (und {len(missing) - 6} weitere)"
|
||||
raise PermissionDenied(
|
||||
f"Sie koennen kein {what} vergeben, das Sie selbst nicht besitzen: {labels}{more}."
|
||||
f"Sie können kein {what} vergeben, das Sie selbst nicht besitzen: {labels}{more}."
|
||||
)
|
||||
|
||||
|
||||
@@ -160,7 +173,7 @@ def create_group(
|
||||
if existing["deleted_at"]:
|
||||
raise ValidationError(
|
||||
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.")
|
||||
|
||||
@@ -205,7 +218,7 @@ def update_group(
|
||||
|
||||
if name is not None and name.strip() != group.name:
|
||||
if group.is_system:
|
||||
raise PermissionDenied("Systemgruppen koennen nicht umbenannt werden.")
|
||||
raise PermissionDenied("Systemgruppen können nicht umbenannt werden.")
|
||||
clean = name.strip()
|
||||
if not clean:
|
||||
raise ValidationError("Der Gruppenname darf nicht leer sein.")
|
||||
@@ -224,13 +237,13 @@ def update_group(
|
||||
"UPDATE groups SET description=?, updated_at=? WHERE id=?",
|
||||
(description.strip(), _now(), group_id),
|
||||
)
|
||||
changes.append("Beschreibung geaendert")
|
||||
changes.append("Beschreibung geändert")
|
||||
|
||||
if permissions is not None:
|
||||
if group.is_system and not unlock_system:
|
||||
raise PermissionDenied(
|
||||
"Die Rechte dieser Systemgruppe sind gesperrt. "
|
||||
"Sie muessen sie zuerst ausdruecklich freischalten."
|
||||
"Sie müssen sie zuerst ausdrücklich freischalten."
|
||||
)
|
||||
wanted = tree.sanitize(permissions)
|
||||
added = sorted(set(wanted) - set(group.permissions))
|
||||
@@ -240,7 +253,7 @@ def update_group(
|
||||
_write_permissions(conn, group_id, wanted)
|
||||
conn.execute("UPDATE groups SET updated_at=? WHERE id=?", (_now(), group_id))
|
||||
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:
|
||||
changes.append("Rechte entfernt: " + ", ".join(tree.label_for(p) for p in removed))
|
||||
|
||||
@@ -252,7 +265,7 @@ def soft_delete_group(conn: sqlite3.Connection, group_id: int) -> None:
|
||||
if group is None:
|
||||
raise ValidationError("Diese Gruppe existiert nicht.")
|
||||
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))
|
||||
|
||||
|
||||
@@ -315,6 +328,113 @@ def ensure_presets(
|
||||
return created
|
||||
|
||||
|
||||
def unberuehrte_vorlagen_entfernen(
|
||||
conn: sqlite3.Connection, tree: PermissionTree, presets: Sequence[RolePreset]
|
||||
) -> list[str]:
|
||||
"""Legt Vorlagengruppen in den Papierkorb -- aber nur unberuehrte.
|
||||
|
||||
Wird gebraucht, wenn eine Vorlage aus der Auslieferung verschwindet. Auf
|
||||
einer frischen Installation entsteht sie dann gar nicht erst; auf einer
|
||||
bestehenden steht sie in der Datenbank, und eine Migration, die sie einfach
|
||||
loescht, nimmt einem Kunden womoeglich eine Gruppe weg, mit der er
|
||||
arbeitet.
|
||||
|
||||
Darum diese fuenf Bedingungen, und alle fuenf muessen zutreffen:
|
||||
|
||||
* Die Gruppe stammt **aus der Auslieferung**: ihr ``preset_key`` ist der der
|
||||
Vorlage. Diese Kennung setzt nur :func:`ensure_presets`; eine selbst
|
||||
angelegte Gruppe hat sie nicht, auch wenn sie zufaellig denselben Namen
|
||||
traegt. Ueber den Namen wird bewusst *nicht* gesucht -- sonst raeumte
|
||||
dieser Schritt jemandem seine eigene Gruppe "Betrieb" weg.
|
||||
* Sie heisst noch so, wie sie ausgeliefert wurde. Ein anderer Name heisst:
|
||||
jemand hat sie uebernommen.
|
||||
* Die Gruppe hat **keine Mitglieder**. Wer Mitglieder hat, wird benutzt.
|
||||
* Ihre Rechte sind **unveraendert** die der Vorlage. Jede Abweichung heisst:
|
||||
jemand hat sie angepasst, also ist sie seine, nicht unsere.
|
||||
* Sie ist keine Systemgruppe.
|
||||
|
||||
Geloescht wird weich: die Gruppe liegt im Papierkorb und kommt mit einem
|
||||
Klick zurueck. Gibt die Namen der einsortierten Gruppen zurueck.
|
||||
"""
|
||||
entfernt: list[str] = []
|
||||
gruppen = list_groups(conn)
|
||||
for preset in presets:
|
||||
gruppe = next(
|
||||
(
|
||||
g
|
||||
for g in gruppen
|
||||
if g.preset_key
|
||||
and g.preset_key == preset.key
|
||||
and g.name.lower() == preset.name.lower()
|
||||
),
|
||||
None,
|
||||
)
|
||||
if gruppe is None or gruppe.is_system or gruppe.member_count:
|
||||
continue
|
||||
if set(gruppe.permissions) != set(tree.expand_preset(preset)):
|
||||
continue
|
||||
conn.execute("UPDATE groups SET deleted_at=? WHERE id=?", (_now(), gruppe.id))
|
||||
entfernt.append(gruppe.name)
|
||||
return entfernt
|
||||
|
||||
|
||||
def funktionsgruppe_anlegen(
|
||||
conn: sqlite3.Connection, tree: PermissionTree, preset: RolePreset
|
||||
) -> int:
|
||||
"""Legt die Vollzugriffsgruppe an, falls sie fehlt, und gibt ihre Nummer.
|
||||
|
||||
Getrennt von :func:`funktionsgruppe_fuer_administratoren`, weil das zwei
|
||||
verschiedene Dinge sind: hier entsteht nur die Gruppe, dort bekommt sie
|
||||
**jeder** Administrator. Wer ein einzelnes Konto aufnehmen will -- etwa
|
||||
das, das an der Kommandozeile entsteht --, braucht das erste ohne das
|
||||
zweite; sonst faengt sich ein Administrator, der die Gruppe bewusst
|
||||
abgelegt hat, sie beim naechsten Aufruf wieder ein.
|
||||
"""
|
||||
zeile = Database.one(
|
||||
conn, "SELECT id FROM groups WHERE name=? COLLATE NOCASE", (preset.name,)
|
||||
)
|
||||
if zeile is not None:
|
||||
return int(zeile["id"])
|
||||
cursor = conn.execute(
|
||||
"INSERT INTO groups (name, description, is_system, is_default, preset_key, "
|
||||
"created_at, updated_at) VALUES (?,?,0,0,?,?,?)",
|
||||
(preset.name, preset.description, preset.key, _now(), _now()),
|
||||
)
|
||||
gruppe = int(cursor.lastrowid or 0)
|
||||
_write_permissions(conn, gruppe, tree.expand_preset(preset))
|
||||
return gruppe
|
||||
|
||||
|
||||
def funktionsgruppe_fuer_administratoren(
|
||||
conn: sqlite3.Connection, tree: PermissionTree, preset: RolePreset
|
||||
) -> int:
|
||||
"""Legt die Vollzugriffsgruppe an und gibt sie allen Administratoren.
|
||||
|
||||
Der Uebergang zu 2.8.0: bis dahin gab das Kennzeichen ``is_admin`` jede
|
||||
Pruefung frei. Danach gibt es den Verwaltungsbereich, und alles andere
|
||||
kommt ueber Gruppen. Ohne diesen Schritt fehlt einem bestehenden
|
||||
Administrator nach dem Update die halbe Anwendung -- und er haelt das fuer
|
||||
einen Fehler, nicht fuer eine Absicht.
|
||||
|
||||
Auf einer frischen Installation gibt es beim Migrieren noch kein Konto;
|
||||
dann entsteht nur die Gruppe, und wer sie will, weist sie sich zu. Genau so
|
||||
ist die neue Trennung gedacht.
|
||||
|
||||
Gibt die Nummer der Gruppe zurueck.
|
||||
"""
|
||||
gruppe = funktionsgruppe_anlegen(conn, tree, preset)
|
||||
conn.executemany(
|
||||
"INSERT OR IGNORE INTO user_groups (user_id, group_id) VALUES (?,?)",
|
||||
[
|
||||
(int(row["id"]), gruppe)
|
||||
for row in Database.all(
|
||||
conn, "SELECT id FROM users WHERE is_admin=1 AND deleted_at IS NULL"
|
||||
)
|
||||
],
|
||||
)
|
||||
return gruppe
|
||||
|
||||
|
||||
def default_group_id(conn: sqlite3.Connection) -> int | None:
|
||||
value = Database.value(
|
||||
conn, "SELECT id FROM groups WHERE is_default=1 AND deleted_at IS NULL ORDER BY id LIMIT 1"
|
||||
@@ -410,7 +530,7 @@ def guard_target_account(actor: Any, target: dict[str, Any]) -> None:
|
||||
"""Ein Administratorkonto darf nur ein echter Administrator anfassen."""
|
||||
if bool(target.get("is_admin")) and not getattr(actor, "is_admin", False):
|
||||
raise PermissionDenied(
|
||||
"Administratorkonten koennen nur von Administratoren bearbeitet werden."
|
||||
"Administratorkonten können nur von Administratoren bearbeitet werden."
|
||||
)
|
||||
|
||||
|
||||
@@ -420,8 +540,8 @@ def guard_last_admin(conn: sqlite3.Connection, user_id: int, *, action: str) ->
|
||||
return
|
||||
if count_active_admins(conn, exclude_id=user_id) == 0:
|
||||
raise PermissionDenied(
|
||||
f"{action} nicht moeglich: es muss mindestens ein entsperrtes "
|
||||
"Administratorkonto uebrig bleiben."
|
||||
f"{action} nicht möglich: es muss mindestens ein entsperrtes "
|
||||
"Administratorkonto übrig bleiben."
|
||||
)
|
||||
|
||||
|
||||
@@ -482,12 +602,12 @@ def create_user(
|
||||
if existing["deleted_at"]:
|
||||
raise ValidationError(
|
||||
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.")
|
||||
|
||||
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":
|
||||
problems = policy.check(password, username=clean)
|
||||
@@ -552,24 +672,30 @@ def update_user(
|
||||
# ueberschrieben -- die Bearbeitung waere eine Illusion.
|
||||
raise ValidationError(
|
||||
"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] = {}
|
||||
# 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 (
|
||||
("first_name", first_name),
|
||||
("last_name", last_name),
|
||||
("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()
|
||||
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 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)):
|
||||
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:
|
||||
guard_last_admin(conn, user_id, action="Herabstufen")
|
||||
fields["is_admin"] = int(bool(is_admin))
|
||||
@@ -577,7 +703,7 @@ def update_user(
|
||||
|
||||
if is_locked is not None and bool(is_locked) != bool(target["is_locked"]):
|
||||
if 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:
|
||||
guard_last_admin(conn, user_id, action="Sperren")
|
||||
fields["is_locked"] = int(bool(is_locked))
|
||||
@@ -595,7 +721,7 @@ def update_user(
|
||||
after = {int(gid) for gid in group_ids}
|
||||
if before != after:
|
||||
set_user_groups(conn, user_id, sorted(after), actor=actor, tree=tree)
|
||||
changes.append("Gruppenzuordnung geaendert")
|
||||
changes.append("Gruppenzuordnung geändert")
|
||||
|
||||
return changes
|
||||
|
||||
@@ -613,7 +739,7 @@ def set_password(
|
||||
raise ValidationError("Dieses Konto existiert nicht.")
|
||||
if str(row.get("auth_source")) == "ldap":
|
||||
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"]))
|
||||
if problems:
|
||||
@@ -631,7 +757,7 @@ def soft_delete_user(conn: sqlite3.Connection, user_id: int, *, actor: Any) -> s
|
||||
raise ValidationError("Dieses Konto existiert nicht (mehr).")
|
||||
guard_target_account(actor, target)
|
||||
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")
|
||||
|
||||
if str(target.get("auth_source")) == "ldap":
|
||||
@@ -677,7 +803,7 @@ def matrix_state(
|
||||
granted = set(permissions)
|
||||
state: dict[str, dict[str, bool]] = {}
|
||||
for area in tree.areas:
|
||||
state[area.key] = {"view": area.view_key in granted}
|
||||
state[area.key] = {"read": area.read_key in granted}
|
||||
for resource in area.resources:
|
||||
state[resource.key] = {
|
||||
action.value: resource.permission(action) in granted for action in resource.actions
|
||||
@@ -690,7 +816,7 @@ def summarize_permissions(tree: PermissionTree, permissions: Iterable[str]) -> l
|
||||
granted = set(permissions)
|
||||
lines: list[str] = []
|
||||
for area in tree.areas:
|
||||
if area.view_key not in granted:
|
||||
if area.read_key not in granted:
|
||||
continue
|
||||
parts: list[str] = []
|
||||
for resource in area.resources:
|
||||
@@ -706,12 +832,9 @@ def summarize_permissions(tree: PermissionTree, permissions: Iterable[str]) -> l
|
||||
return lines
|
||||
|
||||
|
||||
#: Abgeleitet und nicht abgeschrieben: die Buchstaben stehen in ACTION_META.
|
||||
#: Die zweite Liste hier hatte "U" fuer Bearbeiten, waehrend die Matrix "W"
|
||||
#: zeigte -- dieselbe Aktion, zwei Kuerzel, je nachdem wo man hinsah.
|
||||
ACTION_LETTERS: dict[Action, str] = {
|
||||
Action.VIEW: "R",
|
||||
Action.CREATE: "C",
|
||||
Action.EDIT: "U",
|
||||
Action.DELETE: "D",
|
||||
Action.EXECUTE: "X",
|
||||
Action.EXPORT: "E",
|
||||
Action.SECRETS: "S",
|
||||
action: meta[0] for action, meta in ACTION_META.items()
|
||||
}
|
||||
@@ -112,7 +112,7 @@ def protect() -> None:
|
||||
if not expected or not provided:
|
||||
abort(403, description="Das Sicherheitstoken des Formulars fehlt. Bitte Seite neu laden.")
|
||||
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):
|
||||
|
||||
@@ -65,10 +65,10 @@ class PasswordPolicy:
|
||||
if classes < self.require_classes:
|
||||
problems.append(
|
||||
f"Mindestens {self.require_classes} verschiedene Zeichenarten "
|
||||
"(Klein-/Grossbuchstaben, Ziffern, Sonderzeichen)."
|
||||
"(Klein-/Großbuchstaben, Ziffern, Sonderzeichen)."
|
||||
)
|
||||
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():
|
||||
problems.append("Das Passwort darf den Benutzernamen nicht enthalten.")
|
||||
if len(set(normalized)) <= 3 and normalized:
|
||||
|
||||
@@ -163,6 +163,7 @@ class SqliteSessionInterface(SessionInterface):
|
||||
idle_minutes: int,
|
||||
absolute_hours: int,
|
||||
idle_lookup: Any = None,
|
||||
absolute_lookup: Any = None,
|
||||
) -> None:
|
||||
self.database = database
|
||||
self.cookie_name = cookie_name
|
||||
@@ -172,6 +173,16 @@ class SqliteSessionInterface(SessionInterface):
|
||||
#: Zeit zur Laufzeit einstellbar, ohne den Dienst neu zu starten -- und
|
||||
#: ohne eine zweite Datenbankverbindung, denn sie bekommt die offene.
|
||||
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:
|
||||
if self._idle_lookup is None:
|
||||
@@ -280,7 +291,10 @@ class SqliteSessionInterface(SessionInterface):
|
||||
payload = json.dumps(dict(session), separators=(",", ":"), sort_keys=True)
|
||||
with self.database.session() as conn, self.database.transaction(conn):
|
||||
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(
|
||||
"INSERT INTO user_sessions "
|
||||
"(id, user_id, created_at, last_seen_at, idle_expires_at, "
|
||||
@@ -292,7 +306,7 @@ class SqliteSessionInterface(SessionInterface):
|
||||
_iso(now),
|
||||
_iso(now),
|
||||
_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("_ua", ""),
|
||||
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=?",
|
||||
(_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.modified = True
|
||||
return old_sid
|
||||
|
||||
@@ -58,6 +58,30 @@ class SettingSpec:
|
||||
return str(raw)
|
||||
|
||||
def serialize(self, value: Any) -> str:
|
||||
"""Ein Wert als Text fuer die Datenbank.
|
||||
|
||||
Der Wert kann als Python-Wert kommen (aus einem Formular, aus dem
|
||||
Code) -- oder als die Zeichenkette, die schon in der Datenbank stand.
|
||||
Genau so liest eine **Sicherung** ihn wieder ein, und genau dort lag
|
||||
ein stiller Fehler: ``serialize("0")`` ergab ``"1"``, weil jede nicht
|
||||
leere Zeichenkette wahr ist. Aus jedem abgeschalteten Schalter wurde
|
||||
beim Einspielen ein eingeschalteter -- auch aus
|
||||
``ldap_allow_insecure``. Ein JSON-Feld wurde ein zweites Mal kodiert,
|
||||
und aus der Navigationsreihenfolge damit eine Zeichenkette.
|
||||
|
||||
Deshalb: eine Zeichenkette erst lesen. Was hier herauskommt, muss
|
||||
durch ``serialize`` unveraendert hindurchgehen -- sonst ueberlebt kein
|
||||
Wert eine Sicherung.
|
||||
"""
|
||||
if isinstance(value, str):
|
||||
if self.kind == "bool":
|
||||
value = self.parse(value)
|
||||
elif self.kind == "json":
|
||||
try:
|
||||
value = json.loads(value)
|
||||
except ValueError:
|
||||
# Kein JSON-Text, also ein Wert: dann wird er kodiert.
|
||||
pass
|
||||
if self.kind == "bool":
|
||||
return "1" if value else "0"
|
||||
if self.kind == "json":
|
||||
@@ -67,6 +91,45 @@ class SettingSpec:
|
||||
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:
|
||||
"""Zugriff auf ``settings``. Alle Methoden nehmen die bestehende Verbindung."""
|
||||
|
||||
@@ -140,16 +203,24 @@ class SettingsStore:
|
||||
|
||||
def set_many(
|
||||
self, conn: sqlite3.Connection, values: dict[str, Any], *, actor: str = ""
|
||||
) -> list[str]:
|
||||
"""Setzt mehrere Werte, gibt die tatsaechlich geaenderten Schluessel zurueck."""
|
||||
changed: list[str] = []
|
||||
) -> list["Aenderung"]:
|
||||
"""Setzt mehrere Werte und gibt die Aenderungen zurueck.
|
||||
|
||||
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))
|
||||
for key, value in values.items():
|
||||
spec = self.spec(key)
|
||||
if spec.serialize(current.get(key)) == spec.serialize(value):
|
||||
continue
|
||||
self.set(conn, key, value, actor=actor)
|
||||
changed.append(key)
|
||||
changed.append(Aenderung(key, current.get(key), value, geheim=spec.kind == "secret"))
|
||||
return changed
|
||||
|
||||
def delete(self, conn: sqlite3.Connection, key: str) -> None:
|
||||
|
||||
@@ -685,7 +685,10 @@
|
||||
.modal__sub { font-size: var(--fs-xs); color: var(--text-faint); margin-top: 2px; }
|
||||
/* Eine Datei-Vorschau soll die ganze Flaeche des Dialogs nutzen; der
|
||||
uebliche Innenabstand des Dialogkoerpers wuerde sie einrahmen. */
|
||||
.modal__body--flush { padding: 0; max-height: none; }
|
||||
/* Zwei Klassen am selben Element: diese hier muss gewinnen, obwohl
|
||||
.modal__body weiter unten steht. Mit der doppelten Klasse haengt das
|
||||
nicht mehr an der Reihenfolge im Stylesheet. */
|
||||
.modal__body.modal__body--flush { padding: 0; max-height: none; overflow: visible; }
|
||||
.modal__caption {
|
||||
padding: 0 var(--sp-5) var(--sp-2);
|
||||
color: var(--text-muted);
|
||||
@@ -702,6 +705,174 @@
|
||||
darunter. --surface-0 gibt es nicht, der Rahmen blieb durchsichtig. */
|
||||
background: var(--surface-1);
|
||||
}
|
||||
|
||||
/* -- Betrachter: Bild, PDF, Rahmen ------------------------------------- */
|
||||
/* Eine feste Hoehe fuer alle drei Anzeigen: sonst springt der Dialog beim
|
||||
Wechsel zwischen Foto und PDF. */
|
||||
.viewer {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
/* Was der Dialog uebrig laesst: Kopf, Dateiname und Fusszeile brauchen
|
||||
zusammen rund 190 Pixel. Ein fester Anteil der Fensterhoehe schob die
|
||||
Zoomleiste auf kleinen Fenstern unter den Rand. */
|
||||
height: min(calc(100dvh - 240px), 760px);
|
||||
min-height: 220px;
|
||||
background: var(--surface-1);
|
||||
}
|
||||
/* Hochkant -- Tablet oder Monitor im Hochformat -- gibt es unter dem Dialog
|
||||
viel Platz, den die Obergrenze verschenkt. Beim Ausfuellen eines
|
||||
Formulars zaehlt jede sichtbare Zeile, also nehmen wir dort die Hoehe,
|
||||
die da ist. Der Rest (Kopf, Dateiname, Fusszeile) braucht rund 200 Pixel;
|
||||
die 240 lassen etwas Luft. */
|
||||
@media (orientation: portrait) {
|
||||
.viewer {
|
||||
height: calc(100dvh - 240px);
|
||||
}
|
||||
}
|
||||
.viewer > .file-preview {
|
||||
flex: 1;
|
||||
height: auto;
|
||||
}
|
||||
.viewer__stage {
|
||||
position: relative;
|
||||
flex: 1;
|
||||
/* Ohne min-height waechst die Buehne mit dem Bild, und dessen
|
||||
max-height:100% haengt dann an sich selbst: ein 4032x3024-Foto stand
|
||||
36 Pixel ueber den Rand hinaus. */
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
overflow: hidden;
|
||||
/* Die Seite darf nicht mitwandern, wenn im Bild geschoben wird. */
|
||||
touch-action: none;
|
||||
cursor: zoom-in;
|
||||
}
|
||||
.viewer__stage--gezogen {
|
||||
cursor: grab;
|
||||
}
|
||||
.viewer__img {
|
||||
/* Der Ausgangszustand ist "passt in die Flaeche" -- genau das fehlte:
|
||||
ein Foto vom iPad ist 4032 Pixel breit und wurde im Rahmen 1:1
|
||||
gezeigt, also als Ausschnitt aus der Mitte. */
|
||||
max-width: 100%;
|
||||
max-height: 100%;
|
||||
/* Zoomen soll nicht unscharf wirken, solange nicht vergroessert wird. */
|
||||
transform-origin: center center;
|
||||
will-change: transform;
|
||||
user-select: none;
|
||||
-webkit-user-drag: none;
|
||||
}
|
||||
/* pdf.js verlangt einen absolut positionierten, scrollenden Behaelter --
|
||||
der liegt deshalb *innen*. Aussen ein gewoehnlicher Flex-Kasten, damit
|
||||
Zoomleiste und Speicherzeile ihren Platz behalten. Vorher rechnete der
|
||||
Bereich mit "bottom: var(--sp-10)", also mit genau einer Leiste; die
|
||||
zweite Zeile lag dann unter dem Dialogrand. */
|
||||
.viewer__pdf {
|
||||
position: relative;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
/* Ein PDF ist weisses Papier. pdf.js malt Formularfelder mit Systemfarben
|
||||
und ueberlaesst Ankreuzfelder dem Browser -- im dunklen Design heisst
|
||||
CanvasText weiss, und die Haken waren auf dem Papier unsichtbar. Fuer
|
||||
diesen Ausschnitt gilt deshalb immer das helle Schema. */
|
||||
color-scheme: light;
|
||||
}
|
||||
/* pdf.js rechnet fuer die Seite mit dem Standard "content-box": ihr
|
||||
9-Pixel-Rahmen liegt *ausserhalb* der angegebenen Breite. Unser globales
|
||||
"border-box" rechnete ihn hinein -- die Leinwand wurde 18 Pixel schmaler
|
||||
als die Anmerkungsebene, und die Formularfelder wanderten nach rechts
|
||||
unten, immer weiter je weiter unten sie lagen. */
|
||||
.viewer__pdf .pdfViewer .page {
|
||||
box-sizing: content-box;
|
||||
}
|
||||
.viewer__pdfscroll {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
overflow: auto;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
.viewer__bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--sp-2);
|
||||
padding: var(--sp-2) var(--sp-4);
|
||||
border-top: 1px solid var(--border);
|
||||
background: var(--surface-2, var(--surface-1));
|
||||
}
|
||||
.viewer__hint {
|
||||
margin-inline-start: auto;
|
||||
}
|
||||
/* Bildfeld eines Formulars: der Kasten zeigt das gewaehlte Foto, die
|
||||
Schaltflaeche liegt darin. Acrobats eigener Knopf tut im Browser nichts --
|
||||
das hier ist der Ersatz. */
|
||||
.viewer__bildfeld [data-viewer-bildvorschau] {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: contain;
|
||||
background: #fff;
|
||||
}
|
||||
.viewer__bildwahl {
|
||||
position: absolute;
|
||||
inset: auto 0 4px 0;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
}
|
||||
.viewer__bildwahl > span {
|
||||
padding: 4px 10px;
|
||||
border-radius: var(--r-sm, 6px);
|
||||
background: var(--accent);
|
||||
color: #fff;
|
||||
font-size: var(--fs-xs);
|
||||
font-weight: 600;
|
||||
box-shadow: var(--shadow-sm, 0 1px 3px rgb(0 0 0 / 0.3));
|
||||
}
|
||||
.viewer__bildfeld--belegt .viewer__bildwahl > span {
|
||||
opacity: 0.85;
|
||||
}
|
||||
.viewer__save {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: flex-end;
|
||||
gap: var(--sp-3);
|
||||
padding: var(--sp-3) var(--sp-4);
|
||||
border-top: 1px solid var(--border);
|
||||
background: var(--surface-2, var(--surface-1));
|
||||
}
|
||||
.viewer__save .viewer__feld {
|
||||
flex: 1 1 12rem;
|
||||
margin: 0;
|
||||
}
|
||||
/* Die Entscheidung steht rechts und der Haken darueber: er gehoert zum
|
||||
Speichern, nicht zu den Feldern davor. */
|
||||
.viewer__saveaktionen {
|
||||
margin-left: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-end;
|
||||
gap: var(--sp-2);
|
||||
}
|
||||
.viewer__saveknoepfe {
|
||||
display: flex;
|
||||
gap: var(--sp-2);
|
||||
}
|
||||
/* Nach "gibt es schon": der Haken ist die Antwort -- also faellt er auf. */
|
||||
.viewer__saveaktionen .check.is-gefragt {
|
||||
outline: 2px solid var(--accent);
|
||||
outline-offset: 3px;
|
||||
border-radius: var(--r-xs);
|
||||
}
|
||||
/* Eigener Name: ".field--inline" gibt es schon und steht dort fuer
|
||||
"waagerecht" -- diese Regel haette sie app-weit umgestellt. */
|
||||
.viewer__feld {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
/* Der Dateiname ist ein Knopf (er oeffnet die Vorschau), soll aber wie ein
|
||||
Link aussehen -- ein Knopf mit Rahmen mitten in einer Tabellenzeile waere
|
||||
unruhig. */
|
||||
@@ -839,7 +1010,25 @@
|
||||
text-align: center;
|
||||
}
|
||||
.matrix thead th:first-child { text-align: left; }
|
||||
.matrix td:not(:first-child), .matrix th:not(:first-child) { width: 62px; text-align: center; }
|
||||
/* Kuerzel, englischer Name, deutsche Beschriftung -- untereinander. Nur das
|
||||
Kuerzel allein war eine Spalte, die man nachschlagen musste; die Legende
|
||||
steht unter einer Tabelle, die man dafuer erst zu Ende scrollt. */
|
||||
.matrix__spalte { display: flex; flex-direction: column; align-items: center; line-height: 1.15; }
|
||||
.matrix__spalte b {
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--fs-sm);
|
||||
color: var(--text);
|
||||
font-weight: 650;
|
||||
}
|
||||
.matrix__spalte span { font-size: var(--fs-xs); color: var(--text-muted); text-transform: none; }
|
||||
.matrix__spalte small {
|
||||
font-size: 10px;
|
||||
color: var(--text-faint);
|
||||
text-transform: none;
|
||||
letter-spacing: 0;
|
||||
font-weight: 400;
|
||||
}
|
||||
.matrix td:not(:first-child), .matrix th:not(:first-child) { width: 72px; text-align: center; }
|
||||
.matrix tr.matrix__area > td {
|
||||
background: var(--surface-2);
|
||||
font-weight: 640;
|
||||
@@ -865,6 +1054,46 @@
|
||||
}
|
||||
.matrix__resource-label { display: flex; flex-direction: column; gap: 1px; }
|
||||
.matrix__resource-label small { color: var(--text-faint); font-size: var(--fs-xs); }
|
||||
.matrix__zeile { display: flex; align-items: baseline; gap: var(--sp-2); }
|
||||
.matrix__zeile b { font-weight: 600; }
|
||||
|
||||
/* Unterzeile: gehoert zur Zeile darueber. Ohne Einrueckung liest man sechs
|
||||
unabhaengige Dinge, wo sechs Teile eines Ganzen stehen. */
|
||||
.matrix tr.matrix__unterzeile > td:first-child { padding-left: var(--sp-6); }
|
||||
.matrix tr.matrix__unterzeile > td:first-child .matrix__zeile b { font-weight: 500; }
|
||||
|
||||
/* Was eine Aktion in dieser Zeile bedeutet -- in der Zeile, nicht im
|
||||
Titelattribut: mit dem Finger gibt es kein "hover". */
|
||||
.matrix__bedeutung { color: var(--text-faint); font-size: var(--fs-xs); }
|
||||
.matrix__bedeutung b {
|
||||
font-family: var(--font-mono);
|
||||
color: var(--text-muted);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.matrix__alle {
|
||||
border: 1px solid var(--border);
|
||||
background: transparent;
|
||||
color: var(--text-muted);
|
||||
border-radius: var(--r-sm);
|
||||
font-size: 10px;
|
||||
line-height: 1;
|
||||
padding: 2px 4px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.matrix__alle:hover { color: var(--accent); border-color: var(--accent); }
|
||||
|
||||
.matrix__filter {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: var(--sp-3);
|
||||
margin-bottom: var(--sp-3);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.matrix__filter-hint { color: var(--text-faint); font-size: var(--fs-xs); }
|
||||
/* Eine ausgefilterte Zeile ist versteckt, nicht entfernt: ihre Kaestchen
|
||||
bleiben im Formular, sonst loescht ein Filter beim Speichern Rechte. */
|
||||
.matrix tr[hidden] { display: none; }
|
||||
.matrix__legend {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
@@ -904,6 +1133,16 @@
|
||||
}
|
||||
.log-view mark { background: var(--warning-soft); color: var(--warning); border-radius: 3px; }
|
||||
|
||||
/* Stufen im laufenden Protokoll. Die Zuordnung der Farbrollen ist Absicht:
|
||||
--warning ist bernsteinfarben und traegt den HINWEIS, --danger ist rot und
|
||||
traegt die WARNUNG. Wer das nach den Namen "aufraeumt", dreht die Bedeutung
|
||||
um: ein Geraet, das nicht antwortet, waere dann orange und eines, das neu
|
||||
startet, rot. */
|
||||
.log-view__zeile--info { color: var(--success); }
|
||||
.log-view__zeile--hinweis { color: var(--warning); }
|
||||
.log-view__zeile--warnung { color: var(--danger); }
|
||||
.log-view__zeile--fehler { color: var(--danger); font-weight: 600; }
|
||||
|
||||
.kv { display: grid; grid-template-columns: auto 1fr; gap: var(--sp-2) var(--sp-4); font-size: var(--fs-base); }
|
||||
.kv dt { color: var(--text-faint); white-space: nowrap; }
|
||||
.kv dd { margin: 0; min-width: 0; overflow-wrap: anywhere; }
|
||||
@@ -979,6 +1218,12 @@
|
||||
--tile-accent: var(--text-faint);
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
/* Spalte, damit die Fusszeile unten haengen kann (siehe .tile__actions).
|
||||
Die Kacheln eines Rasters sind gleich hoch, ihr Inhalt aber nicht: mal
|
||||
fehlt der Switch, mal der Standort. Ohne diesen Halt stuenden die Knoepfe
|
||||
in jeder Kachel auf einer anderen Hoehe -- und das Auge sucht sie. */
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: var(--sp-4);
|
||||
padding-left: calc(var(--sp-4) + 3px);
|
||||
border: 1px solid var(--border-subtle);
|
||||
@@ -1040,6 +1285,73 @@
|
||||
color: var(--text-faint);
|
||||
}
|
||||
|
||||
/* Mitlesen auf der Wartungsseite: die Ausgabe eines laufenden Auftrags,
|
||||
direkt unter seinem Geraet. Dunkler Grund wie in jeder Konsole -- was
|
||||
dort steht, kommt von einer fremden Maschine und nicht aus dieser
|
||||
Oberflaeche. */
|
||||
.job-output {
|
||||
margin: 0;
|
||||
max-height: 220px;
|
||||
overflow: auto;
|
||||
padding: var(--sp-3) var(--sp-4);
|
||||
background: #101216;
|
||||
color: #d7dbe0;
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--fs-xs);
|
||||
line-height: 1.5;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------------- #
|
||||
Bildschirmsitzung (VNC)
|
||||
|
||||
Die Leinwand bekommt einen dunklen Grund -- unabhaengig vom Thema der
|
||||
Oberflaeche: was darauf erscheint, ist der Bildschirm eines fremden
|
||||
Geraets, und ein heller Rahmen darum laesst ihn wie einen Teil dieser
|
||||
Seite aussehen. Er ist keiner.
|
||||
---------------------------------------------------------------------- */
|
||||
.screen {
|
||||
border: 1px solid var(--border-subtle);
|
||||
border-radius: var(--r-md);
|
||||
background: #101216;
|
||||
overflow: hidden;
|
||||
}
|
||||
.screen__canvas {
|
||||
width: 100%;
|
||||
/* Genug Hoehe, dass ein Bildschirm erkennbar ist, ohne die Seite zu
|
||||
sprengen; noVNC skaliert das Bild hinein. */
|
||||
height: min(72vh, 760px);
|
||||
}
|
||||
/* Im Fenster ist weniger Platz -- und das Fenster soll nicht ueber den
|
||||
Bildschirmrand hinauswachsen, nur weil das Geraet hochaufloesend ist. */
|
||||
dialog.modal .screen__canvas { height: min(62vh, 620px); }
|
||||
.screen__status {
|
||||
font-size: var(--fs-sm);
|
||||
color: var(--text-faint);
|
||||
}
|
||||
.screen__status--ok { color: var(--success); }
|
||||
.screen__status--warnung { color: var(--warning); }
|
||||
.screen__status--fehler { color: var(--danger); }
|
||||
|
||||
/* Fusszeile der Kachel: links der Bildschirm, rechts der Neustart.
|
||||
``margin-top: auto`` schiebt sie an den unteren Rand -- unabhaengig
|
||||
davon, wie viel darueber steht. Die beiden Eingriffe stehen damit in
|
||||
jeder Kachel an derselben Stelle, und der eine liegt nicht dort, wo eben
|
||||
noch der andere war. */
|
||||
.tile__actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--sp-2);
|
||||
margin-top: auto;
|
||||
padding-top: var(--sp-3);
|
||||
}
|
||||
.tile__actions > :last-child { margin-left: auto; }
|
||||
/* Steht nur ein Knopf da, bleibt er links -- sonst spraenge der Neustart bei
|
||||
einem Geraet ohne Bildschirmzugang an die Stelle, an der nebenan VNC
|
||||
steht. Das Fuellstueck haelt den Platz. */
|
||||
.tile__spacer { flex: 1 1 auto; }
|
||||
|
||||
/* Marke und Karte stehen uebereinander im normalen Fluss. Vorher lag die
|
||||
Marke absolut positioniert hinter der Karte -- auf einem flachen Fenster
|
||||
schnitt die Karte mitten durch den Schriftzug und die Funktionszeile. Ein
|
||||
@@ -1163,3 +1475,340 @@
|
||||
.hide-sm { display: none !important; }
|
||||
}
|
||||
}
|
||||
|
||||
/* -- Sortierbare Liste (Navigationsreihenfolge) --------------------------- */
|
||||
/* Symbol und Beschriftung stehen in einer Zeile -- so wie im Menue selbst.
|
||||
Vorher brach die Beschriftung unter das Symbol, und die Liste sah aus wie
|
||||
etwas anderes als das, was sie sortiert. */
|
||||
|
||||
.reorder {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.reorder__row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 6px 10px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--r-sm);
|
||||
background: var(--surface-2);
|
||||
cursor: grab;
|
||||
}
|
||||
|
||||
.reorder__row > .flex-1 {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.reorder__row.is-dragging {
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.reorder__grip {
|
||||
color: var(--text-muted);
|
||||
display: inline-flex;
|
||||
}
|
||||
|
||||
.reorder__pos {
|
||||
min-width: 1.6em;
|
||||
text-align: right;
|
||||
font-variant-numeric: tabular-nums;
|
||||
color: var(--text-muted);
|
||||
font-size: var(--fs-xs);
|
||||
}
|
||||
|
||||
.reorder__moves {
|
||||
display: inline-flex;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.reorder__group {
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.reorder--child {
|
||||
margin: 4px 0 4px 28px;
|
||||
}
|
||||
|
||||
.reorder--child .reorder__row {
|
||||
background: transparent;
|
||||
border-style: dashed;
|
||||
}
|
||||
|
||||
/* -- Unterschrift --------------------------------------------------------- */
|
||||
/* Die Zeichenflaeche braucht "touch-action: none": sonst scrollt ein iPad die
|
||||
Seite, statt einen Strich zu zeichnen -- und es sieht aus, als reagierte das
|
||||
Feld nicht. */
|
||||
|
||||
.viewer__sign {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
padding: 10px 12px;
|
||||
border-top: 1px solid var(--border);
|
||||
background: var(--surface-2);
|
||||
}
|
||||
|
||||
.viewer__signfelder {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.viewer__signpad {
|
||||
width: 100%;
|
||||
height: 160px;
|
||||
border: 1px dashed var(--border-strong, var(--border));
|
||||
border-radius: var(--r-sm);
|
||||
background: #fff;
|
||||
touch-action: none;
|
||||
cursor: crosshair;
|
||||
}
|
||||
|
||||
.viewer__signaktionen {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
justify-content: flex-end;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.viewer__pdfscroll--setzen {
|
||||
cursor: crosshair;
|
||||
}
|
||||
|
||||
.viewer__pdfscroll--setzen .page {
|
||||
outline: 2px dashed var(--accent, #c8791f);
|
||||
outline-offset: -2px;
|
||||
}
|
||||
|
||||
/* Die gesetzte Unterschrift liegt auf der Seite -- in Prozent, damit sie beim
|
||||
Zoomen an ihrer Stelle bleibt. Sie nimmt Zeiger an: verschieben mit dem
|
||||
Block, Groesse mit dem Griff unten rechts, entfernen mit dem Kreuz.
|
||||
"touch-action: none" ist auf dem iPad die Bedingung dafuer, dass ein Finger
|
||||
zieht statt die Seite zu scrollen. */
|
||||
/* Die Marke ist genau die Zeichnung: ihre Unterkante liegt auf dem
|
||||
Ankerpunkt -- dieselbe Kante, an der pdf-lib das Bild setzt. Der Text
|
||||
haengt darunter und darf die Hoehe nicht mitbestimmen. */
|
||||
.viewer__unterschrift {
|
||||
position: absolute;
|
||||
z-index: 3;
|
||||
cursor: grab;
|
||||
touch-action: none;
|
||||
/* "outline" statt "border": ein Rahmen haette die Marke um zwei Pixel
|
||||
groesser gemacht und die Bildkante vom Ankerpunkt weggeschoben. */
|
||||
outline: 1px dashed transparent;
|
||||
outline-offset: 1px;
|
||||
}
|
||||
|
||||
.viewer__unterschrift:hover,
|
||||
.viewer__unterschrift.is-fassend {
|
||||
outline-color: var(--accent);
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
.viewer__unterschrift img {
|
||||
display: block;
|
||||
width: 100%;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* Dieselbe Rechnung wie im PDF (Breite durch 18): auf dem Schirm steht, was
|
||||
spaeter in der Datei steht. */
|
||||
/* "width: max-content": der Strich ist so lang wie der Text darunter -- so
|
||||
wie im PDF. Waere er so breit wie der Block, zeigte die Vorschau etwas
|
||||
anderes als die Datei. */
|
||||
.viewer__unterschrifttext {
|
||||
/* Unter der Zeichnung, nicht in ihr: "top: 100%" setzt die Oberkante des
|
||||
Strichs auf die Bildkante -- genau dort zieht ihn auch pdf-lib. Abstand,
|
||||
Strichstaerke und Innenabstand rechnet das Skript in Punkten. */
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
left: 0;
|
||||
/* Helvetica: dieselbe Schrift, die das PDF benutzt (StandardFonts.Helvetica).
|
||||
Mit der Hausschrift sah die Vorschau anders aus als das Ergebnis. Die
|
||||
Groesse setzt das Skript je Unterschrift -- hier steht nur ein Rueckfall. */
|
||||
font-family: Helvetica, Arial, sans-serif;
|
||||
/* Die tatsaechliche Groesse setzt das Skript in Pixeln (Punkte mal
|
||||
Pixel-je-Punkt der Seite). Dieser Wert gilt nur, solange sie noch nicht
|
||||
gerechnet wurde -- geerbt wuerde sonst die Schriftgroesse der Seite. */
|
||||
font-size: 8px;
|
||||
line-height: 1.15;
|
||||
color: #111;
|
||||
border-top: 1px solid rgba(0, 0, 0, 0.85);
|
||||
pointer-events: none;
|
||||
white-space: nowrap;
|
||||
width: max-content;
|
||||
}
|
||||
|
||||
.viewer__unterschriftgriff,
|
||||
.viewer__unterschriftweg {
|
||||
position: absolute;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
padding: 0;
|
||||
border: 1px solid var(--accent);
|
||||
border-radius: 3px;
|
||||
background: var(--surface-1);
|
||||
color: var(--accent);
|
||||
font-size: 11px;
|
||||
line-height: 1;
|
||||
opacity: 0;
|
||||
cursor: nwse-resize;
|
||||
}
|
||||
|
||||
.viewer__unterschriftweg {
|
||||
cursor: pointer;
|
||||
top: -8px;
|
||||
right: -8px;
|
||||
}
|
||||
|
||||
.viewer__unterschriftgriff {
|
||||
right: -8px;
|
||||
bottom: -8px;
|
||||
}
|
||||
|
||||
.viewer__unterschrift:hover .viewer__unterschriftgriff,
|
||||
.viewer__unterschrift:hover .viewer__unterschriftweg,
|
||||
.viewer__unterschrift.is-fassend .viewer__unterschriftgriff {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* Auf einem Tastbildschirm gibt es kein "hover" -- dort stehen die Knoepfe
|
||||
immer, sonst kaeme man nie an sie heran. */
|
||||
@media (hover: none) {
|
||||
.viewer__unterschriftgriff,
|
||||
.viewer__unterschriftweg {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
/* -- Ergebnis der Signaturpruefung ---------------------------------------- */
|
||||
/* Der Schleier liegt ueber der Seite und nimmt keine Klicks an: ein
|
||||
unterschriebenes Dokument ist flach, aber bei einem Irrtum soll die
|
||||
Kennzeichnung nicht auch noch die Bedienung blockieren. */
|
||||
|
||||
.viewer__pdf--unveraendert {
|
||||
box-shadow: inset 0 0 0 3px var(--success);
|
||||
}
|
||||
|
||||
.viewer__pdf--unveraendert::after,
|
||||
.viewer__pdf--veraendert::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
z-index: 4;
|
||||
}
|
||||
|
||||
.viewer__pdf--unveraendert::after {
|
||||
background: rgba(47, 158, 68, 0.1);
|
||||
}
|
||||
|
||||
.viewer__pdf--veraendert {
|
||||
box-shadow: inset 0 0 0 3px var(--danger);
|
||||
}
|
||||
|
||||
.viewer__pdf--veraendert::after {
|
||||
background: rgba(201, 42, 42, 0.08);
|
||||
}
|
||||
|
||||
.viewer__pruefung {
|
||||
margin: 0;
|
||||
padding: 6px 12px;
|
||||
font-size: var(--fs-xs);
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.viewer__pruefung--gut {
|
||||
color: var(--success);
|
||||
background: rgba(47, 158, 68, 0.12);
|
||||
}
|
||||
|
||||
.viewer__pruefung--schlecht {
|
||||
color: var(--danger);
|
||||
background: rgba(201, 42, 42, 0.12);
|
||||
}
|
||||
|
||||
/* -- Tabelle, die schmal zu Bloecken wird -------------------------------- */
|
||||
/* Eine Protokollzeile besteht aus sieben Angaben, von denen eine lang ist:
|
||||
das Detail. Auf einem breiten Schirm passt das nebeneinander. Wird es eng,
|
||||
ist eine Spalte von zwei Zentimetern fuer eine Pruefsumme keine Loesung --
|
||||
dann steht jede Angabe auf ihrer eigenen Zeile, beschriftet. */
|
||||
|
||||
.table--stapelbar .td--detail {
|
||||
/* Zeilenumbrueche aus dem Eintrag bleiben stehen: die Pruefsumme steht
|
||||
unter dem lesbaren Teil, nicht mitten darin. Und ein Hash ohne Trennstelle
|
||||
darf notfalls hart umbrechen. */
|
||||
white-space: pre-line;
|
||||
overflow-wrap: anywhere;
|
||||
max-width: 52ch;
|
||||
}
|
||||
|
||||
@media (max-width: 1100px) {
|
||||
.table--stapelbar thead {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.table--stapelbar,
|
||||
.table--stapelbar tbody,
|
||||
.table--stapelbar tr,
|
||||
.table--stapelbar td {
|
||||
display: block;
|
||||
width: auto;
|
||||
}
|
||||
|
||||
.table--stapelbar tr {
|
||||
padding: var(--sp-3) 0;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.table--stapelbar td {
|
||||
display: grid;
|
||||
grid-template-columns: 8.5rem 1fr;
|
||||
gap: var(--sp-2);
|
||||
align-items: baseline;
|
||||
border: 0;
|
||||
padding: 2px var(--sp-3);
|
||||
white-space: normal;
|
||||
}
|
||||
|
||||
.table--stapelbar td::before {
|
||||
content: attr(data-spalte);
|
||||
color: var(--text-muted);
|
||||
font-size: var(--fs-xs);
|
||||
}
|
||||
|
||||
.table--stapelbar .td--detail {
|
||||
max-width: none;
|
||||
}
|
||||
|
||||
/* Leere Zellen brauchen keine Zeile. */
|
||||
.table--stapelbar td:empty {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* -- Dateiliste: Symbol und Name in einer Zeile -------------------------- */
|
||||
/* Das Symbol ist ein Block-SVG; ohne diese Regel stand der Name darunter und
|
||||
eine Ordnerzeile sah doppelt so hoch aus wie eine Dateizeile. */
|
||||
|
||||
.file-cell {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.file-cell > svg {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
@@ -109,7 +109,7 @@ export function showToast(message, type = "info", { timeout = 6000 } = {}) {
|
||||
const close = document.createElement("button");
|
||||
close.type = "button";
|
||||
close.className = "toast__close";
|
||||
close.setAttribute("aria-label", "Meldung schliessen");
|
||||
close.setAttribute("aria-label", "Meldung schließen");
|
||||
close.textContent = "×";
|
||||
toast.append(body, close);
|
||||
toastStack.appendChild(toast);
|
||||
@@ -293,7 +293,7 @@ function initConfirmations() {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
pending = trigger;
|
||||
titleNode.textContent = trigger.dataset.confirmTitle || "Bitte bestaetigen";
|
||||
titleNode.textContent = trigger.dataset.confirmTitle || "Bitte bestätigen";
|
||||
textNode.textContent = trigger.dataset.confirm;
|
||||
okButton.textContent = trigger.dataset.confirmOk || "Fortfahren";
|
||||
okButton.className = `btn btn--${trigger.dataset.confirmStyle || "danger"}`;
|
||||
@@ -440,6 +440,56 @@ function initPermissionMatrix() {
|
||||
});
|
||||
};
|
||||
|
||||
/* -- Ganze Zeile, ganze Spalte, Filter -----------------------------
|
||||
Bei 37 Zeilen und bis zu sieben Spalten je Bereich sind das ueber
|
||||
zweihundert Kaestchen. Ohne diese drei Hilfen setzt man oben den
|
||||
Bereichsschalter und laesst es dabei -- und die feine Aufteilung
|
||||
haette nichts gebracht. */
|
||||
// Nur was man sieht: eine vom Filter ausgeblendete Zeile darf ein
|
||||
// Sammelknopf nicht mitnehmen. Sonst setzt man "alle Write" waehrend nach
|
||||
// "Neustart" gefiltert ist und aendert dabei Zeilen, die gar nicht auf dem
|
||||
// Schirm stehen -- und merkt es erst nach dem Speichern.
|
||||
const sichtbar = (box) => !box.disabled && !box.closest("tr").hidden;
|
||||
const felder = (bereich) =>
|
||||
$$('input[type="checkbox"][name][data-action]', bereich).filter(sichtbar);
|
||||
|
||||
table.addEventListener("click", (event) => {
|
||||
const ziel = event.target;
|
||||
if (!(ziel instanceof HTMLElement)) return;
|
||||
|
||||
const zeilenknopf = ziel.closest("[data-matrix-row]");
|
||||
if (zeilenknopf) {
|
||||
event.preventDefault();
|
||||
const row = zeilenknopf.closest("tr");
|
||||
if (!row) return;
|
||||
const kaestchen = felder(row);
|
||||
// Erst wenn alles gesetzt ist, nimmt der Knopf weg -- sonst waere
|
||||
// ein zweiter Klick auf eine halb gesetzte Zeile ein Ruecksprung.
|
||||
const alleGesetzt = kaestchen.length > 0 && kaestchen.every((box) => box.checked);
|
||||
kaestchen.forEach((box) => {
|
||||
box.checked = !alleGesetzt;
|
||||
box.dispatchEvent(new Event("change", { bubbles: true }));
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const spaltenknopf = ziel.closest("[data-matrix-column]");
|
||||
if (spaltenknopf) {
|
||||
event.preventDefault();
|
||||
const aktion = spaltenknopf.dataset.matrixColumn;
|
||||
const bereich = spaltenknopf.dataset.area;
|
||||
const kaestchen = $$(
|
||||
`tr[data-area="${bereich}"] input[data-action="${aktion}"][name]`,
|
||||
table
|
||||
).filter(sichtbar);
|
||||
const alleGesetzt = kaestchen.length > 0 && kaestchen.every((box) => box.checked);
|
||||
kaestchen.forEach((box) => {
|
||||
box.checked = !alleGesetzt;
|
||||
box.dispatchEvent(new Event("change", { bubbles: true }));
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
$$("[data-area-switch]", table).forEach((master) => {
|
||||
const areaKey = master.dataset.areaSwitch;
|
||||
syncArea(areaKey);
|
||||
@@ -451,8 +501,12 @@ function initPermissionMatrix() {
|
||||
if (!(box instanceof HTMLInputElement) || !box.dataset.action) return;
|
||||
const row = box.closest("tr");
|
||||
if (!row) return;
|
||||
const viewBox = $('input[data-action="view"]', row);
|
||||
if (box.dataset.action === "view") {
|
||||
// "read", nicht "view": die Aktion heisst seit 2.8.0 wie das, was sie
|
||||
// tut. Mit dem alten Namen fand diese Zeile das Kaestchen nicht mehr --
|
||||
// die Matrix haette sich weiter bedienen lassen und dabei die Regel
|
||||
// "jede Aktion setzt Ansehen voraus" stillschweigend fallen gelassen.
|
||||
const viewBox = $('input[data-action="read"]', row);
|
||||
if (box.dataset.action === "read") {
|
||||
// "Ansehen" abwaehlen entfernt alles andere dieser Zeile.
|
||||
if (!box.checked) {
|
||||
$$('input[type="checkbox"][name]', row).forEach((other) => {
|
||||
@@ -469,6 +523,30 @@ function initPermissionMatrix() {
|
||||
master.dispatchEvent(new Event("change"));
|
||||
}
|
||||
});
|
||||
|
||||
const suche = $("[data-matrix-filter]");
|
||||
const zaehler = $("[data-matrix-count]");
|
||||
if (suche) {
|
||||
const zeilen = $$("tr[data-suchtext]", table);
|
||||
const filtern = () => {
|
||||
const begriff = suche.value.trim().toLowerCase();
|
||||
let sichtbar = 0;
|
||||
zeilen.forEach((row) => {
|
||||
const passt = !begriff || (row.dataset.suchtext || "").includes(begriff);
|
||||
// "hidden" und nicht aus dem Formular nehmen: ein Filter, der
|
||||
// Kaestchen entfernt, loescht beim Speichern Rechte.
|
||||
row.hidden = !passt;
|
||||
if (passt) sichtbar += 1;
|
||||
});
|
||||
if (zaehler) {
|
||||
zaehler.textContent = begriff
|
||||
? `${sichtbar} von ${zeilen.length} Zeilen`
|
||||
: `${zeilen.length} Zeilen`;
|
||||
}
|
||||
};
|
||||
suche.addEventListener("input", filtern);
|
||||
filtern();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -497,14 +575,14 @@ function initAutoRefresh() {
|
||||
nameField.value = entry.name || "";
|
||||
dnField.value = entry.dn || "";
|
||||
results.hidden = true;
|
||||
show(`Uebernommen: ${entry.name || entry.dn}`);
|
||||
show(`Übernommen: ${entry.name || entry.dn}`);
|
||||
};
|
||||
|
||||
const search = async () => {
|
||||
const term = query.value.trim();
|
||||
if (term.length < 2) { show("Bitte mindestens zwei Zeichen eingeben."); return; }
|
||||
button.disabled = true;
|
||||
show("Suche laeuft ...");
|
||||
show("Suche läuft ...");
|
||||
results.innerHTML = "";
|
||||
try {
|
||||
const url = `${picker.dataset.searchUrl}?q=${encodeURIComponent(term)}`;
|
||||
@@ -532,10 +610,10 @@ function initAutoRefresh() {
|
||||
results.appendChild(row);
|
||||
});
|
||||
results.hidden = false;
|
||||
show(`${entries.length} Treffer -- zum Uebernehmen anklicken.`);
|
||||
show(`${entries.length} Treffer – zum Übernehmen anklicken.`);
|
||||
} catch (error) {
|
||||
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 {
|
||||
button.disabled = false;
|
||||
}
|
||||
@@ -720,7 +798,7 @@ function initClipboard() {
|
||||
timeout: 2500,
|
||||
});
|
||||
} catch {
|
||||
showToast("Kopieren nicht moeglich -- bitte manuell markieren.", "warning");
|
||||
showToast("Kopieren nicht möglich – bitte manuell markieren.", "warning");
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -856,6 +934,81 @@ function initTileFilters() {
|
||||
});
|
||||
}
|
||||
|
||||
/* -- Reihenfolge aendern -------------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Eine Liste, deren Reihenfolge man aendern kann -- ohne Bibliothek.
|
||||
*
|
||||
* Die Reihenfolge steckt in der Reihenfolge der versteckten Felder: das
|
||||
* Formular schickt sie als wiederholtes Feld, und der Server liest sie als
|
||||
* Liste. Verschiebt man einen Eintrag, verschiebt sich das Feld mit ihm.
|
||||
*
|
||||
* Zwei Wege, weil es zwei Arten von Geraeten gibt: Ziehen mit der Maus und
|
||||
* zwei Knoepfe je Zeile. Die Knoepfe sind nicht die Notloesung -- auf einem
|
||||
* Tablet sind sie der einzige Weg, der zuverlaessig funktioniert, und mit der
|
||||
* Tastatur ebenso.
|
||||
*/
|
||||
function initReorder() {
|
||||
$$('[data-behavior="reorder"]').forEach((liste) => {
|
||||
// Nur die eigenen Zeilen: Gruppen enthalten wiederum eine sortierbare
|
||||
// Liste, und deren Zeilen gehoeren der inneren Liste, nicht dieser.
|
||||
const zeilen = () => Array.from(liste.querySelectorAll(":scope > [data-reorder-item]"));
|
||||
|
||||
const nummerieren = () => {
|
||||
zeilen().forEach((zeile, index) => {
|
||||
const nummer = zeile.querySelector("[data-reorder-position-text]");
|
||||
if (nummer) nummer.textContent = String(index + 1);
|
||||
const hoch = zeile.querySelector('[data-reorder-move="up"]');
|
||||
const runter = zeile.querySelector('[data-reorder-move="down"]');
|
||||
if (hoch) hoch.disabled = index === 0;
|
||||
if (runter) runter.disabled = index === zeilen().length - 1;
|
||||
});
|
||||
};
|
||||
|
||||
liste.addEventListener("click", (event) => {
|
||||
const knopf = event.target.closest("[data-reorder-move]");
|
||||
if (!knopf) return;
|
||||
const zeile = knopf.closest("[data-reorder-item]");
|
||||
if (!zeile || zeile.parentElement !== liste) return;
|
||||
event.preventDefault();
|
||||
if (knopf.dataset.reorderMove === "up") {
|
||||
const davor = zeile.previousElementSibling;
|
||||
if (davor) liste.insertBefore(zeile, davor);
|
||||
} else {
|
||||
const danach = zeile.nextElementSibling;
|
||||
if (danach) liste.insertBefore(danach, zeile);
|
||||
}
|
||||
nummerieren();
|
||||
zeile.querySelector(`[data-reorder-move="${knopf.dataset.reorderMove}"]`)?.focus();
|
||||
});
|
||||
|
||||
let gezogen = null;
|
||||
liste.addEventListener("dragstart", (event) => {
|
||||
const zeile = event.target.closest("[data-reorder-item]");
|
||||
if (!zeile || zeile.parentElement !== liste) return;
|
||||
gezogen = zeile;
|
||||
gezogen.classList.add("is-dragging");
|
||||
if (event.dataTransfer) event.dataTransfer.effectAllowed = "move";
|
||||
});
|
||||
liste.addEventListener("dragend", () => {
|
||||
if (gezogen) gezogen.classList.remove("is-dragging");
|
||||
gezogen = null;
|
||||
nummerieren();
|
||||
});
|
||||
liste.addEventListener("dragover", (event) => {
|
||||
if (!gezogen) return;
|
||||
event.preventDefault();
|
||||
const ziel = event.target.closest("[data-reorder-item]");
|
||||
if (!ziel || ziel === gezogen || ziel.parentElement !== liste) return;
|
||||
const kasten = ziel.getBoundingClientRect();
|
||||
const obereHaelfte = event.clientY < kasten.top + kasten.height / 2;
|
||||
liste.insertBefore(gezogen, obereHaelfte ? ziel : ziel.nextElementSibling);
|
||||
});
|
||||
|
||||
nummerieren();
|
||||
});
|
||||
}
|
||||
|
||||
/* -- Start ---------------------------------------------------------------- */
|
||||
|
||||
/**
|
||||
@@ -893,8 +1046,23 @@ function boot() {
|
||||
initPalette();
|
||||
initSubmitGuards();
|
||||
initTileFilters();
|
||||
initReorder();
|
||||
initLogTails();
|
||||
unsavedGuard.init();
|
||||
initViewer();
|
||||
}
|
||||
|
||||
/* Der Dateibetrachter kommt nur auf Seiten mit Vorschau -- und er zieht bei
|
||||
einem PDF noch pdf.js nach. Beides erst dann, wenn es gebraucht wird. */
|
||||
function initViewer() {
|
||||
if (!document.querySelector("[data-viewer]")) return;
|
||||
// Dieselbe Version wie diese Datei: app.js wird als ".../app.js?v=<Zeit>"
|
||||
// eingebunden, und nginx liefert /core-assets/ mit "expires 7d". Ohne das
|
||||
// Anhaengsel bliebe der alte Betrachter nach einem Update im Cache liegen.
|
||||
const version = new URL(import.meta.url).search;
|
||||
import(`./viewer.js${version}`)
|
||||
.then((modul) => modul.initFilePreview())
|
||||
.catch((fehler) => console.error("Dateibetrachter", fehler));
|
||||
}
|
||||
|
||||
if (document.readyState === "loading") {
|
||||
|
||||
@@ -0,0 +1,468 @@
|
||||
// Bildschirmsitzung im Browser: noVNC an die WebSocket-Strecke von TESM.
|
||||
//
|
||||
// Der Client handelt das RFB-Protokoll unmittelbar mit dem Geraet aus; TESM
|
||||
// reicht nur Bytes durch. Alles, was diese Datei tut, ist: verbinden, den
|
||||
// Zustand in Worte fassen und nach Zugangsdaten fragen, wenn keine hinterlegt
|
||||
// sind.
|
||||
//
|
||||
// Die Meldungen sind der eigentliche Wert. Eine Sitzung, die schwarz bleibt,
|
||||
// laesst offen, ob es an der Berechtigung, am Netz, am Server oder am
|
||||
// Passwort liegt -- und wer das nicht sieht, sucht den Fehler in TESM.
|
||||
//
|
||||
// **Ein Bauteil, zwei Orte.** Dieselbe Sitzung laeuft auf der Bildschirmseite
|
||||
// und im Fenster der Statusuebersicht. Sie holt alles, was sie braucht, aus
|
||||
// einer JSON-Auskunft des Servers -- damit gibt es keine zweite Fassung, die
|
||||
// man beim naechsten Fund vergisst nachzuziehen.
|
||||
|
||||
import RFB from "../vendor/novnc/core/rfb.js";
|
||||
|
||||
// Drei Eingriffe in private Methoden der mitgelieferten Bibliothek. Bewusst
|
||||
// hier und nicht in der Bibliothek selbst: eine neue Fassung von noVNC laesst
|
||||
// sich dann austauschen, ohne eine Aenderung nachzupflegen -- faellt eine
|
||||
// Methode weg, entfaellt nur die Auskunft, nicht die Sitzung.
|
||||
//
|
||||
// Zwei davon holen nur Auskunft (_fail, _socketClose). Der dritte greift in
|
||||
// die Wahl des Sicherheitsverfahrens ein -- aber erst, **nachdem** eines
|
||||
// nachweislich nicht getragen hat, oder weil eines schon einmal getragen hat.
|
||||
//
|
||||
// Die Vorgeschichte gehoert dazu, damit niemand den Schritt zurueckgeht: hier
|
||||
// stand einmal eine Vorauswahl, die ohne hinterlegte Zugangsdaten ein
|
||||
// Verfahren ohne Anmeldung erzwang, weil RealVNC RA2ne zuerst nennt. Das war
|
||||
// geraten und falsch. Was jetzt hier steht, ist das Gegenteil einer Vermutung:
|
||||
// ein Befund aus einer vorherigen Sitzung.
|
||||
const istUnterstuetzt = RFB.prototype._isSupportedSecurityType;
|
||||
|
||||
//: Den *Grund* eines Fehlschlags kennt nur ``_fail`` -- das Ereignis
|
||||
//: "disconnect" traegt ihn nicht, und der Weg ueber die Protokollausgabe
|
||||
//: funktioniert nicht: die Bibliothek bindet ``console.error`` beim Laden,
|
||||
//: also bevor eine Seite etwas daran aendern koennte.
|
||||
let letzterGrund = "";
|
||||
const originalFail = RFB.prototype._fail;
|
||||
RFB.prototype._fail = function (details) {
|
||||
letzterGrund = String(details);
|
||||
return originalFail.call(this, details);
|
||||
};
|
||||
|
||||
//: Und der Abschluss der Leitung selbst: der Schliesscode sagt, ob TESM
|
||||
//: abgewiesen hat (1008), ob es ein Serverfehler war (1011) -- oder ob die
|
||||
//: Verbindung nie zustande kam (1006).
|
||||
let letzterCode = 0;
|
||||
const originalSocketClose = RFB.prototype._socketClose;
|
||||
RFB.prototype._socketClose = function (ereignis) {
|
||||
letzterCode = (ereignis && ereignis.code) || 0;
|
||||
if (ereignis && ereignis.reason) {
|
||||
letzterGrund = letzterGrund || String(ereignis.reason);
|
||||
}
|
||||
return originalSocketClose.call(this, ereignis);
|
||||
};
|
||||
|
||||
//: Hoechstens ein zweiter Anlauf. Jeder Versuch ist eine Verbindung, und ein
|
||||
//: RealVNC Server zaehlt abgebrochene Versuche mit -- wer dreimal anklopft,
|
||||
//: sperrt sich aus.
|
||||
const HOECHSTENS = 2;
|
||||
|
||||
//: So lange darf "wird aufgebaut" stehen, bevor die Seite selbst nachsieht.
|
||||
//: Kommt die WebSocket-Verbindung gar nicht erst zustande, wartet noVNC ohne
|
||||
//: jedes Ereignis, und die Seite stuende stumm bis zum Seitenwechsel.
|
||||
const AUFBAU_GRENZE = 12000;
|
||||
|
||||
//: Verfahren, die im Browser keinen Schluesseltausch kosten.
|
||||
//:
|
||||
//: Der Grund steht in der Bibliothek, `core/ra2.js`: fuer RA2ne erzeugt der
|
||||
//: **Browser** bei jeder Sitzung ein eigenes 2048-Bit-RSA-Schluesselpaar, in
|
||||
//: JavaScript. Die Primzahlsuche dafuer dauert ein bis drei Minuten -- genau
|
||||
//: die Zeit, die zwischen Klick und Bild lag. "Ohne Authentifizierung" (1)
|
||||
//: kostet davon nichts.
|
||||
//:
|
||||
//: Deshalb kommt es zuerst, **wenn der Server es anbietet** und wir nicht
|
||||
//: ohnehin schon wissen, was hier traegt. Das ist nicht dieselbe Vorauswahl
|
||||
//: wie in 2.8.14: die war geraten, diese steht auf einer Messung. Traegt sie
|
||||
//: nicht, nimmt der zweite Anlauf die Reihenfolge des Servers -- dann dauert
|
||||
//: es eben.
|
||||
const OHNE_SCHLUESSELTAUSCH = new Set([1]);
|
||||
|
||||
//: RA2ne. Wird es ausgehandelt, ist die Wartezeit erklaerbar und kein Fehler.
|
||||
const RA2NE = 6;
|
||||
|
||||
//: Tastenkombinationen, die der Arbeitsplatz selbst abfaengt und die deshalb
|
||||
//: einen Knopf brauchen. X11-Keysyms.
|
||||
const ALT_LINKS = 0xffe9;
|
||||
const F4 = 0xffc1;
|
||||
const F5 = 0xffc2;
|
||||
|
||||
const schema = window.location.protocol === "https:" ? "wss:" : "ws:";
|
||||
|
||||
/**
|
||||
* Baut eine Sitzung in diesem Behaelter auf.
|
||||
*
|
||||
* ``quelle`` ist die Adresse der JSON-Auskunft; alles Weitere steht dort.
|
||||
* Rueckgabe: eine Funktion, die die Sitzung wieder abbaut.
|
||||
*/
|
||||
export function starteSitzung(wurzel, quelle, { beiEnde = null } = {}) {
|
||||
const teil = (rolle) => wurzel.querySelector(`[data-rolle="${rolle}"]`);
|
||||
const leinwand = teil("leinwand");
|
||||
const zeile = teil("status");
|
||||
const abfrage = teil("zugangsdaten");
|
||||
const feldBenutzer = teil("benutzer");
|
||||
const feldPasswort = teil("passwort");
|
||||
const knopfSenden = teil("anmelden");
|
||||
const knopfTrennen = teil("trennen");
|
||||
const knoepfeSitzung = [...wurzel.querySelectorAll("[data-rolle^='taste-']"), knopfTrennen];
|
||||
|
||||
const gescheitert = new Set();
|
||||
let rfb = null;
|
||||
let aufbauUhr = 0;
|
||||
let versuche = 0;
|
||||
let stand = false;
|
||||
let daten = null;
|
||||
let letzterZugang = {};
|
||||
let beendet = false;
|
||||
|
||||
function melden(text, art = "") {
|
||||
if (!zeile) return;
|
||||
zeile.textContent = text;
|
||||
zeile.className = `screen__status${art ? " screen__status--" + art : ""}`;
|
||||
}
|
||||
|
||||
function sitzungsknoepfe(sichtbar) {
|
||||
knoepfeSitzung.forEach((knopf) => {
|
||||
if (knopf) knopf.hidden = !sichtbar;
|
||||
});
|
||||
}
|
||||
|
||||
function grund() {
|
||||
const teile = [];
|
||||
if (letzterGrund) {
|
||||
teile.push(letzterGrund.replace(/^Failed (when|while) \w+: /, ""));
|
||||
}
|
||||
if (rfb && rfb._rfbAuthScheme > 0) {
|
||||
teile.push(`gewähltes Verfahren: ${rfb._rfbAuthScheme}`);
|
||||
}
|
||||
if (gescheitert.size) {
|
||||
teile.push(`erfolglos: ${[...gescheitert].join(", ")}`);
|
||||
}
|
||||
return teile.join(" · ");
|
||||
}
|
||||
|
||||
// Steht die WebSocket-Strecke zu TESM überhaupt? Zwei sehr verschiedene
|
||||
// Ursachen sehen von aussen gleich aus: der VNC-Server mag nicht, oder die
|
||||
// Verbindung kommt nie zustande, weil ein Proxy zwischen Browser und TESM
|
||||
// "Upgrade" nicht durchreicht. Diese Probe trennt sie.
|
||||
function streckeTesten() {
|
||||
return new Promise((fertig) => {
|
||||
let beantwortet = false;
|
||||
const pruefling = new WebSocket(`${schema}//${window.location.host}/ws/probe`);
|
||||
const schliessen = (ergebnis) => {
|
||||
if (beantwortet) return;
|
||||
beantwortet = true;
|
||||
try {
|
||||
pruefling.close();
|
||||
} catch (fehler) {
|
||||
/* schon zu */
|
||||
}
|
||||
fertig(ergebnis);
|
||||
};
|
||||
pruefling.onmessage = () => schliessen(true);
|
||||
pruefling.onerror = () => schliessen(false);
|
||||
pruefling.onclose = () => schliessen(false);
|
||||
window.setTimeout(() => schliessen(false), 4000);
|
||||
});
|
||||
}
|
||||
|
||||
// Hat das gewählte Verfahren die Verbindung abreissen lassen? Dann ist das
|
||||
// ein Befund und keine Vermutung -- der naechste Anlauf laesst es aus. Nur
|
||||
// solange nie ein Bild stand: bricht eine **laufende** Sitzung ab, liegt es
|
||||
// nicht am Anmeldeverfahren.
|
||||
function naechsterVersuch() {
|
||||
const verfahren = rfb && rfb._rfbAuthScheme > 0 ? rfb._rfbAuthScheme : 0;
|
||||
if (!verfahren || versuche >= HOECHSTENS || gescheitert.has(verfahren)) {
|
||||
return false;
|
||||
}
|
||||
gescheitert.add(verfahren);
|
||||
melden(
|
||||
`Verfahren ${verfahren} hat die Verbindung nicht getragen – nächster Anlauf ` +
|
||||
"mit dem nächsten, das der Server anbietet …",
|
||||
"warnung",
|
||||
);
|
||||
const zugang = letzterZugang;
|
||||
rfb = null;
|
||||
window.setTimeout(() => verbinden(zugang), 400);
|
||||
return true;
|
||||
}
|
||||
|
||||
function verfahrenMelden(gewaehlt) {
|
||||
if (!gewaehlt || gewaehlt === daten.bewaehrt || !daten.meldeweg) return;
|
||||
fetch(daten.meldeweg, {
|
||||
method: "POST",
|
||||
credentials: "same-origin",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"X-Requested-With": "fetch",
|
||||
"X-CSRF-Token": document.body?.dataset.csrf || "",
|
||||
},
|
||||
body: JSON.stringify({ verfahren: gewaehlt }),
|
||||
}).catch(() => {
|
||||
/* Eine Merkhilfe, kein Teil der Sitzung. */
|
||||
});
|
||||
}
|
||||
|
||||
function verbinden(zugang) {
|
||||
if (beendet) return;
|
||||
melden(versuche ? "Nächster Versuch …" : "Verbindung wird aufgebaut …");
|
||||
letzterGrund = "";
|
||||
letzterCode = 0;
|
||||
letzterZugang = zugang;
|
||||
versuche += 1;
|
||||
window.clearTimeout(aufbauUhr);
|
||||
aufbauUhr = window.setTimeout(() => {
|
||||
// Ein ausgehandeltes RA2ne ist kein Fehler, sondern Rechenzeit: der
|
||||
// Browser erzeugt dafuer gerade einen 2048-Bit-Schluessel. Das zu
|
||||
// melden ist das Gegenteil einer Warnung -- es nimmt die Sorge.
|
||||
if (rfb && rfb._rfbAuthScheme === RA2NE) {
|
||||
melden(
|
||||
"Der Server verlangt RA2ne (RSA-AES). Dafür erzeugt der Browser ein " +
|
||||
"2048-Bit-Schlüsselpaar – das dauert ein bis drei Minuten. Bitte warten.",
|
||||
"warnung",
|
||||
);
|
||||
return;
|
||||
}
|
||||
melden("Es kommt keine Antwort. Wird geprüft, woran es liegt …", "warnung");
|
||||
streckeTesten().then((steht) => {
|
||||
melden(
|
||||
steht
|
||||
? "Die WebSocket-Strecke zu TESM steht, aber die Sitzung kommt nicht " +
|
||||
"zustande. Antwortet der VNC-Server auf dem Gerät noch? Ein Neustart " +
|
||||
"des VNC-Dienstes räumt eine Sperre ab."
|
||||
: "Die WebSocket-Verbindung zu TESM kommt nicht zustande – auch eine " +
|
||||
"einfache Probe scheitert. Ein Proxy zwischen Browser und TESM muss " +
|
||||
"die Kopfzeilen Upgrade und Connection durchreichen (nginx: " +
|
||||
"location /ws/ mit proxy_set_header Upgrade $http_upgrade).",
|
||||
"fehler",
|
||||
);
|
||||
});
|
||||
}, AUFBAU_GRENZE);
|
||||
|
||||
// Drei Regeln, alle aus Messung und keine aus Vermutung:
|
||||
//
|
||||
// 1. Was schon einmal getragen hat, kommt zuerst.
|
||||
// 2. Sonst das Verfahren ohne Schluesseltausch -- aber nur, wenn der
|
||||
// Server es nachweislich anbietet (sonst faende noVNC gar keines und
|
||||
// scheiterte sofort). RA2ne kostet sonst ein bis drei Minuten
|
||||
// Rechenzeit im Browser, jedes Mal.
|
||||
// 3. Was abgerissen ist, wird nicht noch einmal gewaehlt.
|
||||
//
|
||||
// Beides gilt nur fuer den **ersten** Anlauf; danach zaehlt die
|
||||
// Reihenfolge des Servers, abzueglich dessen, was schon abgerissen ist.
|
||||
const bewaehrt = daten.bewaehrt || 0;
|
||||
const angeboten = daten.verfahren || [];
|
||||
const schnell = angeboten.filter((typ) => OHNE_SCHLUESSELTAUSCH.has(typ));
|
||||
let bevorzugt = 0;
|
||||
if (versuche === 1) {
|
||||
if (bewaehrt > 0 && !gescheitert.has(bewaehrt)) bevorzugt = bewaehrt;
|
||||
else if (schnell.length && !gescheitert.has(schnell[0])) bevorzugt = schnell[0];
|
||||
}
|
||||
RFB.prototype._isSupportedSecurityType = function (typ) {
|
||||
if (gescheitert.has(typ)) return false;
|
||||
if (bevorzugt) return typ === bevorzugt;
|
||||
return istUnterstuetzt.call(this, typ);
|
||||
};
|
||||
|
||||
rfb = new RFB(leinwand, `${schema}//${window.location.host}${daten.socket}`, {
|
||||
credentials: zugang,
|
||||
// Eine Bildschirmsitzung soll nicht nebenbei die Zwischenablage des
|
||||
// Arbeitsplatzes mit dem Geraet teilen -- das ist eine eigene
|
||||
// Entscheidung, und niemand hat sie hier getroffen.
|
||||
clipboardTarget: null,
|
||||
});
|
||||
rfb.scaleViewport = true;
|
||||
rfb.resizeSession = false;
|
||||
rfb.focusOnClick = true;
|
||||
|
||||
rfb.addEventListener("connect", () => {
|
||||
window.clearTimeout(aufbauUhr);
|
||||
stand = true;
|
||||
verfahrenMelden(rfb && rfb._rfbAuthScheme > 0 ? rfb._rfbAuthScheme : 0);
|
||||
melden(`Verbunden mit ${daten.ziel}`, "ok");
|
||||
if (abfrage) abfrage.hidden = true;
|
||||
leinwand.hidden = false;
|
||||
sitzungsknoepfe(true);
|
||||
});
|
||||
|
||||
rfb.addEventListener("disconnect", (ereignis) => {
|
||||
window.clearTimeout(aufbauUhr);
|
||||
leinwand.hidden = true;
|
||||
sitzungsknoepfe(false);
|
||||
if (ereignis.detail && ereignis.detail.clean) {
|
||||
// Beim sauberen Schluss steht der Grund im Schliessrahmen -- etwa,
|
||||
// dass TESM wegen Untätigkeit getrennt hat.
|
||||
melden(letzterGrund ? `Sitzung beendet – ${letzterGrund}` : "Sitzung beendet.");
|
||||
// Im Fenster gibt es nach dem Trennen nichts mehr zu sehen: ein leerer
|
||||
// Rahmen, den man wegklicken muss. Bei einem **Abbruch** bleibt es
|
||||
// stehen -- dort steht der Grund, und der ist der ganze Zweck.
|
||||
beiEnde?.();
|
||||
} else if (!stand && naechsterVersuch()) {
|
||||
return;
|
||||
} else {
|
||||
const erklaerung = grund();
|
||||
melden(
|
||||
erklaerung
|
||||
? `Verbindung abgebrochen – ${erklaerung}`
|
||||
: "Verbindung abgebrochen. Wird geprüft, woran es liegt …",
|
||||
"fehler",
|
||||
);
|
||||
if (letzterCode === 1006 || !erklaerung) {
|
||||
streckeTesten().then((steht) => {
|
||||
melden(
|
||||
steht
|
||||
? `${erklaerung || "Verbindung abgebrochen"} – die WebSocket-Strecke zu ` +
|
||||
"TESM selbst steht (Probe erfolgreich), der Abbruch kommt also von " +
|
||||
"der VNC-Seite."
|
||||
: "Die WebSocket-Verbindung zu TESM kommt nicht zustande – auch eine " +
|
||||
"einfache Probe scheitert. Ein Proxy zwischen Browser und TESM muss " +
|
||||
"die Kopfzeilen Upgrade und Connection durchreichen (nginx: " +
|
||||
"location /ws/ mit proxy_set_header Upgrade $http_upgrade). Dasselbe " +
|
||||
"gilt für die Browser-Konsole.",
|
||||
"fehler",
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
rfb = null;
|
||||
});
|
||||
|
||||
rfb.addEventListener("credentialsrequired", (ereignis) => {
|
||||
window.clearTimeout(aufbauUhr);
|
||||
const gefragt = ereignis.detail.types || ["password"];
|
||||
if (abfrage) abfrage.hidden = false;
|
||||
if (feldBenutzer) feldBenutzer.closest("label").hidden = !gefragt.includes("username");
|
||||
melden("Der VNC-Server verlangt Zugangsdaten.", "warnung");
|
||||
(gefragt.includes("username") ? feldBenutzer : feldPasswort)?.focus();
|
||||
});
|
||||
|
||||
rfb.addEventListener("securityfailure", (ereignis) => {
|
||||
window.clearTimeout(aufbauUhr);
|
||||
melden(
|
||||
`Anmeldung abgelehnt: ${ereignis.detail.reason || "kein Grund genannt"}`,
|
||||
"fehler",
|
||||
);
|
||||
if (abfrage) abfrage.hidden = false;
|
||||
});
|
||||
}
|
||||
|
||||
knopfSenden?.addEventListener("click", () => {
|
||||
const zugang = {};
|
||||
if (feldBenutzer?.value) zugang.username = feldBenutzer.value;
|
||||
if (feldPasswort?.value) zugang.password = feldPasswort.value;
|
||||
if (rfb) rfb.sendCredentials(zugang);
|
||||
else verbinden(zugang);
|
||||
});
|
||||
|
||||
[feldBenutzer, feldPasswort].forEach((feld) =>
|
||||
feld?.addEventListener("keydown", (ereignis) => {
|
||||
if (ereignis.key === "Enter") {
|
||||
ereignis.preventDefault();
|
||||
knopfSenden?.click();
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
knopfTrennen?.addEventListener("click", () => rfb && rfb.disconnect());
|
||||
teil("taste-cad")?.addEventListener("click", () => rfb && rfb.sendCtrlAltDel());
|
||||
// Alt+F4 kennt noVNC nicht als fertigen Griff: der Arbeitsplatz faengt die
|
||||
// Kombination selbst ab, bevor sie in der Leinwand ankommt. Also einzeln --
|
||||
// Alt halten, F4 tippen, Alt loslassen -- wie es auf der Tastatur auch
|
||||
// geschieht.
|
||||
teil("taste-altf4")?.addEventListener("click", () => {
|
||||
if (!rfb) return;
|
||||
rfb.sendKey(ALT_LINKS, "AltLeft", true);
|
||||
rfb.sendKey(F4, "F4", true);
|
||||
rfb.sendKey(F4, "F4", false);
|
||||
rfb.sendKey(ALT_LINKS, "AltLeft", false);
|
||||
});
|
||||
// F5 faengt der Browser des Arbeitsplatzes ab -- er wuerde diese Seite neu
|
||||
// laden statt das Bild auf dem Geraet.
|
||||
teil("taste-f5")?.addEventListener("click", () => {
|
||||
if (!rfb) return;
|
||||
rfb.sendKey(F5, "F5", true);
|
||||
rfb.sendKey(F5, "F5", false);
|
||||
});
|
||||
|
||||
sitzungsknoepfe(false);
|
||||
melden("Wird vorbereitet …");
|
||||
|
||||
fetch(quelle, { credentials: "same-origin", headers: { "X-Requested-With": "fetch" } })
|
||||
.then((antwort) => antwort.json().then((inhalt) => ({ status: antwort.status, inhalt })))
|
||||
.then(({ status, inhalt }) => {
|
||||
if (beendet) return;
|
||||
if (status === 409 && inhalt.reauth) {
|
||||
melden(
|
||||
"Für den Bildschirm ist eine frische Passwortbestätigung nötig – bitte über " +
|
||||
"die Geräteseite öffnen.",
|
||||
"warnung",
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (status !== 200) {
|
||||
melden(inhalt.fehler || "Diese Sitzung lässt sich nicht öffnen.", "fehler");
|
||||
return;
|
||||
}
|
||||
daten = inhalt;
|
||||
if (daten.hinderungsgrund) {
|
||||
melden(daten.hinderungsgrund, "fehler");
|
||||
return;
|
||||
}
|
||||
const zugang = {};
|
||||
if (daten.vorgaben?.username) zugang.username = daten.vorgaben.username;
|
||||
if (daten.vorgaben?.password) zugang.password = daten.vorgaben.password;
|
||||
verbinden(zugang);
|
||||
})
|
||||
.catch(() => melden("Die Sitzungsdaten sind nicht abrufbar.", "fehler"));
|
||||
|
||||
return function beenden() {
|
||||
beendet = true;
|
||||
window.clearTimeout(aufbauUhr);
|
||||
try {
|
||||
rfb?.disconnect();
|
||||
} catch (fehler) {
|
||||
/* schon zu */
|
||||
}
|
||||
rfb = null;
|
||||
};
|
||||
}
|
||||
|
||||
// -- Die beiden Orte -------------------------------------------------------- #
|
||||
|
||||
// 1. Die Bildschirmseite: ein Behaelter, der seine Quelle selbst mitbringt.
|
||||
const seite = document.getElementById("screen");
|
||||
if (seite?.dataset.sitzung) {
|
||||
starteSitzung(seite, seite.dataset.sitzung);
|
||||
}
|
||||
|
||||
// 2. Die Statusuebersicht: ein Fenster, das jede Kachel oeffnen kann. Ein
|
||||
// Dialog fuer alle -- er bekommt beim Oeffnen, welches Geraet gemeint ist.
|
||||
const fenster = document.getElementById("vnc-fenster");
|
||||
if (fenster) {
|
||||
let beenden = null;
|
||||
const behaelter = fenster.querySelector("[data-rolle='behaelter']");
|
||||
const titel = fenster.querySelector("[data-rolle='titel']");
|
||||
|
||||
document.addEventListener("click", (ereignis) => {
|
||||
const knopf = ereignis.target.closest("[data-vnc-oeffnen]");
|
||||
if (!knopf) return;
|
||||
ereignis.preventDefault();
|
||||
beenden?.();
|
||||
titel.textContent = knopf.dataset.vncName || "Bildschirm";
|
||||
if (typeof fenster.showModal === "function") fenster.showModal();
|
||||
else fenster.setAttribute("open", "");
|
||||
beenden = starteSitzung(behaelter, knopf.dataset.vncOeffnen, {
|
||||
beiEnde: () => fenster.close(),
|
||||
});
|
||||
});
|
||||
|
||||
// Zu heisst zu: eine Sitzung, die im Hintergrund weiterlaeuft, haelt die
|
||||
// Verbindung zum Geraet und bei RealVNC den einen Sitzungsplatz.
|
||||
fenster.addEventListener("close", () => {
|
||||
beenden?.();
|
||||
beenden = null;
|
||||
});
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
Loaded 100 of 242 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user