Compare commits

..
28 Commits
Author SHA1 Message Date
alientim ce22239d78 Version 2.7.3: Zeugenvermerk einmal und in den Dokumenteigenschaften 2026-09-11 23:45:17 +02:00
alientim 92c8abb79b Zahlen im Kommentar geradegerueckt 2026-09-11 23:28:28 +02:00
alientim dcb4f57eeb Version 2.7.2: Umlaute in der Oberflaeche, Einbindungen an der Sitzung 2026-09-11 23:24:28 +02:00
alientim 11a55b20f8 Version 2.7.1: Signaturverzeichnis ueberlebt die Archivierung 2026-09-11 21:36:24 +02:00
alientim ed17bce40a Version 2.7.0: Pruefung beim Oeffnen, Unterschrift in menschlichem Mass 2026-09-11 21:23:11 +02:00
alientim 5282cd8f2d Version 2.6.1: die Speichernzeile fuehrt zur Entscheidung 2026-09-11 21:03:24 +02:00
alientim 754cbea6bb Version 2.6.0: Unterschreiben mit dem Finger, Ablage in eine andere Freigabe 2026-09-11 20:41:47 +02:00
alientim 07ae69e3b7 Version 2.5.2: Wartung ist auch in der Rechtematrix ein eigener Bereich 2026-09-11 20:16:37 +02:00
alientim d3c700283a Version 2.5.1: Sitzungsdauer im Lizenzserver, Umlaute, Umleitung alter Adressen 2026-09-11 19:16:26 +02:00
alientim 61a47debdc Version 2.5.0: englische Adressen entlang der Navigation, sparsameres Protokoll 2026-09-11 18:57:22 +02:00
alientim bec74e6936 Version 2.4.1: die Wiederanmeldung kommt aus dem Rechtemodell 2026-09-10 02:00:27 +02:00
alientim d2679361bc Version 2.4.0: eigene Rechtezeile fuer PDF-Formulare, Download ist Export 2026-09-10 00:08:21 +02:00
alientim 20c7fd8813 Version 2.3.3: weiter ausfuellbar oder abgeschlossen 2026-09-09 23:43:46 +02:00
alientim eaca6f7990 Version 2.3.2: Namen ohne Endung, lesbare Haken, hochkant mehr Hoehe 2026-09-09 23:35:42 +02:00
alientim 4eb1c0c2d8 Version 2.3.1: Formularfelder sitzen richtig, Bildfeld nutzbar 2026-09-09 23:11:49 +02:00
alientim c0a6dd057c Version 2.3.0: Speichern mit Ordner und Namen 2026-09-09 22:41:07 +02:00
alientim 8c2cd10ee4 Version 2.2.1: pdf.js unter .js -- .mjs kennt kein Webserver 2026-09-09 22:06:15 +02:00
alientim 2c3ca4acc1 Version 2.2.0: Vorschau mit Zoom und PDF-Formularen, eine Uploadgrenze 2026-09-09 21:48:05 +02:00
alientim f16ba7d875 Version 2.1.2: Wurzelinstanz auch an der Lizenz erkennen 2026-09-09 00:27:00 +02:00
alientim 7bfe328e4b Version 2.1.1: update.sh mit --yes, ohne Terminal kein stiller Ausstieg 2026-09-09 00:20:30 +02:00
alientim 5b9373bb24 release.sh nimmt den Namen des Repositoriums 2026-09-09 00:12:59 +02:00
alientim f77c8b221c Version 2.1.0 2026-09-09 00:10:39 +02:00
alientim 985deb7a24 README: vier Repositorien, zwei Ausgaben des Lizenzservers 2026-09-09 00:08:32 +02:00
alientim 258e90bf4f Rueckweg bei jedem Fehlschlag, install.sh oben im Paket, eigener Port je Instanz 2026-09-09 00:06:44 +02:00
alientimandClaude Opus 5 337a52cc38 sudoers ohne requiretty, und ein Rueckweg fuer fehlgeschlagene Upgrades
Auf einem frisch aufgesetzten Server brach das Upgrade mitten drin ab:

    /tmp/tmp.XXXX:8:24: syntax error: unknown setting: 'requiretty'
    visudo: invalid sudoers file
     !! sudoers-Datei ist fehlerhaft

"Defaults:<user> !requiretty" stand in beiden sudoers-Dateien. Auf Debian und
Ubuntu ist requiretty nie gesetzt -- die Zeile war von Anfang an wirkungslos --
und ab sudo 1.9.16 kennt visudo die Einstellung nicht mehr und lehnt die ganze
Datei ab. Sie ist entfernt; ein Hygienetest haelt fest, dass sie nicht
zurueckkommt, und prueft gleich mit, dass in diesen Dateien genau eine Regel
steht und die auf den Helfer zeigt.

Schlimmer als der Fehler war die Folge: der alte Dienst war zu diesem Zeitpunkt
gestoppt und deaktiviert, das Verzeichnis teils ersetzt, der neue Dienst nicht
vorhanden. upgbrade.sh faengt den Fehlschlag jetzt ab und dreht den Lauf
zurueck -- und mit "--rollback" auch spaeter noch, aus einer vorhandenen
Sicherung.

Dafuer muss die Sicherung mehr enthalten als das Verzeichnis. Bei einem Upgrade
in dasselbe Verzeichnis schreibt install.sh auch systemd-Unit, nginx-Site,
logrotate und Banner neu und ersetzt das venv; ohne Kopie davon liesse sich der
alte Stand nicht herstellen. Ein Manifest haelt Quelle und Art des Umzugs fest,
damit ein spaeterer Rueckweg nicht raten muss.

Beim Nachstellen des Fehlschlags fiel ein zweiter Fund auf, gefaehrlicher als
der erste: die Liste der "alten Units" war fest verdrahtet -- fuer --app tesm
immer tesm.service. Nach einem rename.sh heisst so aber die *laufende*
Installation. Wer die archivierte 1.x in eine eigene Instanz hochzieht und
--keep-old-running vergisst, haette damit den Produktionsdienst gestoppt und
deaktiviert. Die Units werden jetzt daran erkannt, dass sie in genau das
Quellverzeichnis zeigen; Timer und Hilfsdienste kommen ueber den Namen der so
gefundenen Unit mit.

Am System geprueft, nicht behauptet: Fehlschlag nachgestellt (1.2.8 mit eigener
Unit, eigener Site und erkennbarem venv), Rueckweg lief -- VERSION 1.2.8, das
alte venv und die alte Unit wieder da, die neue Struktur weg, die laufende
Produktion unberuehrt. Danach derselbe Lauf mit heiler sudoers-Datei bis zum
Ende (4 Clients, 2 Zugangsdaten, DHCP), und zuletzt "--rollback" als eigener
Aufruf auf dieselbe Installation.

Version 2.0.10.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 23:13:43 +02:00
alientimandClaude Opus 5 160675e368 upgrade.sh und update.sh genuegen als einzelne Datei
Auf einer 1.2.8 liegt nichts von dieser Fassung -- upgrade.sh verlangte aber
ein install.sh daneben und brach sonst ab. Das war die falsche Voraussetzung
fuer genau den Fall, fuer den das Skript gedacht ist. Beide Skripte holen sich
das Release jetzt selbst:

    curl -fsSLO .../raw/branch/main/deploy/upgrade.sh
    sudo bash upgrade.sh --app tesm

Liegt das Skript in einem entpackten Release, wird dessen install.sh benutzt
statt eines Downloads: wer ein Paket ausgepackt hat, will nicht, dass ungefragt
ein anderes geladen wird. Mit --tag laesst sich eine Version festlegen.

Der Bootstrap steht wortgleich in beiden Dateien -- zwei eigenstaendige
Skripte koennen sich keine Datei teilen, und beide sollen allein genuegen. Ein
Hygienetest vergleicht die Bloecke, ein zweiter prueft, dass upgrade.sh kein
entpacktes Release mehr verlangt, ein dritter fuehrt den Block mit leerem PATH
aus und erwartet, dass er das fehlende Werkzeug benennt.

update.sh ruft install.sh nicht mehr per exec auf: dadurch lief die
Aufraeumfalle nie, und jedes Update liess ein entpacktes Release unter /tmp
liegen.

Ausserdem "opus" aus allen Beispielen genommen. Das war der Name der
Testinstanz neben der Fassung 1.x; seit dem Umbenennen heissen die
Installationen wie ihre Anwendung, und in einer Hilfe, die jemand anders liest,
ist "opus" ein Raetsel. In BETRIEB.md stand dabei zweimal /opt statt /srv.

Geprueft am System, nicht behauptet: die einzelne upgrade.sh hat eine echte
1.2.8 (Kopie des Archivs) **in dasselbe Verzeichnis** auf 2.0.8 gebracht --
Release geladen, 4 Clients, 2 Zugangsdaten und die DHCP-Konfiguration
uebernommen, Dienst laeuft, /login 200. Die Sicherung enthaelt die 1.2.8 samt
sqlite.db und fernet.key (Verzeichnis 0700, Passphrase 0600), und /tmp bleibt
leer. Danach dieselbe Probe mit der einzelnen update.sh.

Version 2.0.9.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-03 16:18:52 +02:00
alientimandClaude Opus 5 3bbddd7d29 upgrade.sh findet die Fassung 1.x auch im Archiv
Nach einem Umbenennen liegt unter /srv/<app> die laufende Installation und die
Fassung 1.x unter /srv/<app>-sonnet5 -- dorthin verschiebt rename.sh sie. Die
Vorgabe von upgrade.sh zeigte danach auf die neue Installation und brach ab
(sauber, aber unbrauchbar). Gesucht wird jetzt an beiden Orten; erkannt wird
eine 1.x an der sqlite.db im Wurzelverzeichnis.

Den Weg selbst habe ich gegen die echten Altdaten geprueft, nicht behauptet:
aus /srv/tesm-sonnet5 (Version 1.2.8) kamen 4 Clients, 2 Zugangsdaten und die
DHCP-Konfiguration in eine Probeinstanz, aus /srv/tesm-license-sonnet5
(Version 1.2.7) 2 Kunden, 2 Tickets und 2 neu signierte Lizenzen samt
uebernommenem Signaturschluessel und Anbieterblock. Die laufenden
Installationen blieben unberuehrt, die Probeinstanzen sind abgeraeumt.

Zwei Tests halten die beiden Skripte zusammen: derselbe Namenszusatz, und die
Suche laeuft ueber die echten Zeilen aus upgrade.sh.

Version 2.0.8.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-03 14:53:36 +02:00
alientimandClaude Opus 5 7407f4bc00 Anmeldeseite: Wortmarke fuer beide, Karte ohne Wiederholung
* Die Wortmarke stand nur bei TESM -- sie hing an der Funktionszeile, und die
  hatte der Lizenzserver nicht. Jetzt zeigen beide die Marke; die Zeile
  darunter ist optional und beim Lizenzserver "LIZENZIEREN".
* Der Kartenkopf ist weg: eine Kachel mit zwei Buchstaben, die Ueberschrift
  "Anmelden" und der Anwendungsname darunter sagten dasselbe wie die Marke
  darueber und der Knopf darunter. Die Ueberschrift bleibt fuer die
  Titelzeile des Browsers.
* Marke und Karte stehen jetzt uebereinander im Fluss statt uebereinander
  gestapelt: auf einem flachen Fenster schnitt die Karte mitten durch den
  Schriftzug.
* Die Marke ist kuerzer und symmetrisch um die Mitte aufgebaut -- Schriftzug
  ueber text-anchor, Zacken spiegelbildlich, Linie links so lang wie der Punkt
  rechts. Volle Deckkraft, weil sie kein Wasserzeichen hinter der Karte mehr
  ist.
* Funktionszeile gekuerzt auf "UEBERWACHEN . BOOTEN . ANBINDEN" -- mit echtem
  Umlaut. "(PXE)" stand fuer etwas, das es noch nicht gibt.
* Der Farbverlauf im Hintergrund hat wieder Geometrie und Staerke der Fassung
  1.x. Der zweite Kreis lag auf --info und 10 %: ein kaltes Blau, das man auf
  dunklem Grund nicht sah und das nicht zur Marke gehoert.
* "Bitte melden Sie sich an." stand auf einer Seite aus Anmeldefeld,
  Passwortfeld und einem Knopf "Anmelden". Statt dessen erfaehrt jetzt der,
  dessen Sitzung gerade ablief, warum er wieder hier ist.

Dabei drei Dinge gefunden, die lautlos nichts taten:

* __version__ stand fest verdrahtet in drei __init__.py. Die Fusszeile zeigte
  vier Releases lang 2.0.2, waehrend die Anwendung 2.0.6 war. Die Nummer kommt
  jetzt aus den Metadaten des installierten Pakets; Quelle ist pyproject.toml.
* "gap: var(--sp-7)" -- die Skala kennt kein --sp-7 (1 bis 6, dann 8, 10, 12,
  16). Eine undefinierte Variable verwirft die ganze Deklaration ohne Meldung:
  der Abstand war "normal", und Marke und Karte stiessen auf den Pixel
  aneinander.
* Ein Test dagegen fand sofort ein zweites: --surface-0 wird dreimal benutzt
  und nirgends definiert. Die Schrift auf der Kachel "kritisch" blieb
  dunkelgrau auf Rot, die Dateivorschau hatte keinen Grund, und das Feld
  "Recht nicht vorhanden" in der Rechtematrix war durchsichtig -- also so hell
  wie die Karte, obwohl es dunkler sein sollte.

Version 2.0.7.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-03 14:44:32 +02:00
163 changed files with 18108 additions and 1107 deletions

No files matched your search

+27 -2
View File
@@ -23,7 +23,8 @@ mitbringen.
| Wo sonst noch | | | 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 | | [alientim/TESM-DEV](https://gitea.int.eertmoed.net/alientim/TESM-DEV) | Entwicklung: beide Anwendungen, Tests, Architekturdoku |
## Zweige ## Zweige
@@ -38,10 +39,18 @@ mitbringen.
## Installation ## Installation
```bash ```bash
sudo bash deploy/install.sh --app tesm --https sudo bash install.sh --app tesm --https
sudo -u tesm tesm-admin create-admin 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 Danach `https://<host>/`. Ohne `--domain` entsteht ein selbstsigniertes
Zertifikat; der Browser warnt einmal, die Verbindung ist verschlüsselt. 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 * **Zwei Installationen stören sich nicht** -- eigener `SITE_KEY` für
nginx-Site, netplan, logrotate, Zertifikate und Sitzungscookie. nginx-Site, netplan, logrotate, Zertifikate und Sitzungscookie.
* **Verschachtelte Datenbankverbindungen sind ein Fehler**, kein Deadlock. * **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).
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project] [project]
name = "tesm" name = "tesm"
version = "2.0.6" version = "2.7.3"
description = "TESM -- Verwaltung und Ueberwachung von PoE-Endgeraeten und Aruba-Switchen" description = "TESM -- Verwaltung und Ueberwachung von PoE-Endgeraeten und Aruba-Switchen"
requires-python = ">=3.11" requires-python = ">=3.11"
dependencies = [ dependencies = [
+58 -25
View File
@@ -23,7 +23,20 @@ from .permissions import PERMISSIONS, ROLE_PRESETS
from .schema import TESM_MIGRATIONS from .schema import TESM_MIGRATIONS
from .settings_spec import TESM_SETTINGS from .settings_spec import TESM_SETTINGS
__version__ = "2.0.2" #: Version aus den Metadaten des installierten Pakets -- die einzige Quelle
#: ist ``pyproject.toml``. Vorher stand hier eine feste Zahl, und die
#: Fusszeile zeigte vier Releases lang 2.0.2.
#:
#: Im Quellbaum ohne ``pip install`` gibt es keine Metadaten; dann steht dort
#: "unbekannt" statt einer erfundenen Nummer. In einem editierbaren Install
#: zeigt sie den Stand des letzten ``pip install -e`` -- also die Version, die
#: tatsaechlich installiert ist.
try: # pragma: no cover - haengt an der Installationsart
from importlib.metadata import PackageNotFoundError, version as _paketversion
__version__ = _paketversion("tesm")
except (ImportError, PackageNotFoundError): # pragma: no cover
__version__ = "unbekannt"
APP_KEY = "tesm" APP_KEY = "tesm"
APP_NAME = "TESM" APP_NAME = "TESM"
@@ -32,10 +45,12 @@ APP_EDITION = "Geraeteverwaltung"
#: Funktionszeile unter der Wortmarke auf der Anmeldeseite. Uebernommen vom #: Funktionszeile unter der Wortmarke auf der Anmeldeseite. Uebernommen vom
#: Vorgaenger, wo sie Teil des Hintergrundmotivs war -- sie sagt in einer Zeile, #: Vorgaenger, wo sie Teil des Hintergrundmotivs war -- sie sagt in einer Zeile,
#: was die Anwendung tut, und das gehoert auf die erste Seite. #: was die Anwendung tut, und das gehoert auf die erste Seite.
AUTH_TAGLINE = ( #: Zeile unter der Wortmarke auf den Anmeldeseiten. Kurz gehalten: sie ist
"UEBERWACHEN · BOOTEN · ANBINDEN (DHCP) · " #: eine Marke, keine Funktionsliste. "(PXE)" stand ausserdem fuer etwas, das
"BETRIEBSSYSTEM GEBEN (PXE)" #: es noch nicht gibt -- das Anmeldebanner hat es aus demselben Grund nie
) #: gezeigt. Wortgleich in ``deploy/tesm-motd``; ein Hygienetest haelt beides
#: zusammen.
AUTH_TAGLINE = "ÜBERWACHEN · BOOTEN · ANBINDEN"
ENV_PREFIX = "TESM_" ENV_PREFIX = "TESM_"
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -149,7 +164,7 @@ def _mount_shares_after_login(conn: Any, user: Any, secret: str, group_dns: tupl
group_dns=group_dns, group_dns=group_dns,
) )
for message in messages: for message in messages:
current_app.logger.info("Dateifreigabe fuer %s -- %s", user.username, message) current_app.logger.info("Dateifreigabe für %s | %s", user.username, message)
def _release_shares_on_logout(conn: Any, user: Any) -> None: def _release_shares_on_logout(conn: Any, user: Any) -> None:
@@ -159,14 +174,19 @@ def _release_shares_on_logout(conn: Any, user: Any) -> None:
dem Abmelden bestehen, waere sein Zugriff weiter offen -- bis irgendwann dem Abmelden bestehen, waere sein Zugriff weiter offen -- bis irgendwann
die Zeitgrenze greift. Das ist zu lange. die Zeitgrenze greift. Das ist zu lange.
""" """
from flask import session
from .services import fileshare from .services import fileshare
extension = core() extension = core()
# Nur die Einbindungen dieser Sitzung: wer an zwei Geraeten angemeldet ist,
# soll am zweiten weiterarbeiten koennen.
sitzung = str(getattr(session, "sid", "") or "")
with extension.database.transaction(conn): with extension.database.transaction(conn):
released = fileshare.release_all_for_user(conn, user.id) released = fileshare.release_all_for_user(conn, user.id, sitzung)
if released: if released:
current_app.logger.info( current_app.logger.info(
"Dateifreigaben fuer %s geloest: %d", user.username, released "Dateifreigaben für %s gelöst: %d", user.username, released
) )
@@ -190,19 +210,19 @@ def _status_pills(user: Any) -> list[dict[str, str]]:
faellig = monitor.next_check_at(verbindung, intervall) faellig = monitor.next_check_at(verbindung, intervall)
if faellig is not None: if faellig is not None:
eintrag = { eintrag = {
"label": "Naechste Pruefung in", "label": "Nächste Prüfung in",
"countdown_to": faellig.isoformat(), "countdown_to": faellig.isoformat(),
"countdown_done": "jetzt", "countdown_done": "jetzt",
"css_class": "", "css_class": "",
"dot": "1", "dot": "1",
"dot_class": "", "dot_class": "",
"title": f"Der Ueberwachungsdienst prueft alle {intervall} Sekunden.", "title": f"Der Überwachungsdienst prüft alle {intervall} Sekunden.",
} }
if user.has("settings_system.execute"): if user.has("settings_system.execute"):
from flask import url_for from flask import url_for
eintrag["action"] = url_for("settings.run_check_now") eintrag["action"] = url_for("settings.run_check_now")
eintrag["action_title"] = "Alle Clients sofort pruefen" eintrag["action_title"] = "Alle Clients sofort prüfen"
pills.append(eintrag) pills.append(eintrag)
counts = monitor.dashboard_counts(verbindung) counts = monitor.dashboard_counts(verbindung)
@@ -213,7 +233,7 @@ def _status_pills(user: Any) -> list[dict[str, str]]:
"css_class": "status-pill--danger", "css_class": "status-pill--danger",
"dot": "1", "dot": "1",
"dot_class": "status-pill__dot--pulse", "dot_class": "status-pill__dot--pulse",
"title": "Geraete, die beim letzten Durchlauf nicht geantwortet haben.", "title": "Geräte, die beim letzten Durchlauf nicht geantwortet haben.",
} }
) )
@@ -250,7 +270,7 @@ def _register_trash_providers(app: Flask) -> None:
conn, "devices", int(ident), unique_column="mac" conn, "devices", int(ident), unique_column="mac"
), ),
purge_fn=lambda conn, ident: inventory.purge_row(conn, "devices", int(ident)), purge_fn=lambda conn, ident: inventory.purge_row(conn, "devices", int(ident)),
hint="Die MAC-Adresse bleibt reserviert, solange das Geraet hier liegt.", hint="Die MAC-Adresse bleibt reserviert, solange das Gerät hier liegt.",
) )
) )
trash_views.register( trash_views.register(
@@ -279,7 +299,7 @@ def _register_trash_providers(app: Flask) -> None:
conn, "credentials", int(ident), unique_column="name" conn, "credentials", int(ident), unique_column="name"
), ),
purge_fn=lambda conn, ident: inventory.purge_row(conn, "credentials", int(ident)), purge_fn=lambda conn, ident: inventory.purge_row(conn, "credentials", int(ident)),
hint="Endgueltiges Loeschen entfernt auch das gespeicherte Passwort.", hint="Endgültiges Löschen entfernt auch das gespeicherte Passwort.",
) )
) )
@@ -295,7 +315,7 @@ def _startup_tasks(app: Flask) -> None:
extension = core(app) extension = core(app)
if not extension.config.web_process: if not extension.config.web_process:
logger.info( logger.info(
"Startaufgaben uebersprungen -- dieser Prozess ist nicht als Webprozess markiert." "Startaufgaben übersprungen – dieser Prozess ist nicht als Webprozess markiert."
) )
return return
@@ -308,7 +328,7 @@ def _startup_tasks(app: Flask) -> None:
# die Oberflaeche Freigaben, die es nicht gibt. # die Oberflaeche Freigaben, die es nicht gibt.
vergessen = fileshare.forget_all_mounts(conn) vergessen = fileshare.forget_all_mounts(conn)
if orphaned: if orphaned:
logger.warning("%s unterbrochene Wartungsauftraege als unklar markiert", orphaned) logger.warning("%s unterbrochene Wartungsaufträge als unklar markiert", orphaned)
if vergessen: if vergessen:
logger.info( logger.info(
"%s Freigabe-Einbindungen eines vorherigen Prozesses vergessen " "%s Freigabe-Einbindungen eines vorherigen Prozesses vergessen "
@@ -338,19 +358,32 @@ def _start_fileshare_sweep(app: Flask) -> None:
weiter eingebunden. weiter eingebunden.
""" """
import threading import threading
import time
from .services import fileshare 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: def schleife() -> None:
while True: # Zwei Netze: das innere haelt den Faden am Leben, wenn ein einzelner
time.sleep(FILESHARE_SWEEP_SECONDS) # Durchlauf scheitert; das aeussere sorgt dafuer, dass sein Ende
try: # ueberhaupt auffaellt. Vorher fehlte der Import von "time" -- der
with app.app_context(): # NameError kam beim ersten sleep, also vor jedem try, und der Faden
geloest = fileshare.sweep_expired(core(app).database) # war weg, ohne eine Zeile zu hinterlassen.
if geloest: try:
logger.info("%s abgelaufene Freigabe-Einbindungen geloest", geloest) while True:
except Exception: # noqa: BLE001 - der Webprozess darf nie sterben time.sleep(FILESHARE_SWEEP_SECONDS)
logger.exception("Aufraeumen der Dateifreigaben fehlgeschlagen") 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() threading.Thread(target=schleife, name="fileshare-sweep", daemon=True).start()
+16 -16
View File
@@ -13,7 +13,7 @@ from tesm_core.licensing_client import LicenseError
from ..services import transfer from ..services import transfer
bp = Blueprint("backup", __name__, url_prefix="/sicherung") bp = Blueprint("backup", __name__, url_prefix="/administration/backup")
MAX_IMPORT_BYTES = 64 * 1024 * 1024 MAX_IMPORT_BYTES = 64 * 1024 * 1024
PREVIEW_SESSION_KEY = "_import_token" PREVIEW_SESSION_KEY = "_import_token"
@@ -78,7 +78,7 @@ def overview():
@bp.post("/export") @bp.post("/export")
@permission_required("backup.export") @permission_required("backup.export")
@reauth_required # Die frische Bestaetigung verlangt das Recht selbst (SENSITIVE_ACTIONS).
def export(): def export():
extension = core() extension = core()
conn = db() conn = db()
@@ -92,7 +92,7 @@ def export():
allowed = {category.key for category in _allowed_categories()} allowed = {category.key for category in _allowed_categories()}
chosen = [key for key in request.form.getlist("categories") if key in allowed] chosen = [key for key in request.form.getlist("categories") if key in allowed]
if not chosen: if not chosen:
flash("Es wurde keine Kategorie ausgewaehlt.", "warning") flash("Es wurde keine Kategorie ausgewählt.", "warning")
return redirect(url_for("backup.overview")) return redirect(url_for("backup.overview"))
include_secrets = ( include_secrets = (
@@ -101,7 +101,7 @@ def export():
passphrase = request.form.get("passphrase") or "" passphrase = request.form.get("passphrase") or ""
repeat = request.form.get("passphrase_repeat") or "" repeat = request.form.get("passphrase_repeat") or ""
if passphrase != repeat: if passphrase != repeat:
flash("Die beiden Passphrasen stimmen nicht ueberein.", "danger") flash("Die beiden Passphrasen stimmen nicht überein.", "danger")
return redirect(url_for("backup.overview")) return redirect(url_for("backup.overview"))
try: try:
@@ -129,18 +129,18 @@ def export():
) )
@bp.post("/import/vorschau") @bp.post("/import/preview")
@permission_required("backup.execute") @permission_required("backup.execute")
@reauth_required @reauth_required
def import_preview(): def import_preview():
conn = db() conn = db()
upload = request.files.get("backup_file") upload = request.files.get("backup_file")
if upload is None or not upload.filename: if upload is None or not upload.filename:
flash("Keine Datei ausgewaehlt.", "danger") flash("Keine Datei ausgewählt.", "danger")
return redirect(url_for("backup.overview")) return redirect(url_for("backup.overview"))
raw = upload.stream.read(MAX_IMPORT_BYTES + 1) raw = upload.stream.read(MAX_IMPORT_BYTES + 1)
if len(raw) > MAX_IMPORT_BYTES: if len(raw) > MAX_IMPORT_BYTES:
flash("Die Datei ist zu gross.", "danger") flash("Die Datei ist zu groß.", "danger")
return redirect(url_for("backup.overview")) return redirect(url_for("backup.overview"))
try: try:
@@ -154,14 +154,14 @@ def import_preview():
session[PREVIEW_SESSION_KEY] = token session[PREVIEW_SESSION_KEY] = token
_audit("backup.import_previewed", f"Erstellt {header.get('created_at')}", severity="notice") _audit("backup.import_previewed", f"Erstellt {header.get('created_at')}", severity="notice")
flash( flash(
f"Datei geoeffnet. Sie haben {transfer.PREVIEW_TTL_MINUTES} Minuten Zeit, die " f"Datei geöffnet. Sie haben {transfer.PREVIEW_TTL_MINUTES} Minuten Zeit, die "
"Uebernahme zu bestaetigen.", "Übernahme zu bestätigen.",
"success", "success",
) )
return redirect(url_for("backup.overview")) return redirect(url_for("backup.overview"))
@bp.post("/import/uebernehmen") @bp.post("/import/apply")
@permission_required("backup.execute") @permission_required("backup.execute")
@reauth_required @reauth_required
def import_apply(): def import_apply():
@@ -169,13 +169,13 @@ def import_apply():
conn = db() conn = db()
token = session.get(PREVIEW_SESSION_KEY, "") token = session.get(PREVIEW_SESSION_KEY, "")
if not token: if not token:
flash("Es liegt keine geoeffnete Sicherung vor.", "danger") flash("Es liegt keine geöffnete Sicherung vor.", "danger")
return redirect(url_for("backup.overview")) return redirect(url_for("backup.overview"))
allowed = {category.key for category in _allowed_categories()} allowed = {category.key for category in _allowed_categories()}
chosen = [key for key in request.form.getlist("categories") if key in allowed] chosen = [key for key in request.form.getlist("categories") if key in allowed]
if not chosen: if not chosen:
flash("Es wurde keine Kategorie ausgewaehlt.", "warning") flash("Es wurde keine Kategorie ausgewählt.", "warning")
return redirect(url_for("backup.overview")) return redirect(url_for("backup.overview"))
try: try:
@@ -206,7 +206,7 @@ def import_apply():
action="backup.imported", action="backup.imported",
detail="; ".join( detail="; ".join(
f"{key}: {counts['created']} neu, {counts['updated']} aktualisiert, " f"{key}: {counts['created']} neu, {counts['updated']} aktualisiert, "
f"{counts['skipped']} uebersprungen" f"{counts['skipped']} übersprungen"
for key, counts in report.items() for key, counts in report.items()
), ),
ip=client_ip(), ip=client_ip(),
@@ -217,15 +217,15 @@ def import_apply():
lines = [ lines = [
f"{transfer.CATEGORY_BY_KEY[key].label}: {counts['created']} neu, " f"{transfer.CATEGORY_BY_KEY[key].label}: {counts['created']} neu, "
f"{counts['updated']} aktualisiert" f"{counts['updated']} aktualisiert"
+ (f", {counts['skipped']} uebersprungen" if counts["skipped"] else "") + (f", {counts['skipped']} übersprungen" if counts["skipped"] else "")
for key, counts in report.items() for key, counts in report.items()
if key in transfer.CATEGORY_BY_KEY if key in transfer.CATEGORY_BY_KEY
] ]
flash("Uebernahme abgeschlossen. " + " | ".join(lines), "success") flash("Übernahme abgeschlossen. " + " – ".join(lines), "success")
return redirect(url_for("backup.overview")) return redirect(url_for("backup.overview"))
@bp.post("/import/verwerfen") @bp.post("/import/discard")
@permission_required("backup.execute") @permission_required("backup.execute")
def import_discard(): def import_discard():
token = session.pop(PREVIEW_SESSION_KEY, "") token = session.pop(PREVIEW_SESSION_KEY, "")
+7 -7
View File
@@ -6,13 +6,13 @@ from typing import Any
from flask import Blueprint, abort, flash, redirect, render_template, request, url_for from flask import Blueprint, abort, flash, redirect, render_template, request, url_for
from tesm_core import audit from tesm_core import audit
from tesm_core.auth.models import current_user, permission_required, reauth_required from tesm_core.auth.models import current_user, permission_required
from tesm_core.auth.service import client_ip from tesm_core.auth.service import client_ip
from tesm_core.extension import core, db, transaction from tesm_core.extension import core, db, transaction
from ..services import inventory from ..services import inventory
bp = Blueprint("credentials", __name__, url_prefix="/zugangsdaten") bp = Blueprint("credentials", __name__, url_prefix="/monitoring/credentials")
def _audit(conn: Any, action: str, target: str, detail: str = "", severity: str = "info") -> None: def _audit(conn: Any, action: str, target: str, detail: str = "", severity: str = "info") -> None:
@@ -39,7 +39,7 @@ def overview():
) )
@bp.post("/neu") @bp.post("/new")
@permission_required("credentials.create") @permission_required("credentials.create")
def create(): def create():
try: try:
@@ -85,13 +85,13 @@ def update(credential_id: int):
"; ".join(changes), "; ".join(changes),
severity="notice", severity="notice",
) )
flash("Aenderungen gespeichert." if changes else "Keine Aenderungen.", "success" if changes else "info") flash("Änderungen gespeichert." if changes else "Keine Änderungen.", "success" if changes else "info")
except inventory.ValidationError as exc: except inventory.ValidationError as exc:
flash(str(exc), "danger") flash(str(exc), "danger")
return redirect(url_for("credentials.overview")) return redirect(url_for("credentials.overview"))
@bp.post("/<int:credential_id>/loeschen") @bp.post("/<int:credential_id>/delete")
@permission_required("credentials.delete") @permission_required("credentials.delete")
def delete(credential_id: int): def delete(credential_id: int):
try: try:
@@ -109,9 +109,9 @@ def delete(credential_id: int):
return redirect(url_for("credentials.overview")) return redirect(url_for("credentials.overview"))
@bp.post("/<int:credential_id>/anzeigen") @bp.post("/<int:credential_id>/reveal")
@permission_required("credentials.secrets") @permission_required("credentials.secrets")
@reauth_required # Die frische Bestaetigung verlangt das Recht selbst (SENSITIVE_ACTIONS).
def reveal(credential_id: int): def reveal(credential_id: int):
"""Zeigt ein gespeichertes Passwort im Klartext. """Zeigt ein gespeichertes Passwort im Klartext.
+4 -4
View File
@@ -80,7 +80,7 @@ def _tiles(*, authenticated: bool) -> dict[str, Any]:
# erwartbar weg. Wer die Uebersicht ansieht, will genau das zuerst # erwartbar weg. Wer die Uebersicht ansieht, will genau das zuerst
# wissen -- sonst haelt er einen laufenden Neustart fuer einen Ausfall. # wissen -- sonst haelt er einen laufenden Neustart fuer einen Ausfall.
"key": "restarting", "key": "restarting",
"label": "Neustart laeuft", "label": "Neustart läuft",
"kind": "warning", "kind": "warning",
"tiles": [tile for tile in tiles if tile["state"] == "restarting"], "tiles": [tile for tile in tiles if tile["state"] == "restarting"],
}, },
@@ -98,7 +98,7 @@ def _tiles(*, authenticated: bool) -> dict[str, Any]:
}, },
{ {
"key": "unknown", "key": "unknown",
"label": "Noch nicht geprueft", "label": "Noch nicht geprüft",
"kind": "warning", "kind": "warning",
"tiles": [tile for tile in tiles if tile["state"] == "unknown"], "tiles": [tile for tile in tiles if tile["state"] == "unknown"],
}, },
@@ -121,7 +121,7 @@ def _tiles(*, authenticated: bool) -> dict[str, Any]:
} }
@bp.get("/uebersicht") @bp.get("/overview")
def index(): def index():
from tesm_core.auth.models import current_user from tesm_core.auth.models import current_user
@@ -138,7 +138,7 @@ def index_view():
return index() return index()
@bp.get("/uebersicht/kacheln") @bp.get("/overview/tiles")
@fragment @fragment
def tiles(): def tiles():
"""Teilbereich fuer die automatische Aktualisierung -- liefert nur HTML-Fragment.""" """Teilbereich fuer die automatische Aktualisierung -- liefert nur HTML-Fragment."""
+24 -24
View File
@@ -20,7 +20,7 @@ from tesm_core.extension import core, db, transaction
from ..services import inventory, monitor, restart, ssh from ..services import inventory, monitor, restart, ssh
bp = Blueprint("devices", __name__, url_prefix="/clients") bp = Blueprint("devices", __name__, url_prefix="/monitoring/clients")
def _audit(conn: Any, action: str, target: str, detail: str = "", severity: str = "info") -> None: def _audit(conn: Any, action: str, target: str, detail: str = "", severity: str = "info") -> None:
@@ -100,7 +100,7 @@ def _credential_from_form(conn: Any) -> int | None:
) )
name = (request.form.get("new_credential_name") or "").strip() name = (request.form.get("new_credential_name") or "").strip()
if not name: if not name:
raise inventory.ValidationError("Fuer neue Zugangsdaten wird eine Bezeichnung gebraucht.") raise inventory.ValidationError("Für neue Zugangsdaten wird eine Bezeichnung gebraucht.")
return inventory.create_credential( return inventory.create_credential(
conn, conn,
core().keystore, core().keystore,
@@ -108,12 +108,12 @@ def _credential_from_form(conn: Any) -> int | None:
username=(request.form.get("new_credential_username") or "").strip(), username=(request.form.get("new_credential_username") or "").strip(),
secret=request.form.get("new_credential_secret") or "", secret=request.form.get("new_credential_secret") or "",
category=request.form.get("new_credential_category", "linux"), category=request.form.get("new_credential_category", "linux"),
description="Beim Anlegen eines Geraets erfasst.", description="Beim Anlegen eines Geräts erfasst.",
actor=current_user.username, actor=current_user.username,
) )
@bp.post("/neu") @bp.post("/new")
@permission_required("devices.create") @permission_required("devices.create")
def create(): def create():
try: try:
@@ -130,7 +130,7 @@ def create():
f"MAC {request.form.get('mac')}, IP {request.form.get('ip')}", f"MAC {request.form.get('mac')}, IP {request.form.get('ip')}",
severity="notice", severity="notice",
) )
flash("Geraet angelegt.", "success") flash("Gerät angelegt.", "success")
return redirect(url_for("devices.overview", highlight=device_id)) return redirect(url_for("devices.overview", highlight=device_id))
except inventory.ValidationError as exc: except inventory.ValidationError as exc:
flash(str(exc), "danger") flash(str(exc), "danger")
@@ -155,13 +155,13 @@ def update(device_id: int):
"; ".join(changes), "; ".join(changes),
severity="notice", severity="notice",
) )
flash("Aenderungen gespeichert." if changes else "Keine Aenderungen.", "success" if changes else "info") flash("Änderungen gespeichert." if changes else "Keine Änderungen.", "success" if changes else "info")
except inventory.ValidationError as exc: except inventory.ValidationError as exc:
flash(str(exc), "danger") flash(str(exc), "danger")
return redirect(url_for("devices.overview")) return redirect(url_for("devices.overview"))
@bp.post("/<int:device_id>/aktiv") @bp.post("/<int:device_id>/active")
@permission_required("devices.edit") @permission_required("devices.edit")
def toggle_active(device_id: int): def toggle_active(device_id: int):
active = request.form.get("active") == "1" active = request.form.get("active") == "1"
@@ -176,11 +176,11 @@ def toggle_active(device_id: int):
str(device["name"]), str(device["name"]),
severity="notice", severity="notice",
) )
flash("Geraet aktiviert." if active else "Geraet deaktiviert.", "success") flash("Gerät aktiviert." if active else "Gerät deaktiviert.", "success")
return redirect(request.referrer or url_for("devices.overview")) return redirect(request.referrer or url_for("devices.overview"))
@bp.post("/<int:device_id>/loeschen") @bp.post("/<int:device_id>/delete")
@permission_required("devices.delete") @permission_required("devices.delete")
def delete(device_id: int): def delete(device_id: int):
with transaction() as conn: with transaction() as conn:
@@ -189,11 +189,11 @@ def delete(device_id: int):
abort(404) abort(404)
inventory.delete_device(conn, device_id) inventory.delete_device(conn, device_id)
_audit(conn, "device.deleted", str(device["name"]), severity="warning") _audit(conn, "device.deleted", str(device["name"]), severity="warning")
flash("Geraet in den Papierkorb verschoben.", "success") flash("Gerät in den Papierkorb verschoben.", "success")
return redirect(url_for("devices.overview")) return redirect(url_for("devices.overview"))
@bp.post("/<int:device_id>/neustart") @bp.post("/<int:device_id>/restart")
@permission_required("devices.execute") @permission_required("devices.execute")
def restart_device(device_id: int): def restart_device(device_id: int):
"""Startet ein Geraet neu -- ueber den Weg, der dafuer gewaehlt wurde. """Startet ein Geraet neu -- ueber den Weg, der dafuer gewaehlt wurde.
@@ -219,14 +219,14 @@ def restart_device(device_id: int):
options = restart.available(dict(device), maintenance_active=maintenance_active) options = restart.available(dict(device), maintenance_active=maintenance_active)
if options: if options:
flash( flash(
"Der gewaehlte Weg steht fuer dieses Geraet nicht zur Verfuegung. " "Der gewählte Weg steht für dieses Gerät nicht zur Verfügung. "
"Moeglich waere: " + ", ".join(entry.label for entry in options), "Möglich wäre: " + ", ".join(entry.label for entry in options),
"warning", "warning",
) )
else: else:
flash( flash(
f"Fuer {device['name']} ist kein Neustart moeglich: es fehlen Switch und Port " f"Für {device['name']} ist kein Neustart möglich: es fehlen Switch und Port "
"fuer den PoE-Weg und passende Zugangsdaten fuer SSH oder RPC.", "für den PoE-Weg und passende Zugangsdaten für SSH oder RPC.",
"warning", "warning",
) )
return redirect(request.referrer or url_for("devices.overview")) return redirect(request.referrer or url_for("devices.overview"))
@@ -262,7 +262,7 @@ def _restart_over_poe(extension: Any, conn: Any, device: Any): # type: ignore[n
return redirect(request.referrer or url_for("devices.overview")) return redirect(request.referrer or url_for("devices.overview"))
flash( flash(
f"PoE-Neustart fuer {device['name']} durchgefuehrt." f"PoE-Neustart für {device['name']} durchgeführt."
if outcome["ok"] if outcome["ok"]
else f"PoE-Neustart fehlgeschlagen: {outcome['message']}", else f"PoE-Neustart fehlgeschlagen: {outcome['message']}",
"success" if outcome["ok"] else "danger", "success" if outcome["ok"] else "danger",
@@ -319,7 +319,7 @@ def _restart_as_job(extension: Any, conn: Any, device: Any, method: Any): # typ
method=method.key, method=method.key,
) )
flash( flash(
f"{method.label} fuer {device['name']} gestartet. Der Verlauf steht unter Wartung.", f"{method.label} für {device['name']} gestartet. Der Verlauf steht unter Wartung.",
"success", "success",
) )
return redirect(request.referrer or url_for("devices.overview")) return redirect(request.referrer or url_for("devices.overview"))
@@ -351,7 +351,7 @@ def detail(device_id: int):
categories=inventory.DEVICE_CATEGORIES, categories=inventory.DEVICE_CATEGORIES,
host_keys=host_keys, host_keys=host_keys,
method_labels=monitor.METHOD_LABELS, method_labels=monitor.METHOD_LABELS,
terminal_enabled=bool(extension.settings.get(conn, "terminal_enabled")), terminal_enabled=bool(extension.settings.get(conn, "terminal_clients_enabled")),
) )
@@ -360,7 +360,7 @@ def detail(device_id: int):
# --------------------------------------------------------------------------- # # --------------------------------------------------------------------------- #
@bp.post("/<int:device_id>/hostkey/pruefen") @bp.post("/<int:device_id>/host-key/probe")
@permission_required("devices.edit") @permission_required("devices.edit")
def probe_host_key(device_id: int): def probe_host_key(device_id: int):
"""Liest den Host-Schluessel des Clients, ohne sich anzumelden. """Liest den Host-Schluessel des Clients, ohne sich anzumelden.
@@ -390,7 +390,7 @@ def probe_host_key(device_id: int):
) )
@bp.post("/<int:device_id>/hostkey/freigeben") @bp.post("/<int:device_id>/host-key/trust")
@permission_required("devices.edit") @permission_required("devices.edit")
@reauth_required @reauth_required
def trust_host_key(device_id: int): def trust_host_key(device_id: int):
@@ -401,7 +401,7 @@ def trust_host_key(device_id: int):
key_type = (request.form.get("key_type") or "").strip() key_type = (request.form.get("key_type") or "").strip()
fingerprint = (request.form.get("fingerprint") or "").strip() fingerprint = (request.form.get("fingerprint") or "").strip()
if not key_type or not ssh.valid_fingerprint(fingerprint): if not key_type or not ssh.valid_fingerprint(fingerprint):
flash("Es wurde kein gueltiger Fingerprint uebergeben.", "danger") flash("Es wurde kein gültiger Fingerprint übergeben.", "danger")
return redirect(url_for("devices.detail", device_id=device_id)) return redirect(url_for("devices.detail", device_id=device_id))
with transaction(): with transaction():
@@ -420,11 +420,11 @@ def trust_host_key(device_id: int):
f"{key_type} {fingerprint}", f"{key_type} {fingerprint}",
severity="notice", severity="notice",
) )
flash("Host-Schluessel freigegeben.", "success") flash("Host-Schlüssel freigegeben.", "success")
return redirect(url_for("devices.detail", device_id=device_id)) return redirect(url_for("devices.detail", device_id=device_id))
@bp.post("/<int:device_id>/hostkey/verwerfen") @bp.post("/<int:device_id>/host-key/forget")
@permission_required("devices.edit") @permission_required("devices.edit")
def forget_host_key(device_id: int): def forget_host_key(device_id: int):
conn = db() conn = db()
@@ -434,5 +434,5 @@ def forget_host_key(device_id: int):
with transaction(): with transaction():
ssh.forget_key(conn, str(device["ip"]), int(device["ssh_port"] or 22)) ssh.forget_key(conn, str(device["ip"]), int(device["ssh_port"] or 22))
_audit(conn, "device.host_key_forgotten", str(device["name"]), severity="warning") _audit(conn, "device.host_key_forgotten", str(device["name"]), severity="warning")
flash("Gespeicherte Host-Schluessel entfernt.", "info") flash("Gespeicherte Host-Schlüssel entfernt.", "info")
return redirect(url_for("devices.detail", device_id=device_id)) return redirect(url_for("devices.detail", device_id=device_id))
+22 -22
View File
@@ -105,7 +105,7 @@ def overview():
# --------------------------------------------------------------------------- # # --------------------------------------------------------------------------- #
@bp.post("/einstellungen") @bp.post("/settings")
@permission_required("dhcp.edit") @permission_required("dhcp.edit")
def save_settings(): def save_settings():
extension = core() extension = core()
@@ -118,11 +118,11 @@ def save_settings():
with transaction(): with transaction():
changed = extension.settings.set_many(conn, values, actor=current_user.username) changed = extension.settings.set_many(conn, values, actor=current_user.username)
_audit("dhcp.settings_saved", detail=", ".join(changed), severity="notice") _audit("dhcp.settings_saved", detail=", ".join(changed), severity="notice")
flash("Einstellungen gespeichert." if changed else "Keine Aenderungen.", "success" if changed else "info") flash("Einstellungen gespeichert." if changed else "Keine Änderungen.", "success" if changed else "info")
return _write_and_redirect() return _write_and_redirect()
@bp.post("/subnetz") @bp.post("/subnet")
@permission_required("dhcp.edit") @permission_required("dhcp.edit")
def add_subnet(): def add_subnet():
extension = core() extension = core()
@@ -158,7 +158,7 @@ def add_subnet():
return _write_and_redirect() return _write_and_redirect()
@bp.post("/subnetz/<int:subnet_id>") @bp.post("/subnet/<int:subnet_id>")
@permission_required("dhcp.edit") @permission_required("dhcp.edit")
def update_subnet(subnet_id: int): def update_subnet(subnet_id: int):
extension = core() extension = core()
@@ -194,7 +194,7 @@ def update_subnet(subnet_id: int):
return _write_and_redirect() return _write_and_redirect()
@bp.post("/subnetz/<int:subnet_id>/loeschen") @bp.post("/subnet/<int:subnet_id>/delete")
@permission_required("dhcp.edit") @permission_required("dhcp.edit")
def delete_subnet(subnet_id: int): def delete_subnet(subnet_id: int):
with transaction() as conn: with transaction() as conn:
@@ -209,7 +209,7 @@ def delete_subnet(subnet_id: int):
# --------------------------------------------------------------------------- # # --------------------------------------------------------------------------- #
@bp.post("/option/wert") @bp.post("/option/value")
@permission_required("dhcp.edit") @permission_required("dhcp.edit")
def set_option_value(): def set_option_value():
conn = db() conn = db()
@@ -248,7 +248,7 @@ def _device_option_values(conn) -> dict[int, list[dict[str, Any]]]: # type: ign
return gruppen return gruppen
@bp.post("/option/geraet") @bp.post("/option/device")
@permission_required("dhcp.edit") @permission_required("dhcp.edit")
def set_device_option_value(): def set_device_option_value():
"""Setzt eine Option fuer ein einzelnes Geraet. """Setzt eine Option fuer ein einzelnes Geraet.
@@ -289,14 +289,14 @@ def set_device_option_value():
value or "(geleert)", value or "(geleert)",
) )
flash( flash(
f"Option {option['name']} fuer {device['name']} " f"Option {option['name']} für {device['name']} "
f"{'gespeichert' if value else 'entfernt'}.", f"{'gespeichert' if value else 'entfernt'}.",
"success", "success",
) )
return _write_and_redirect() return _write_and_redirect()
@bp.post("/option/neu") @bp.post("/option/new")
@permission_required("dhcp.edit") @permission_required("dhcp.edit")
def add_custom_option(): def add_custom_option():
conn = db() conn = db()
@@ -327,7 +327,7 @@ def add_custom_option():
return redirect(url_for("dhcp.overview")) return redirect(url_for("dhcp.overview"))
@bp.post("/option/<int:option_id>/loeschen") @bp.post("/option/<int:option_id>/delete")
@permission_required("dhcp.edit") @permission_required("dhcp.edit")
def delete_custom_option(option_id: int): def delete_custom_option(option_id: int):
conn = db() conn = db()
@@ -336,7 +336,7 @@ def delete_custom_option(option_id: int):
abort(404) abort(404)
if int(option["is_standard"] or 0): if int(option["is_standard"] or 0):
flash( flash(
"Standardoptionen koennen nicht geloescht werden -- nur ihr Wert lasst sich leeren.", "Standardoptionen können nicht gelöscht werden – nur ihr Wert lässt sich leeren.",
"danger", "danger",
) )
return redirect(url_for("dhcp.overview")) return redirect(url_for("dhcp.overview"))
@@ -352,14 +352,14 @@ def delete_custom_option(option_id: int):
# --------------------------------------------------------------------------- # # --------------------------------------------------------------------------- #
@bp.post("/reservierung") @bp.post("/reservation")
@permission_required("dhcp.edit") @permission_required("dhcp.edit")
def add_reservation(): def add_reservation():
conn = db() conn = db()
mac = kea.normalize_mac(request.form.get("mac", "")) mac = kea.normalize_mac(request.form.get("mac", ""))
ip = (request.form.get("ip") or "").strip() ip = (request.form.get("ip") or "").strip()
if not mac: if not mac:
flash("Die MAC-Adresse ist ungueltig.", "danger") flash("Die MAC-Adresse ist ungültig.", "danger")
return redirect(url_for("dhcp.overview")) return redirect(url_for("dhcp.overview"))
# Eine Reservierung ausserhalb aller eingerichteten Bereiche liefert Kea nie # Eine Reservierung ausserhalb aller eingerichteten Bereiche liefert Kea nie
@@ -367,7 +367,7 @@ def add_reservation():
# schlechteste der drei Moeglichkeiten -- deshalb hier ablehnen. # schlechteste der drei Moeglichkeiten -- deshalb hier ablehnen.
if not Database.value(conn, "SELECT COUNT(*) FROM dhcp_subnets WHERE enabled=1"): if not Database.value(conn, "SELECT COUNT(*) FROM dhcp_subnets WHERE enabled=1"):
flash( flash(
"Es ist noch kein Bereich eingerichtet. Legen Sie zuerst ein Subnetz an -- " "Es ist noch kein Bereich eingerichtet. Legen Sie zuerst ein Subnetz an – "
"ohne eines wird keine Reservierung ausgeliefert.", "ohne eines wird keine Reservierung ausgeliefert.",
"danger", "danger",
) )
@@ -380,7 +380,7 @@ def add_reservation():
# keine Reservierung mehr moeglich. Stattdessen benennen, was ungeprueft # keine Reservierung mehr moeglich. Stattdessen benennen, was ungeprueft
# bleibt. # bleibt.
flash( flash(
"Der Bereich konnte nicht geprueft werden -- ohne Systemhelfer ist das Netz " "Der Bereich konnte nicht geprüft werden – ohne Systemhelfer ist das Netz "
"dieses Servers nicht feststellbar.", "dieses Servers nicht feststellbar.",
"warning", "warning",
) )
@@ -390,7 +390,7 @@ def add_reservation():
bereiche = ", ".join(str(netz) for netz in netze) bereiche = ", ".join(str(netz) for netz in netze)
flash( flash(
f"{ip or 'Diese Adresse'} liegt in keinem eingerichteten Bereich " f"{ip or 'Diese Adresse'} liegt in keinem eingerichteten Bereich "
f"({bereiche}). Die Reservierung waere ohne Wirkung.", f"({bereiche}). Die Reservierung wäre ohne Wirkung.",
"danger", "danger",
) )
return redirect(url_for("dhcp.overview")) return redirect(url_for("dhcp.overview"))
@@ -412,7 +412,7 @@ def add_reservation():
return _write_and_redirect() return _write_and_redirect()
@bp.post("/reservierung/<int:reservation_id>/loeschen") @bp.post("/reservation/<int:reservation_id>/delete")
@permission_required("dhcp.edit") @permission_required("dhcp.edit")
def delete_reservation(reservation_id: int): def delete_reservation(reservation_id: int):
with transaction() as conn: with transaction() as conn:
@@ -437,14 +437,14 @@ def _write_config() -> dict[str, Any]:
sysops = extension.sysops sysops = extension.sysops
conn = db() conn = db()
if sysops is None or not sysops.available: if sysops is None or not sysops.available:
return {"ok": False, "message": "Systemhelfer nicht verfuegbar."} return {"ok": False, "message": "Systemhelfer nicht verfügbar."}
networks = kea.detect_networks(sysops) networks = kea.detect_networks(sysops)
config, warnings = kea.render_config(conn, networks=networks, settings=_settings_values()) config, warnings = kea.render_config(conn, networks=networks, settings=_settings_values())
if not config["Dhcp4"]["subnet4"]: if not config["Dhcp4"]["subnet4"]:
return { return {
"ok": False, "ok": False,
"message": "Es ist kein gueltiges Subnetz konfiguriert -- die Datei wurde nicht geschrieben.", "message": "Es ist kein gültiges Subnetz konfiguriert – die Datei wurde nicht geschrieben.",
"warnings": warnings, "warnings": warnings,
} }
result = sysops.write_kea_config(kea.config_to_text(config)) result = sysops.write_kea_config(kea.config_to_text(config))
@@ -480,13 +480,13 @@ def _write_and_redirect():
return redirect(url_for("dhcp.overview")) return redirect(url_for("dhcp.overview"))
@bp.post("/schreiben") @bp.post("/write")
@permission_required("dhcp.edit") @permission_required("dhcp.edit")
def write_config(): def write_config():
return _write_and_redirect() return _write_and_redirect()
@bp.post("/dienst/<action>") @bp.post("/service/<action>")
@permission_required("dhcp.execute") @permission_required("dhcp.execute")
def service_action(action: str): def service_action(action: str):
allowed = {"install", "start", "stop"} allowed = {"install", "start", "stop"}
@@ -495,7 +495,7 @@ def service_action(action: str):
extension = core() extension = core()
sysops = extension.sysops sysops = extension.sysops
if sysops is None or not sysops.available: if sysops is None or not sysops.available:
flash("Systemaktionen sind in dieser Umgebung nicht verfuegbar.", "warning") flash("Systemaktionen sind in dieser Umgebung nicht verfügbar.", "warning")
return redirect(url_for("dhcp.overview")) return redirect(url_for("dhcp.overview"))
if action == "install": if action == "install":
+279 -42
View File
@@ -2,7 +2,10 @@
from __future__ import annotations from __future__ import annotations
import hashlib
import io import io
import json
from datetime import datetime, timezone
from flask import ( from flask import (
Blueprint, Blueprint,
@@ -18,11 +21,12 @@ from flask import (
from tesm_core import audit 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
from tesm_core.auth.service import client_ip from tesm_core.auth.service import client_ip
from tesm_core import uploads
from tesm_core.extension import core, db, transaction from tesm_core.extension import core, db, transaction
from ..services import fileshare as service from ..services import fileshare as service
bp = Blueprint("fileshare", __name__, url_prefix="/dateifreigaben") bp = Blueprint("fileshare", __name__, url_prefix="/fileshares")
@bp.before_request @bp.before_request
@@ -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: def _mount_or_404(token: str) -> service.Mount:
mounts = service.active_mounts(db(), current_user.id) conn = db()
mounts = service.doppelte_zusammenfassen(
conn, service.active_mounts(conn, current_user.id), service.aktuelle_sitzung()
)
for mount in mounts: for mount in mounts:
if mount.token == token: if mount.token == token:
return mount return mount
@@ -58,9 +87,11 @@ def _mount_or_404(token: str) -> service.Mount:
@permission_required("fileshare.view") @permission_required("fileshare.view")
def browse(): def browse():
conn = db() conn = db()
mounts = service.active_mounts(conn, current_user.id) mounts = service.doppelte_zusammenfassen(
token = request.args.get("freigabe") or (mounts[0].token if mounts else "") conn, service.active_mounts(conn, current_user.id), service.aktuelle_sitzung()
relative = request.args.get("pfad") or "" )
token = request.args.get("share") or (mounts[0].token if mounts else "")
relative = request.args.get("path") or ""
listing = None listing = None
error = "" error = ""
if token: if token:
@@ -75,15 +106,27 @@ def browse():
token=token, token=token,
listing=listing, listing=listing,
error=error, 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") @bp.get("/<token>/download")
@permission_required("fileshare.view") # Herunterladen traegt Daten aus dem System heraus -- in der Rechtematrix ist
# das "Exportieren", nicht "Ansehen". Eine Vorschau bleibt im Haus, eine
# heruntergeladene Datei nicht.
@permission_required("fileshare.export")
def download(token: str): def download(token: str):
mount = _mount_or_404(token) mount = _mount_or_404(token)
relative = request.args.get("pfad") or "" relative = request.args.get("path") or ""
try: try:
path = service.resolve_path(mount, relative) path = service.resolve_path(mount, relative)
except service.FileshareError as exc: except service.FileshareError as exc:
@@ -95,15 +138,20 @@ def download(token: str):
path, path,
as_attachment=True, as_attachment=True,
download_name=path.name, 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") @bp.get("/<token>/preview")
@permission_required("fileshare.view") @permission_required("fileshare.view")
def preview(token: str): def preview(token: str):
mount = _mount_or_404(token) mount = _mount_or_404(token)
relative = request.args.get("pfad") or "" relative = request.args.get("path") or ""
try: try:
path = service.resolve_path(mount, relative) path = service.resolve_path(mount, relative)
mimetype = service.preview_mime(path.name) mimetype = service.preview_mime(path.name)
@@ -138,17 +186,17 @@ def preview(token: str):
) )
@bp.post("/<token>/hochladen") @bp.post("/<token>/upload")
@permission_required("fileshare.create") @permission_required("fileshare.create")
def upload(token: str): def upload(token: str):
mount = _mount_or_404(token) mount = _mount_or_404(token)
relative = request.form.get("pfad") or "" relative = request.form.get("path") or ""
files = request.files.getlist("files") files = request.files.getlist("files")
if not files: if not files:
flash("Keine Datei ausgewaehlt.", "danger") flash("Keine Datei ausgewählt.", "danger")
return redirect(url_for("fileshare.browse", freigabe=token, pfad=relative)) return redirect(url_for("fileshare.browse", share=token, path=relative))
limit = int(core().settings.get(db(), "fileshare_upload_max_mb") or 512) * 1024 * 1024 limit = uploads.limit_bytes(db())
stored = 0 stored = 0
try: try:
target_dir = service.resolve_path(mount, relative) target_dir = service.resolve_path(mount, relative)
@@ -158,58 +206,218 @@ def upload(token: str):
name = service.safe_filename(upload_file.filename) name = service.safe_filename(upload_file.filename)
data = upload_file.stream.read(limit + 1) data = upload_file.stream.read(limit + 1)
if len(data) > limit: if len(data) > limit:
flash(f"{name}: zu gross (Grenze {limit // 1024 // 1024} MB).", "danger") flash(f"{name}: zu groß (Grenze {limit // 1024 // 1024} MB).", "danger")
continue continue
(target_dir / name).write_bytes(data) (target_dir / name).write_bytes(data)
stored += 1 stored += 1
except service.FileshareError as exc: except service.FileshareError as exc:
flash(str(exc), "danger") flash(str(exc), "danger")
return redirect(url_for("fileshare.browse", freigabe=token, pfad=relative)) return redirect(url_for("fileshare.browse", share=token, path=relative))
if stored: if stored:
_audit("fileshare.uploaded", f"{mount.label}:{relative}", f"{stored} Datei(en)") _audit("fileshare.uploaded", f"{mount.label}:{relative}", f"{stored} Datei(en)")
flash(f"{stored} Datei(en) hochgeladen.", "success") flash(f"{stored} Datei(en) hochgeladen.", "success")
return redirect(url_for("fileshare.browse", freigabe=token, pfad=relative)) return redirect(url_for("fileshare.browse", share=token, path=relative))
@bp.post("/<token>/ordner") @bp.get("/<token>/signature")
@permission_required("fileshare.view")
def signature_state(token: str):
"""Passt die Datei noch zu ihrem Eintrag im Aenderungsprotokoll?
Die Vorschau fragt das beim Oeffnen. Eine unterschriebene Fassung, die
seither veraendert wurde, sieht man einer PDF sonst nicht an -- und genau
das ist die Frage, die man sich vor einer Unterschrift stellt.
"""
mount = _mount_or_404(token)
try:
return service.signaturstand(db(), mount, request.args.get("path") or "")
except service.FileshareError as exc:
return {"error": str(exc)}, 400
@bp.get("/<token>/folders")
@permission_required("fileshare.view")
def folders(token: str):
"""Die Ordner **dieser** Freigabe als Vorschlagsliste.
Gebraucht, seit das Speichern in eine andere Freigabe gehen darf: die
Vorschlaege muessen der gewaehlten Freigabe folgen. Alle Listen beim
Seitenaufbau mitzuschicken waere die Alternative gewesen -- bei vier
eingebundenen Freigaben mit je ein paar hundert Ordnern ist das eine
viertel Megabyte, die fast nie gebraucht wird.
"""
mount = _mount_or_404(token)
return {"folders": service.all_directories(mount)}
@bp.post("/<token>/form")
@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
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.edit"):
return {
"error": "Sie dürfen ausgefüllte Formulare nur abgeschlossen speichern "
"(Felder gesperrt). Bitte im Auswahlfeld 'Abgeschlossen' wählen."
}, 403
# Wohin und unter welchem Namen -- beides darf der Benutzer bestimmen.
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 "")
# 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.
beschreibung = f"{ziel_mount.label}:{wohin} sha256={pruefsumme}"
if unterschriften:
namen = ", ".join(unterschriften)
beschreibung = f"{beschreibung} -- angegeben unterschrieben von: {namen}"
_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") @permission_required("fileshare.create")
def make_directory(token: str): def make_directory(token: str):
mount = _mount_or_404(token) mount = _mount_or_404(token)
relative = request.form.get("pfad") or "" relative = request.form.get("path") or ""
try: try:
service.make_directory(mount, relative, request.form.get("name", "")) service.make_directory(mount, relative, request.form.get("name", ""))
except (service.FileshareError, OSError) as exc: except (service.FileshareError, OSError) as exc:
flash(str(exc), "danger") flash(str(exc), "danger")
return redirect(url_for("fileshare.browse", freigabe=token, pfad=relative)) return redirect(url_for("fileshare.browse", share=token, path=relative))
_audit("fileshare.directory_created", f"{mount.label}:{relative}/{request.form.get('name')}") _audit("fileshare.directory_created", f"{mount.label}:{relative}/{request.form.get('name')}")
flash("Ordner angelegt.", "success") flash("Ordner angelegt.", "success")
return redirect(url_for("fileshare.browse", freigabe=token, pfad=relative)) return redirect(url_for("fileshare.browse", share=token, path=relative))
@bp.post("/<token>/umbenennen") @bp.post("/<token>/rename")
@permission_required("fileshare.edit") @permission_required("fileshare.edit")
def rename(token: str): def rename(token: str):
mount = _mount_or_404(token) mount = _mount_or_404(token)
relative = request.form.get("pfad") or "" relative = request.form.get("path") or ""
parent = request.form.get("parent") or "" parent = request.form.get("parent") or ""
try: try:
service.rename_entry(mount, relative, request.form.get("name", "")) service.rename_entry(mount, relative, request.form.get("name", ""))
except (service.FileshareError, OSError) as exc: except (service.FileshareError, OSError) as exc:
flash(str(exc), "danger") flash(str(exc), "danger")
return redirect(url_for("fileshare.browse", freigabe=token, pfad=parent)) return redirect(url_for("fileshare.browse", share=token, path=parent))
_audit("fileshare.renamed", f"{mount.label}:{relative}") _audit("fileshare.renamed", f"{mount.label}:{relative}")
flash("Umbenannt.", "success") flash("Umbenannt.", "success")
return redirect(url_for("fileshare.browse", freigabe=token, pfad=parent)) return redirect(url_for("fileshare.browse", share=token, path=parent))
@bp.post("/<token>/loeschen") @bp.post("/<token>/delete")
@permission_required("fileshare.delete") @permission_required("fileshare.delete")
def delete(token: str): def delete(token: str):
mount = _mount_or_404(token) mount = _mount_or_404(token)
parent = request.form.get("parent") or "" parent = request.form.get("parent") or ""
removed = 0 removed = 0
for relative in request.form.getlist("pfade"): for relative in request.form.getlist("paths"):
try: try:
kind = service.delete_entry(mount, relative) kind = service.delete_entry(mount, relative)
_audit("fileshare.deleted", f"{mount.label}:{relative}", kind, severity="warning") _audit("fileshare.deleted", f"{mount.label}:{relative}", kind, severity="warning")
@@ -217,18 +425,18 @@ def delete(token: str):
except (service.FileshareError, OSError) as exc: except (service.FileshareError, OSError) as exc:
flash(str(exc), "danger") flash(str(exc), "danger")
if removed: if removed:
flash(f"{removed} Eintrag/Eintraege geloescht.", "success") flash(f"{removed} Eintrag/Einträge gelöscht.", "success")
return redirect(url_for("fileshare.browse", freigabe=token, pfad=parent)) return redirect(url_for("fileshare.browse", share=token, path=parent))
@bp.post("/<token>/freigeben") @bp.post("/<token>/release")
@permission_required("fileshare.view") @permission_required("fileshare.view")
def release(token: str): def release(token: str):
mount = _mount_or_404(token) mount = _mount_or_404(token)
with transaction() as conn: with transaction() as conn:
service.release_mount(conn, mount.id) service.release_mount(conn, mount.id)
_audit("fileshare.released", mount.label) _audit("fileshare.released", mount.label)
flash("Einbindung geloest.", "success") flash("Einbindung gelöst.", "success")
return redirect(url_for("fileshare.browse")) return redirect(url_for("fileshare.browse"))
@@ -237,7 +445,7 @@ def release(token: str):
# --------------------------------------------------------------------------- # # --------------------------------------------------------------------------- #
@bp.get("/zuordnungen") @bp.get("/mappings")
@permission_required("fileshare.edit") @permission_required("fileshare.edit")
def mappings(): def mappings():
from tesm_core.auth import directory from tesm_core.auth import directory
@@ -251,7 +459,7 @@ def mappings():
) )
@bp.post("/zuordnungen") @bp.post("/mappings")
@permission_required("fileshare.edit") @permission_required("fileshare.edit")
def mapping_add(): def mapping_add():
try: try:
@@ -263,18 +471,33 @@ def mapping_add():
label=request.form.get("label", ""), label=request.form.get("label", ""),
unc=request.form.get("unc", ""), unc=request.form.get("unc", ""),
) )
_audit("fileshare.mapping_added", request.form.get("label", ""), severity="notice") _audit(
"fileshare.mapping_added",
request.form.get("label", ""),
service.describe_mapping(
{
"unc": request.form.get("unc", ""),
"ad_group_name": request.form.get("ad_group_name", ""),
"ad_group_dn": request.form.get("ad_group_dn", ""),
}
),
severity="notice",
)
flash("Zuordnung gespeichert.", "success") flash("Zuordnung gespeichert.", "success")
except service.FileshareError as exc: except service.FileshareError as exc:
flash(str(exc), "danger") flash(str(exc), "danger")
return redirect(url_for("fileshare.mappings")) return redirect(url_for("fileshare.mappings"))
@bp.post("/zuordnungen/<int:mapping_id>") @bp.post("/mappings/<int:mapping_id>")
@permission_required("fileshare.edit") @permission_required("fileshare.edit")
def mapping_update(mapping_id: int): def mapping_update(mapping_id: int):
try: try:
with transaction() as conn: with transaction() as conn:
# Den Stand vor der Aenderung merken -- danach ist er weg, und
# "geaendert" ohne "was" beantwortet keine einzige Frage, die man
# spaeter an ein Protokoll stellt.
vorher = service.get_mapping(conn, mapping_id)
service.update_mapping( service.update_mapping(
conn, conn,
mapping_id, mapping_id,
@@ -283,18 +506,32 @@ def mapping_update(mapping_id: int):
label=request.form.get("label", ""), label=request.form.get("label", ""),
unc=request.form.get("unc", ""), unc=request.form.get("unc", ""),
) )
_audit("fileshare.mapping_updated", request.form.get("label", ""), severity="notice") nachher = service.get_mapping(conn, mapping_id)
flash("Zuordnung geaendert.", "success") _audit(
"fileshare.mapping_updated",
str((nachher or {}).get("label") or request.form.get("label", "")),
service.mapping_changes(vorher, nachher) or "keine inhaltliche Änderung",
severity="notice",
)
flash("Zuordnung geändert.", "success")
except service.FileshareError as exc: except service.FileshareError as exc:
flash(str(exc), "danger") flash(str(exc), "danger")
return redirect(url_for("fileshare.mappings")) return redirect(url_for("fileshare.mappings"))
@bp.post("/zuordnungen/<int:mapping_id>/loeschen") @bp.post("/mappings/<int:mapping_id>/delete")
@permission_required("fileshare.edit") @permission_required("fileshare.edit")
def mapping_delete(mapping_id: int): def mapping_delete(mapping_id: int):
with transaction() as conn: with transaction() as conn:
entfernt = service.get_mapping(conn, mapping_id)
service.delete_mapping(conn, mapping_id) service.delete_mapping(conn, mapping_id)
_audit("fileshare.mapping_deleted", str(mapping_id), severity="notice") # Eine entfernte Zuordnung nimmt allen Mitgliedern der Gruppe den Zugang
# zu dieser Freigabe -- das ist keine Notiz am Rande, sondern eine Warnung.
_audit(
"fileshare.mapping_deleted",
str((entfernt or {}).get("label") or mapping_id),
service.describe_mapping(entfernt),
severity="warning",
)
flash("Zuordnung entfernt.", "success") flash("Zuordnung entfernt.", "success")
return redirect(url_for("fileshare.mappings")) return redirect(url_for("fileshare.mappings"))
+11 -11
View File
@@ -31,7 +31,7 @@ from tesm_core.extension import core, db, transaction
from ..services import inventory, ssh from ..services import inventory, ssh
bp = Blueprint("hostkeys", __name__, url_prefix="/hostschluessel") bp = Blueprint("hostkeys", __name__, url_prefix="/monitoring/host-keys")
KIND_DEVICE = "client" KIND_DEVICE = "client"
KIND_SWITCH = "switch" KIND_SWITCH = "switch"
@@ -142,7 +142,7 @@ def _require(kind: str) -> None:
abort(403) abort(403)
@bp.post("/<kind>/<int:target_id>/pruefen") @bp.post("/<kind>/<int:target_id>/probe")
@permission_required("devices.view", "switches.view", mode="any") @permission_required("devices.view", "switches.view", mode="any")
def probe(kind: str, target_id: int): def probe(kind: str, target_id: int):
conn = db() conn = db()
@@ -169,7 +169,7 @@ def probe(kind: str, target_id: int):
) )
@bp.post("/<kind>/<int:target_id>/freigeben") @bp.post("/<kind>/<int:target_id>/trust")
@permission_required("devices.view", "switches.view", mode="any") @permission_required("devices.view", "switches.view", mode="any")
@reauth_required @reauth_required
def trust(kind: str, target_id: int): def trust(kind: str, target_id: int):
@@ -181,7 +181,7 @@ def trust(kind: str, target_id: int):
key_type = (request.form.get("key_type") or "").strip() key_type = (request.form.get("key_type") or "").strip()
fingerprint = (request.form.get("fingerprint") or "").strip() fingerprint = (request.form.get("fingerprint") or "").strip()
if not key_type or not ssh.valid_fingerprint(fingerprint): if not key_type or not ssh.valid_fingerprint(fingerprint):
flash("Es wurde kein gueltiger Fingerprint uebergeben.", "danger") flash("Es wurde kein gültiger Fingerprint übergeben.", "danger")
return redirect(url_for("hostkeys.overview")) return redirect(url_for("hostkeys.overview"))
with transaction(): with transaction():
@@ -199,11 +199,11 @@ def trust(kind: str, target_id: int):
f"{key_type} {fingerprint}", f"{key_type} {fingerprint}",
severity="notice", severity="notice",
) )
flash(f"Host-Schluessel von {target['label']} freigegeben.", "success") flash(f"Host-Schlüssel von {target['label']} freigegeben.", "success")
return redirect(url_for("hostkeys.overview")) return redirect(url_for("hostkeys.overview"))
@bp.post("/<kind>/<int:target_id>/verwerfen") @bp.post("/<kind>/<int:target_id>/forget")
@permission_required("devices.view", "switches.view", mode="any") @permission_required("devices.view", "switches.view", mode="any")
def forget(kind: str, target_id: int): def forget(kind: str, target_id: int):
conn = db() conn = db()
@@ -218,11 +218,11 @@ def forget(kind: str, target_id: int):
target["label"], target["label"],
severity="warning", severity="warning",
) )
flash(f"Gespeicherte Schluessel von {target['label']} entfernt.", "info") flash(f"Gespeicherte Schlüssel von {target['label']} entfernt.", "info")
return redirect(url_for("hostkeys.overview")) return redirect(url_for("hostkeys.overview"))
@bp.post("/verwaist/verwerfen") @bp.post("/orphaned/forget")
@permission_required("devices.edit", "switches.edit", mode="any") @permission_required("devices.edit", "switches.edit", mode="any")
def forget_orphan(): def forget_orphan():
"""Entfernt eine Freigabe, zu der es kein Ziel mehr gibt.""" """Entfernt eine Freigabe, zu der es kein Ziel mehr gibt."""
@@ -232,18 +232,18 @@ def forget_orphan():
except ValueError: except ValueError:
port = 0 port = 0
if not host or not 1 <= port <= 65535: if not host or not 1 <= port <= 65535:
flash("Ungueltiges Ziel.", "danger") flash("Ungültiges Ziel.", "danger")
return redirect(url_for("hostkeys.overview")) return redirect(url_for("hostkeys.overview"))
conn = db() conn = db()
if (host, port) in {(row["host"], row["port"]) for row in _targets(conn)}: if (host, port) in {(row["host"], row["port"]) for row in _targets(conn)}:
# Ein Ziel, das es noch gibt, wird ueber seine eigene Zeile verworfen -- # Ein Ziel, das es noch gibt, wird ueber seine eigene Zeile verworfen --
# sonst umginge dieser Weg die Rechtepruefung je Objektart. # sonst umginge dieser Weg die Rechtepruefung je Objektart.
flash("Zu diesem Eintrag gibt es ein Ziel -- bitte dort verwerfen.", "warning") flash("Zu diesem Eintrag gibt es ein Ziel – bitte dort verwerfen.", "warning")
return redirect(url_for("hostkeys.overview")) return redirect(url_for("hostkeys.overview"))
with transaction(): with transaction():
ssh.forget_key(conn, host, port) ssh.forget_key(conn, host, port)
_audit("host_key.orphan_forgotten", f"{host}:{port}", severity="warning") _audit("host_key.orphan_forgotten", f"{host}:{port}", severity="warning")
flash(f"Verwaiste Freigabe fuer {host}:{port} entfernt.", "info") flash(f"Verwaiste Freigabe für {host}:{port} entfernt.", "info")
return redirect(url_for("hostkeys.overview")) return redirect(url_for("hostkeys.overview"))
+13 -13
View File
@@ -23,7 +23,7 @@ from tesm_core.licensing_client import LICENSED_FEATURES, LicenseError
import tesm_licensing as lic import tesm_licensing as lic
bp = Blueprint("license", __name__, url_prefix="/lizenz") bp = Blueprint("license", __name__, url_prefix="/administration/license")
MAX_UPLOAD_BYTES = 256 * 1024 MAX_UPLOAD_BYTES = 256 * 1024
@@ -69,7 +69,7 @@ def overview():
) )
@bp.post("/einspielen") @bp.post("/install")
@permission_required("settings_license.edit") @permission_required("settings_license.edit")
def upload(): def upload():
extension = core() extension = core()
@@ -80,11 +80,11 @@ def upload():
upload_file = request.files.get("license_file") upload_file = request.files.get("license_file")
if upload_file is None or not upload_file.filename: if upload_file is None or not upload_file.filename:
flash("Keine Datei ausgewaehlt.", "danger") flash("Keine Datei ausgewählt.", "danger")
return redirect(url_for("license.overview")) return redirect(url_for("license.overview"))
raw = upload_file.stream.read(MAX_UPLOAD_BYTES + 1) raw = upload_file.stream.read(MAX_UPLOAD_BYTES + 1)
if len(raw) > MAX_UPLOAD_BYTES: if len(raw) > MAX_UPLOAD_BYTES:
flash("Die Datei ist zu gross fuer eine Lizenzdatei.", "danger") flash("Die Datei ist zu groß für eine Lizenzdatei.", "danger")
return redirect(url_for("license.overview")) return redirect(url_for("license.overview"))
allow_new_issuer = request.form.get("allow_new_issuer") == "1" and current_user.is_admin allow_new_issuer = request.form.get("allow_new_issuer") == "1" and current_user.is_admin
@@ -104,19 +104,19 @@ def upload():
if state.status and state.status.role != "customer": if state.status and state.status.role != "customer":
flash( flash(
f"Achtung: diese Lizenz hat die Rolle {state.status.role!r}. " f"Achtung: diese Lizenz hat die Rolle {state.status.role!r}. "
"In einer TESM-Kundeninstanz ist das ungewoehnlich.", "In einer TESM-Kundeninstanz ist das ungewöhnlich.",
"warning", "warning",
) )
return redirect(url_for("license.overview")) return redirect(url_for("license.overview"))
@bp.post("/aktivieren") @bp.post("/activate")
@permission_required("settings_license.edit") @permission_required("settings_license.edit")
def activate(): def activate():
return _handshake("activate") return _handshake("activate")
@bp.post("/deaktivieren") @bp.post("/deactivate")
@permission_required("settings_license.edit") @permission_required("settings_license.edit")
@reauth_required @reauth_required
def deactivate(): def deactivate():
@@ -158,7 +158,7 @@ def _handshake(action: str):
return redirect(url_for("license.overview")) return redirect(url_for("license.overview"))
@bp.post("/offline-antwort") @bp.post("/offline-response")
@permission_required("settings_license.edit") @permission_required("settings_license.edit")
def offline_response(): def offline_response():
extension = core() extension = core()
@@ -177,7 +177,7 @@ def offline_response():
return redirect(url_for("license.overview")) return redirect(url_for("license.overview"))
@bp.post("/serverzertifikat") @bp.post("/server-certificate")
@permission_required("settings_license.edit") @permission_required("settings_license.edit")
def server_certificate(): def server_certificate():
"""Hinterlegt das Zertifikat des Lizenzservers. """Hinterlegt das Zertifikat des Lizenzservers.
@@ -214,15 +214,15 @@ def server_certificate():
severity="notice", severity="notice",
) )
flash( flash(
"Zertifikat hinterlegt. Der naechste Versuch laeuft wieder ueber den Online-Weg." "Zertifikat hinterlegt. Der nächste Versuch läuft wieder über den Online-Weg."
if pem if pem
else "Zertifikat entfernt -- es gelten wieder die Systemanker.", else "Zertifikat entfernt – es gelten wieder die Systemanker.",
"success", "success",
) )
return redirect(url_for("license.overview")) return redirect(url_for("license.overview"))
@bp.post("/aussteller/<key_id>/entfernen") @bp.post("/issuer/<key_id>/remove")
@admin_required @admin_required
@reauth_required @reauth_required
def remove_trusted_key(key_id: str): def remove_trusted_key(key_id: str):
@@ -246,5 +246,5 @@ def remove_trusted_key(key_id: str):
if manager is not None: if manager is not None:
with core().database.session() as conn: with core().database.session() as conn:
manager.reload(conn) manager.reload(conn)
flash("Aussteller-Schluessel entfernt.", "info") flash("Aussteller-Schlüssel entfernt.", "info")
return redirect(url_for("license.overview")) return redirect(url_for("license.overview"))
+5 -5
View File
@@ -13,12 +13,12 @@ from tesm_core.extension import core, db
from ..services import kea, monitor from ..services import kea, monitor
bp = Blueprint("tesmlogs", __name__, url_prefix="/protokolle") bp = Blueprint("tesmlogs", __name__, url_prefix="/logs")
MAX_TAIL_LINES = 2000 MAX_TAIL_LINES = 2000
@bp.get("/kea") @bp.get("/dhcp")
@permission_required("logs_dhcp.view") @permission_required("logs_dhcp.view")
def dhcp(): def dhcp():
extension = core() extension = core()
@@ -34,11 +34,11 @@ def dhcp():
else: else:
error = result.message error = result.message
else: else:
error = "Systemhelfer nicht verfuegbar." error = "Systemhelfer nicht verfügbar."
return render_template("tesm/logs_kea.html", lines=lines, path=kea.LOG_PATH, error=error) return render_template("tesm/logs_kea.html", lines=lines, path=kea.LOG_PATH, error=error)
@bp.get("/neustarts") @bp.get("/restarts")
@permission_required("devices.view") @permission_required("devices.view")
def restarts(): def restarts():
"""Alle Neustarts -- ueber den PoE-Port und per Befehl aus der Wartung. """Alle Neustarts -- ueber den PoE-Port und per Befehl aus der Wartung.
@@ -48,7 +48,7 @@ def restarts():
der Neustart-Historie vollstaendig -- obwohl genau dort jemand nachsieht, der Neustart-Historie vollstaendig -- obwohl genau dort jemand nachsieht,
der wissen will, warum ein Geraet weg war. der wissen will, warum ein Geraet weg war.
""" """
method = (request.args.get("methode") or "").strip() method = (request.args.get("method") or "").strip()
if method and method not in monitor.METHOD_LABELS: if method and method not in monitor.METHOD_LABELS:
method = "" method = ""
sql = ( sql = (
+12 -12
View File
@@ -25,7 +25,7 @@ from tesm_core.extension import core, db, transaction
from ..services import inventory, maintenance as service, ssh, windowsops, monitor from ..services import inventory, maintenance as service, ssh, windowsops, monitor
bp = Blueprint("maintenance", __name__, url_prefix="/wartung") bp = Blueprint("maintenance", __name__, url_prefix="/maintenance")
@bp.before_request @bp.before_request
@@ -85,7 +85,7 @@ def overview():
category_labels=dict(inventory.CREDENTIAL_CATEGORIES), category_labels=dict(inventory.CREDENTIAL_CATEGORIES),
category_effects=inventory.CATEGORY_EFFECTS, category_effects=inventory.CATEGORY_EFFECTS,
update_category=inventory.MAINTENANCE_CATEGORY, update_category=inventory.MAINTENANCE_CATEGORY,
terminal_enabled=bool(core().settings.get(conn, "terminal_enabled")), terminal_enabled=bool(core().settings.get(conn, "terminal_clients_enabled")),
windows_tool_missing=_windows_tool_missing(conn), windows_tool_missing=_windows_tool_missing(conn),
windows_package=windowsops.REQUIRED_PACKAGE, windows_package=windowsops.REQUIRED_PACKAGE,
) )
@@ -105,7 +105,7 @@ def _windows_tool_missing(conn) -> bool: # type: ignore[no-untyped-def]
) )
@bp.post("/paket-nachinstallieren") @bp.post("/install-package")
@permission_required("maintenance.execute") @permission_required("maintenance.execute")
def install_windows_tool(): def install_windows_tool():
"""Installiert das Paket, das der Fehlermeldung nach fehlt. """Installiert das Paket, das der Fehlermeldung nach fehlt.
@@ -116,7 +116,7 @@ def install_windows_tool():
""" """
sysops = core().sysops sysops = core().sysops
if sysops is None or not sysops.available: if sysops is None or not sysops.available:
flash("Systemaktionen sind in dieser Umgebung nicht verfuegbar.", "warning") flash("Systemaktionen sind in dieser Umgebung nicht verfügbar.", "warning")
return redirect(url_for("maintenance.overview")) return redirect(url_for("maintenance.overview"))
result = sysops.apt_install(windowsops.REQUIRED_PACKAGE) result = sysops.apt_install(windowsops.REQUIRED_PACKAGE)
@@ -165,7 +165,7 @@ def status():
) )
@bp.get("/auftrag/<job_id>") @bp.get("/job/<job_id>")
@permission_required("maintenance.view", "maintenance.execute", mode="any") @permission_required("maintenance.view", "maintenance.execute", mode="any")
def job_detail(job_id: str): def job_detail(job_id: str):
conn = db() conn = db()
@@ -175,7 +175,7 @@ def job_detail(job_id: str):
return render_template("tesm/maintenance_job.html", job=job) return render_template("tesm/maintenance_job.html", job=job)
@bp.get("/auftrag/<job_id>/ausgabe") @bp.get("/job/<job_id>/output")
@permission_required("maintenance.view", "maintenance.execute", mode="any") @permission_required("maintenance.view", "maintenance.execute", mode="any")
@fragment @fragment
def job_output(job_id: str): def job_output(job_id: str):
@@ -191,7 +191,7 @@ def _start(kind: str) -> None:
conn = db() conn = db()
device_ids = [int(value) for value in request.form.getlist("device_ids") if value.isdigit()] device_ids = [int(value) for value in request.form.getlist("device_ids") if value.isdigit()]
if not device_ids: if not device_ids:
flash("Es wurde kein Geraet ausgewaehlt.", "warning") flash("Es wurde kein Gerät ausgewählt.", "warning")
return return
eligible = {int(row["id"]): row for row in _eligible_devices(conn)} eligible = {int(row["id"]): row for row in _eligible_devices(conn)}
@@ -204,8 +204,8 @@ def _start(kind: str) -> None:
# zurueck, der nie gestartet wird und ewig auf "wartet" steht. # zurueck, der nie gestartet wird und ewig auf "wartet" steht.
if kind == service.JOB_KIND_UPDATE and str(device["category"]) != "linux": if kind == service.JOB_KIND_UPDATE and str(device["category"]) != "linux":
flash( flash(
f"{device['name']}: Paketaktualisierung gibt es nur fuer Linux-Clients. " f"{device['name']}: Paketaktualisierung gibt es nur für Linux-Clients. "
"Windows-Geraete lassen sich hier nur neu starten.", "Windows-Geräte lassen sich hier nur neu starten.",
"warning", "warning",
) )
continue continue
@@ -273,17 +273,17 @@ def _start(kind: str) -> None:
started += 1 started += 1
if started: if started:
flash(f"{started} Auftrag/Auftraege gestartet.", "success") flash(f"{started} Auftrag/Aufträge gestartet.", "success")
@bp.post("/aktualisieren") @bp.post("/update")
@permission_required("maintenance.execute") @permission_required("maintenance.execute")
def start_update(): def start_update():
_start(service.JOB_KIND_UPDATE) _start(service.JOB_KIND_UPDATE)
return redirect(url_for("maintenance.overview")) return redirect(url_for("maintenance.overview"))
@bp.post("/neustarten") @bp.post("/restart")
@permission_required("maintenance.execute") @permission_required("maintenance.execute")
def start_reboot(): def start_reboot():
_start(service.JOB_KIND_REBOOT) _start(service.JOB_KIND_REBOOT)
+19 -10
View File
@@ -18,7 +18,7 @@ from tesm_core.auth.service import client_ip
from tesm_core.extension import core, db, transaction from tesm_core.extension import core, db, transaction
from tesm_core.licensing_client import LicenseError from tesm_core.licensing_client import LicenseError
bp = Blueprint("settings", __name__, url_prefix="/einstellungen") bp = Blueprint("settings", __name__, url_prefix="/administration")
def _audit(action: str, target: str = "", detail: str = "", severity: str = "info") -> None: def _audit(action: str, target: str = "", detail: str = "", severity: str = "info") -> None:
@@ -79,9 +79,13 @@ def system():
"monitor_failures_before_restart", "monitor_failures_before_restart",
"monitor_auto_restart", "monitor_auto_restart",
"dashboard_public", "dashboard_public",
"terminal_enabled", "terminal_switches_enabled",
"terminal_clients_enabled",
"terminal_idle_timeout", "terminal_idle_timeout",
"signature_witness_enabled",
"trash_retention_days", "trash_retention_days",
"session_idle_minutes",
"session_absolute_hours",
"log_rotate_interval", "log_rotate_interval",
"log_rotate_keep", "log_rotate_keep",
], ],
@@ -92,6 +96,7 @@ def system():
timezones=_timezone_choices(), timezones=_timezone_choices(),
nav_items=extension.nav.items, nav_items=extension.nav.items,
nav_order=extension.settings.get(conn, "nav_order") or extension.nav.default_order(), nav_order=extension.settings.get(conn, "nav_order") or extension.nav.default_order(),
nav_child_order=extension.settings.get(conn, "nav_child_order") or {},
license_hint=_license_hint("monitor_interval"), license_hint=_license_hint("monitor_interval"),
sysops_available=bool(sysops and sysops.available), sysops_available=bool(sysops and sysops.available),
) )
@@ -109,9 +114,13 @@ def system_save():
"monitor_failures_before_restart", "monitor_failures_before_restart",
"monitor_auto_restart", "monitor_auto_restart",
"dashboard_public", "dashboard_public",
"terminal_enabled", "terminal_switches_enabled",
"terminal_clients_enabled",
"terminal_idle_timeout", "terminal_idle_timeout",
"signature_witness_enabled",
"trash_retention_days", "trash_retention_days",
"session_idle_minutes",
"session_absolute_hours",
"log_rotate_interval", "log_rotate_interval",
"log_rotate_keep", "log_rotate_keep",
): ):
@@ -151,10 +160,10 @@ def system_save():
if changed: if changed:
message = "Einstellungen gespeichert." message = "Einstellungen gespeichert."
if interval_changed: if interval_changed:
message += " Das Pruefintervall greift beim naechsten Durchlauf." message += " Das Prüfintervall greift beim nächsten Durchlauf."
flash(message, "success") flash(message, "success")
else: else:
flash("Keine Aenderungen.", "info") flash("Keine Änderungen.", "info")
return redirect(url_for("settings.system")) return redirect(url_for("settings.system"))
@@ -165,10 +174,10 @@ def set_hostname():
sysops = extension.sysops sysops = extension.sysops
hostname = (request.form.get("hostname") or "").strip() hostname = (request.form.get("hostname") or "").strip()
if not hostname or len(hostname) > 63 or not hostname.replace("-", "").isalnum(): if not hostname or len(hostname) > 63 or not hostname.replace("-", "").isalnum():
flash("Der Hostname ist ungueltig.", "danger") flash("Der Hostname ist ungültig.", "danger")
return redirect(url_for("settings.system")) return redirect(url_for("settings.system"))
if sysops is None or not sysops.available: if sysops is None or not sysops.available:
flash("Systemaktionen sind in dieser Umgebung nicht verfuegbar.", "warning") flash("Systemaktionen sind in dieser Umgebung nicht verfügbar.", "warning")
return redirect(url_for("settings.system")) return redirect(url_for("settings.system"))
result = sysops.set_hostname(hostname) result = sysops.set_hostname(hostname)
if result.ok: if result.ok:
@@ -181,7 +190,7 @@ def set_hostname():
return redirect(url_for("settings.system")) return redirect(url_for("settings.system"))
@bp.post("/system/zeitzone") @bp.post("/system/timezone")
@permission_required("settings_system.execute") @permission_required("settings_system.execute")
def set_timezone(): def set_timezone():
extension = core() extension = core()
@@ -191,7 +200,7 @@ def set_timezone():
flash("Unbekannte Zeitzone.", "danger") flash("Unbekannte Zeitzone.", "danger")
return redirect(url_for("settings.system")) return redirect(url_for("settings.system"))
if sysops is None or not sysops.available: if sysops is None or not sysops.available:
flash("Systemaktionen sind in dieser Umgebung nicht verfuegbar.", "warning") flash("Systemaktionen sind in dieser Umgebung nicht verfügbar.", "warning")
return redirect(url_for("settings.system")) return redirect(url_for("settings.system"))
result = sysops.set_timezone(timezone) result = sysops.set_timezone(timezone)
if result.ok: if result.ok:
@@ -210,7 +219,7 @@ def set_timezone():
return redirect(url_for("settings.system")) return redirect(url_for("settings.system"))
@bp.post("/system/pruefung-jetzt") @bp.post("/system/check-now")
@permission_required("settings_system.execute") @permission_required("settings_system.execute")
def run_check_now(): def run_check_now():
"""Startet einen Ueberwachungsdurchlauf sofort, im Hintergrund.""" """Startet einen Ueberwachungsdurchlauf sofort, im Hintergrund."""
+15 -15
View File
@@ -17,7 +17,7 @@ from tesm_core.extension import core, db, transaction
from ..services import inventory, ssh, switchcli from ..services import inventory, ssh, switchcli
bp = Blueprint("switches", __name__, url_prefix="/switche") bp = Blueprint("switches", __name__, url_prefix="/monitoring/switches")
def _audit(conn: Any, action: str, target: str, detail: str = "", severity: str = "info") -> None: def _audit(conn: Any, action: str, target: str, detail: str = "", severity: str = "info") -> None:
@@ -54,11 +54,11 @@ def overview():
"tesm/switches.html", "tesm/switches.html",
switches=inventory.list_switches(conn), switches=inventory.list_switches(conn),
credentials=inventory.list_credentials(conn), credentials=inventory.list_credentials(conn),
terminal_enabled=bool(core().settings.get(conn, "terminal_enabled")), terminal_enabled=bool(core().settings.get(conn, "terminal_switches_enabled")),
) )
@bp.post("/neu") @bp.post("/new")
@permission_required("switches.create") @permission_required("switches.create")
def create(): def create():
try: try:
@@ -71,7 +71,7 @@ def create():
f"IP {request.form.get('ip')}", f"IP {request.form.get('ip')}",
severity="notice", severity="notice",
) )
flash("Switch angelegt. Der Host-Schluessel muss vor dem ersten Zugriff freigegeben werden.", "success") flash("Switch angelegt. Der Host-Schlüssel muss vor dem ersten Zugriff freigegeben werden.", "success")
return redirect(url_for("switches.detail", switch_id=switch_id)) return redirect(url_for("switches.detail", switch_id=switch_id))
except inventory.ValidationError as exc: except inventory.ValidationError as exc:
flash(str(exc), "danger") flash(str(exc), "danger")
@@ -92,13 +92,13 @@ def update(switch_id: int):
"; ".join(changes), "; ".join(changes),
severity="notice", severity="notice",
) )
flash("Aenderungen gespeichert." if changes else "Keine Aenderungen.", "success" if changes else "info") flash("Änderungen gespeichert." if changes else "Keine Änderungen.", "success" if changes else "info")
except inventory.ValidationError as exc: except inventory.ValidationError as exc:
flash(str(exc), "danger") flash(str(exc), "danger")
return redirect(request.referrer or url_for("switches.overview")) return redirect(request.referrer or url_for("switches.overview"))
@bp.post("/<int:switch_id>/loeschen") @bp.post("/<int:switch_id>/delete")
@permission_required("switches.delete") @permission_required("switches.delete")
def delete(switch_id: int): def delete(switch_id: int):
try: try:
@@ -142,7 +142,7 @@ def detail(switch_id: int):
devices=devices, devices=devices,
credentials=inventory.list_credentials(conn), credentials=inventory.list_credentials(conn),
safe_commands=switchcli.SAFE_COMMANDS, safe_commands=switchcli.SAFE_COMMANDS,
terminal_enabled=bool(core().settings.get(conn, "terminal_enabled")), terminal_enabled=bool(core().settings.get(conn, "terminal_switches_enabled")),
) )
@@ -151,7 +151,7 @@ def detail(switch_id: int):
# --------------------------------------------------------------------------- # # --------------------------------------------------------------------------- #
@bp.post("/<int:switch_id>/hostkey/pruefen") @bp.post("/<int:switch_id>/host-key/probe")
@permission_required("switches.edit") @permission_required("switches.edit")
def probe_host_key(switch_id: int): def probe_host_key(switch_id: int):
conn = db() conn = db()
@@ -176,7 +176,7 @@ def probe_host_key(switch_id: int):
) )
@bp.post("/<int:switch_id>/hostkey/freigeben") @bp.post("/<int:switch_id>/host-key/trust")
@permission_required("switches.edit") @permission_required("switches.edit")
@reauth_required @reauth_required
def trust_host_key(switch_id: int): def trust_host_key(switch_id: int):
@@ -192,7 +192,7 @@ def trust_host_key(switch_id: int):
key_type = (request.form.get("key_type") or "").strip() key_type = (request.form.get("key_type") or "").strip()
fingerprint = (request.form.get("fingerprint") or "").strip() fingerprint = (request.form.get("fingerprint") or "").strip()
if not key_type or not ssh.valid_fingerprint(fingerprint): if not key_type or not ssh.valid_fingerprint(fingerprint):
flash("Es wurde kein gueltiger Fingerprint uebergeben.", "danger") flash("Es wurde kein gültiger Fingerprint übergeben.", "danger")
return redirect(url_for("switches.detail", switch_id=switch_id)) return redirect(url_for("switches.detail", switch_id=switch_id))
with transaction(): with transaction():
@@ -211,11 +211,11 @@ def trust_host_key(switch_id: int):
f"{key_type} {fingerprint}", f"{key_type} {fingerprint}",
severity="notice", severity="notice",
) )
flash("Host-Schluessel freigegeben.", "success") flash("Host-Schlüssel freigegeben.", "success")
return redirect(url_for("switches.detail", switch_id=switch_id)) return redirect(url_for("switches.detail", switch_id=switch_id))
@bp.post("/<int:switch_id>/hostkey/verwerfen") @bp.post("/<int:switch_id>/host-key/forget")
@permission_required("switches.edit") @permission_required("switches.edit")
def forget_host_key(switch_id: int): def forget_host_key(switch_id: int):
conn = db() conn = db()
@@ -225,7 +225,7 @@ def forget_host_key(switch_id: int):
with transaction(): with transaction():
ssh.forget_key(conn, str(switch["ip"]), int(switch["ssh_port"])) ssh.forget_key(conn, str(switch["ip"]), int(switch["ssh_port"]))
_audit(conn, "switch.host_key_forgotten", str(switch["hostname"]), severity="warning") _audit(conn, "switch.host_key_forgotten", str(switch["hostname"]), severity="warning")
flash("Gespeicherte Host-Schluessel entfernt.", "info") flash("Gespeicherte Host-Schlüssel entfernt.", "info")
return redirect(url_for("switches.detail", switch_id=switch_id)) return redirect(url_for("switches.detail", switch_id=switch_id))
@@ -234,7 +234,7 @@ def forget_host_key(switch_id: int):
# --------------------------------------------------------------------------- # # --------------------------------------------------------------------------- #
@bp.post("/<int:switch_id>/kommando") @bp.post("/<int:switch_id>/command")
@permission_required("switches.execute") @permission_required("switches.execute")
@reauth_required @reauth_required
def run_command(switch_id: int): def run_command(switch_id: int):
@@ -256,7 +256,7 @@ def run_command(switch_id: int):
flash("Dieses Kommando steht nicht auf der Positivliste.", "danger") flash("Dieses Kommando steht nicht auf der Positivliste.", "danger")
return redirect(url_for("switches.detail", switch_id=switch_id)) return redirect(url_for("switches.detail", switch_id=switch_id))
if not switch["credential_id"]: if not switch["credential_id"]:
flash("Fuer diesen Switch sind keine Zugangsdaten hinterlegt.", "danger") flash("Für diesen Switch sind keine Zugangsdaten hinterlegt.", "danger")
return redirect(url_for("switches.detail", switch_id=switch_id)) return redirect(url_for("switches.detail", switch_id=switch_id))
try: try:
+30 -15
View File
@@ -24,7 +24,10 @@ Rechteausweitung, die dieses Projekt an anderer Stelle ausschliesst.
Gemeinsame Sicherheitsmerkmale: Gemeinsame Sicherheitsmerkmale:
* Frische Passwortbestaetigung, zusaetzlich zum Recht. * Frische Passwortbestaetigung, zusaetzlich zum Recht.
* Abschaltbar ueber die Einstellung ``terminal_enabled``. * Abschaltbar -- getrennt fuer Switche (``terminal_switches_enabled``) und
Clients (``terminal_clients_enabled``). Das Freigeben von Host-Schluesseln
haengt an eigenen Routen und bleibt davon unberuehrt: es wird fuer den
PoE-Neustart gebraucht.
* Der Host-Schluessel muss vorher freigegeben sein -- die Konsole akzeptiert * Der Host-Schluessel muss vorher freigegeben sein -- die Konsole akzeptiert
keine unbekannten Schluessel (anders als der Vorgaenger, der sie hier keine unbekannten Schluessel (anders als der Vorgaenger, der sie hier
interaktiv uebernahm). Freigegeben wird auf der jeweiligen Detailseite. interaktiv uebernahm). Freigegeben wird auf der jeweiligen Detailseite.
@@ -71,7 +74,7 @@ CLIENT_COMMANDS: tuple[tuple[str, str], ...] = (
("ip -brief address", "Netzwerkadressen"), ("ip -brief address", "Netzwerkadressen"),
("systemctl --failed", "Fehlgeschlagene Dienste"), ("systemctl --failed", "Fehlgeschlagene Dienste"),
("journalctl -p err -n 50 --no-pager", "Letzte Fehler im Journal"), ("journalctl -p err -n 50 --no-pager", "Letzte Fehler im Journal"),
("apt list --upgradable 2>/dev/null", "Verfuegbare Aktualisierungen"), ("apt list --upgradable 2>/dev/null", "Verfügbare Aktualisierungen"),
("uname -a", "Kernel und Architektur"), ("uname -a", "Kernel und Architektur"),
) )
@@ -128,6 +131,18 @@ def _load_target(conn: Any, kind: str, target_id: int) -> Target | None:
) )
def _konsole_erlaubt(conn: Any, kind: str) -> bool:
"""Ist die Konsole fuer diese Art Ziel eingeschaltet?
Zwei Schalter, weil beides unabhaengig voneinander gebraucht wird: eine
Werkstatt will vielleicht auf Switche, aber nicht auf Clients.
"""
schluessel = (
"terminal_switches_enabled" if kind == KIND_SWITCH else "terminal_clients_enabled"
)
return bool(core().settings.get(conn, schluessel))
def _client_module_active() -> bool: def _client_module_active() -> bool:
manager = core().license manager = core().license
return manager is None or manager.module_active("maintenance") return manager is None or manager.module_active("maintenance")
@@ -136,10 +151,10 @@ def _client_module_active() -> bool:
def _render_console(target: Target): # type: ignore[no-untyped-def] def _render_console(target: Target): # type: ignore[no-untyped-def]
extension = core() extension = core()
conn = db() conn = db()
if not extension.settings.get(conn, "terminal_enabled"): if not _konsole_erlaubt(conn, target.kind):
abort(404) abort(404)
if not target.credential_id: if not target.credential_id:
abort(409, description="Fuer dieses Ziel sind keine Zugangsdaten hinterlegt.") abort(409, description="Für dieses Ziel sind keine Zugangsdaten hinterlegt.")
return render_template( return render_template(
"tesm/console.html", "tesm/console.html",
@@ -151,7 +166,7 @@ def _render_console(target: Target): # type: ignore[no-untyped-def]
) )
@bp.get("/switche/<int:switch_id>/konsole") @bp.get("/monitoring/switches/<int:switch_id>/console")
@permission_required("switches.execute") @permission_required("switches.execute")
@reauth_required @reauth_required
def switch_console(switch_id: int): def switch_console(switch_id: int):
@@ -161,7 +176,7 @@ def switch_console(switch_id: int):
return _render_console(target) return _render_console(target)
@bp.get("/clients/<int:device_id>/konsole") @bp.get("/monitoring/clients/<int:device_id>/console")
@permission_required("maintenance.execute") @permission_required("maintenance.execute")
@reauth_required @reauth_required
def device_console(device_id: int): def device_console(device_id: int):
@@ -180,7 +195,7 @@ def init_app(app) -> None: # type: ignore[no-untyped-def]
try: try:
from flask_sock import Sock from flask_sock import Sock
except ImportError: # pragma: no cover - optionale Abhaengigkeit except ImportError: # pragma: no cover - optionale Abhaengigkeit
logger.info("flask-sock ist nicht installiert -- die Konsole bleibt deaktiviert.") logger.info("flask-sock ist nicht installiert – die Konsole bleibt deaktiviert.")
return return
sock = Sock(app) sock = Sock(app)
@@ -212,16 +227,16 @@ def _handle_socket(ws, kind: str, target_id: int) -> None: # type: ignore[no-un
# WebSocket umgeht Dekoratoren, also stehen sie hier ausdruecklich. # WebSocket umgeht Dekoratoren, also stehen sie hier ausdruecklich.
required = "switches.execute" if kind == KIND_SWITCH else "maintenance.execute" required = "switches.execute" if kind == KIND_SWITCH else "maintenance.execute"
if not current_user.is_authenticated or not current_user.has(required): if not current_user.is_authenticated or not current_user.has(required):
_send(ws, "error", "Keine Berechtigung fuer diese Konsole.") _send(ws, "error", "Keine Berechtigung für diese Konsole.")
return return
if not reauth_is_fresh(): if not reauth_is_fresh():
_send(ws, "error", "Die Passwortbestaetigung ist abgelaufen. Bitte Seite neu laden.") _send(ws, "error", "Die Passwortbestätigung ist abgelaufen. Bitte Seite neu laden.")
return return
if not extension.settings.get(conn, "terminal_enabled"): if not _konsole_erlaubt(conn, kind):
_send(ws, "error", "Die Konsole ist in dieser Installation abgeschaltet.") _send(ws, "error", "Die Konsole ist in dieser Installation abgeschaltet.")
return return
if kind == KIND_DEVICE and not _client_module_active(): if kind == KIND_DEVICE and not _client_module_active():
_send(ws, "error", "Die Client-Konsole gehoert zum Modul Wartung -- es ist nicht aktiv.") _send(ws, "error", "Die Client-Konsole gehört zum Modul Wartung – es ist nicht aktiv.")
return return
target = _load_target(conn, kind, target_id) target = _load_target(conn, kind, target_id)
@@ -278,8 +293,8 @@ def _handle_socket(ws, kind: str, target_id: int) -> None: # type: ignore[no-un
_send( _send(
ws, ws,
"error", "error",
f"{exc} Die Konsole akzeptiert keine unbekannten Schluessel -- bitte auf der " f"{exc} Die Konsole akzeptiert keine unbekannten Schlüssel – bitte auf der "
f"{page} ausdruecklich freigeben.", f"{page} ausdrücklich freigeben.",
) )
except ssh.SshError as exc: except ssh.SshError as exc:
_send(ws, "error", str(exc)) _send(ws, "error", str(exc))
@@ -304,7 +319,7 @@ def _read_command(ws, idle_timeout: int) -> str | None: # type: ignore[no-untyp
"""Naechstes Kommando, oder ``None`` wenn die Sitzung enden soll.""" """Naechstes Kommando, oder ``None`` wenn die Sitzung enden soll."""
raw = ws.receive(timeout=idle_timeout) raw = ws.receive(timeout=idle_timeout)
if raw is None: if raw is None:
_send(ws, "info", "Verbindung wegen Untaetigkeit getrennt.") _send(ws, "info", "Verbindung wegen Untätigkeit getrennt.")
return None return None
try: try:
message = json.loads(raw) message = json.loads(raw)
@@ -392,6 +407,6 @@ def _run_client_session( # type: ignore[no-untyped-def]
_send( _send(
ws, ws,
"error", "error",
f"Rueckgabewert {result['status']} nach {result['duration_ms']} ms.", f"Rückgabewert {result['status']} nach {result['duration_ms']} ms.",
) )
return commands return commands
+52 -24
View File
@@ -39,7 +39,7 @@ def cmd_create_admin(args: argparse.Namespace) -> int:
if not password: if not password:
password = getpass.getpass("Passwort: ") password = getpass.getpass("Passwort: ")
if password != getpass.getpass("Passwort wiederholen: "): if password != getpass.getpass("Passwort wiederholen: "):
print("Die Passwoerter stimmen nicht ueberein.", file=sys.stderr) print("Die Passwörter stimmen nicht überein.", file=sys.stderr)
return 2 return 2
problems = extension.password_policy.check(password, username=username) problems = extension.password_policy.check(password, username=username)
@@ -75,7 +75,7 @@ def cmd_create_admin(args: argparse.Namespace) -> int:
actor_id=user_id, actor_id=user_id,
action="user.admin_created", action="user.admin_created",
target=username, target=username,
detail=f"ueber die Kommandozeile {action}", detail=f"über die Kommandozeile {action}",
severity="warning", severity="warning",
) )
print(f"Administrator {username!r} {action}.") print(f"Administrator {username!r} {action}.")
@@ -133,7 +133,7 @@ def cmd_status(args: argparse.Namespace) -> int:
print( print(
"Audit-Kette : " "Audit-Kette : "
+ ("unversehrt" if chain["ok"] else f"GEBROCHEN bei Eintrag {chain['broken_at']}") + ("unversehrt" if chain["ok"] else f"GEBROCHEN bei Eintrag {chain['broken_at']}")
+ f" ({chain['checked']} geprueft)" + f" ({chain['checked']} geprüft)"
) )
for key, value in counts.items(): for key, value in counts.items():
print(f"{key.capitalize():<15}: {value}") print(f"{key.capitalize():<15}: {value}")
@@ -168,6 +168,27 @@ def cmd_housekeeping(_args: argparse.Namespace) -> int:
return 0 return 0
def cmd_restore_signatures(_args: argparse.Namespace) -> int:
"""Traegt Unterschriften aus dem Journal wieder in die Datenbank ein.
Gebraucht nach einer Wiederherstellung: die Freigabe traegt die Dateien
von gestern, die zurueckgespielte Datenbank ist von vorgestern -- die
Unterschriften dazwischen stehen dann nur noch im Journal neben dem
Protokollarchiv.
"""
app, extension = _app_and_core()
from .services import fileshare
with app.app_context(), extension.database.session() as conn:
with extension.database.transaction(conn):
uebernommen = fileshare.signaturen_aus_journal(conn)
gesamt = int(
conn.execute("SELECT COUNT(*) FROM document_signatures").fetchone()[0]
)
print(f"{uebernommen} Unterschrift(en) übernommen, {gesamt} insgesamt.")
return 0
def cmd_check_now(_args: argparse.Namespace) -> int: def cmd_check_now(_args: argparse.Namespace) -> int:
app, extension = _app_and_core() app, extension = _app_and_core()
from .services import inventory, monitor from .services import inventory, monitor
@@ -183,7 +204,7 @@ def cmd_check_now(_args: argparse.Namespace) -> int:
license_allows_auto_restart=allowed, license_allows_auto_restart=allowed,
) )
print( print(
f"{report.checked} geprueft, {report.online} online, {report.offline} offline, " f"{report.checked} geprüft, {report.online} online, {report.offline} offline, "
f"{report.restarts_triggered} Neustarts ({report.restarts_ok} erfolgreich), " f"{report.restarts_triggered} Neustarts ({report.restarts_ok} erfolgreich), "
f"{report.duration_ms} ms" f"{report.duration_ms} ms"
) )
@@ -214,7 +235,7 @@ def cmd_convert_legacy(args: argparse.Namespace) -> int:
return 2 return 2
ziel = Path(args.ausgabe) if args.ausgabe else quelle.with_name(f"{quelle.stem}-neu.json") ziel = Path(args.ausgabe) if args.ausgabe else quelle.with_name(f"{quelle.stem}-neu.json")
if ziel.exists() and not args.force: if ziel.exists() and not args.force:
print(f"{ziel} existiert bereits. Mit --force ueberschreiben.") print(f"{ziel} existiert bereits. Mit --force überschreiben.")
return 2 return 2
alt_pass = args.passphrase or getpass("Passphrase der alten Datei: ") alt_pass = args.passphrase or getpass("Passphrase der alten Datei: ")
@@ -231,7 +252,7 @@ def cmd_convert_legacy(args: argparse.Namespace) -> int:
print("--dry-run: keine Datei geschrieben.") print("--dry-run: keine Datei geschrieben.")
return 0 return 0
neu_pass = args.neue_passphrase or getpass("Passphrase fuer die neue Datei: ") neu_pass = args.neue_passphrase or getpass("Passphrase für die neue Datei: ")
try: try:
ziel.write_text(transfer.seal(nutzlast, neu_pass), encoding="utf-8", newline="\n") ziel.write_text(transfer.seal(nutzlast, neu_pass), encoding="utf-8", newline="\n")
except Exception as exc: # noqa: BLE001 - Passphrase zu kurz, Pfad nicht schreibbar except Exception as exc: # noqa: BLE001 - Passphrase zu kurz, Pfad nicht schreibbar
@@ -265,10 +286,10 @@ def cmd_legacy_export(args: argparse.Namespace) -> int:
quelle = Path(args.source) quelle = Path(args.source)
ziel = Path(args.out) ziel = Path(args.out)
if ziel.exists() and not args.force: if ziel.exists() and not args.force:
print(f"{ziel} existiert bereits. Mit --force ueberschreiben.") print(f"{ziel} existiert bereits. Mit --force überschreiben.")
return 2 return 2
passphrase = args.passphrase or getpass("Passphrase fuer die Exportdatei: ") passphrase = args.passphrase or getpass("Passphrase für die Exportdatei: ")
try: try:
nutzlast, bericht = legacy.read_installation(quelle) nutzlast, bericht = legacy.read_installation(quelle)
inhalt = legacy.seal_legacy(nutzlast, passphrase) inhalt = legacy.seal_legacy(nutzlast, passphrase)
@@ -354,7 +375,7 @@ def cmd_legacy_import(args: argparse.Namespace) -> int:
action="backup.imported", action="backup.imported",
detail="; ".join( detail="; ".join(
f"{key}: {zahlen['created']} neu, {zahlen['updated']} aktualisiert, " f"{key}: {zahlen['created']} neu, {zahlen['updated']} aktualisiert, "
f"{zahlen['skipped']} uebersprungen" f"{zahlen['skipped']} übersprungen"
for key, zahlen in bericht.items() for key, zahlen in bericht.items()
) )
+ " (Kommandozeile)", + " (Kommandozeile)",
@@ -364,19 +385,20 @@ def cmd_legacy_import(args: argparse.Namespace) -> int:
for key, zahlen in sorted(bericht.items()): for key, zahlen in sorted(bericht.items()):
print( print(
f" {key}: {zahlen['created']} neu, {zahlen['updated']} aktualisiert, " f" {key}: {zahlen['created']} neu, {zahlen['updated']} aktualisiert, "
f"{zahlen['skipped']} uebersprungen" f"{zahlen['skipped']} übersprungen"
) )
return 0 return 0
def build_parser() -> argparse.ArgumentParser: def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser( parser = argparse.ArgumentParser(
prog="tesm-admin", description="Verwaltungswerkzeuge fuer TESM" prog="tesm-admin", description="Verwaltungswerkzeuge für TESM"
) )
sub = parser.add_subparsers(dest="command", required=True) sub = parser.add_subparsers(dest="command", required=True)
admin = sub.add_parser("create-admin", help="Administratorkonto anlegen oder zuruecksetzen") admin = sub.add_parser("create-admin", help="Administratorkonto anlegen oder zurücksetzen")
admin.add_argument("--username") admin.add_argument("--username")
admin.add_argument("--password", help="Nur fuer automatisierte Installationen") admin.add_argument("--password", help="Nur für automatisierte Installationen")
admin.add_argument("--force", action="store_true", help="Passwortregeln ignorieren") admin.add_argument("--force", action="store_true", help="Passwortregeln ignorieren")
admin.set_defaults(func=cmd_create_admin) admin.set_defaults(func=cmd_create_admin)
@@ -384,18 +406,24 @@ def build_parser() -> argparse.ArgumentParser:
status.add_argument("--json", action="store_true") status.add_argument("--json", action="store_true")
status.set_defaults(func=cmd_status) status.set_defaults(func=cmd_status)
house = sub.add_parser("housekeeping", help="Sitzungen, Ratenereignisse, Protokolle aufraeumen") house = sub.add_parser("housekeeping", help="Sitzungen, Ratenereignisse, Protokolle aufräumen")
house.set_defaults(func=cmd_housekeeping) house.set_defaults(func=cmd_housekeeping)
check = sub.add_parser("check-now", help="Einen Ueberwachungsdurchlauf sofort ausfuehren") restore = sub.add_parser(
"restore-signatures",
help="Unterschriften aus dem Journal neben dem Protokollarchiv übernehmen",
)
restore.set_defaults(func=cmd_restore_signatures)
check = sub.add_parser("check-now", help="Einen Überwachungsdurchlauf sofort ausführen")
check.set_defaults(func=cmd_check_now) check.set_defaults(func=cmd_check_now)
legacy = sub.add_parser( legacy = sub.add_parser(
"convert-legacy", "convert-legacy",
help="Export des alten TESM in das neue Format uebersetzen", help="Export des alten TESM in das neue Format übersetzen",
description=( description=(
"Liest eine Exportdatei des Vorgaengers und schreibt eine Datei im " "Liest eine Exportdatei des Vorgängers und schreibt eine Datei im "
"Format dieser Anwendung. Uebersetzt werden Clients, Switche, " "Format dieser Anwendung. Übersetzt werden Clients, Switche, "
"Zugangsdaten und die DHCP-Konfiguration." "Zugangsdaten und die DHCP-Konfiguration."
), ),
) )
@@ -410,7 +438,7 @@ def build_parser() -> argparse.ArgumentParser:
legacy.add_argument( legacy.add_argument(
"--dry-run", action="store_true", help="Nur den Bericht zeigen, nichts schreiben" "--dry-run", action="store_true", help="Nur den Bericht zeigen, nichts schreiben"
) )
legacy.add_argument("--force", action="store_true", help="Zieldatei ueberschreiben") legacy.add_argument("--force", action="store_true", help="Zieldatei überschreiben")
legacy.set_defaults(func=cmd_convert_legacy) legacy.set_defaults(func=cmd_convert_legacy)
@@ -418,10 +446,10 @@ def build_parser() -> argparse.ArgumentParser:
"legacy-export", "legacy-export",
help="Daten einer alten Installation als Exportdatei sichern", help="Daten einer alten Installation als Exportdatei sichern",
description=( description=(
"Liest die Datenbank einer Installation der Vorgaengerfassung und schreibt " "Liest die Datenbank einer Installation der Vorgängerfassung und schreibt "
"Clients, Switche, Zugangsdaten und DHCP in eine Datei im alten Format. " "Clients, Switche, Zugangsdaten und DHCP in eine Datei im alten Format. "
"Die alte Installation wird nur gelesen. Ohne Browser und auch dann noch " "Die alte Installation wird nur gelesen. Ohne Browser und auch dann noch "
"moeglich, wenn die alte Anwendung nicht mehr startet." "möglich, wenn die alte Anwendung nicht mehr startet."
), ),
) )
export_alt.add_argument( export_alt.add_argument(
@@ -430,16 +458,16 @@ def build_parser() -> argparse.ArgumentParser:
export_alt.add_argument("--out", required=True, help="Zieldatei") export_alt.add_argument("--out", required=True, help="Zieldatei")
export_alt.add_argument("--passphrase", help="Passphrase der Zieldatei (sonst wird gefragt)") export_alt.add_argument("--passphrase", help="Passphrase der Zieldatei (sonst wird gefragt)")
export_alt.add_argument("--dry-run", action="store_true", help="Nur den Bericht zeigen") export_alt.add_argument("--dry-run", action="store_true", help="Nur den Bericht zeigen")
export_alt.add_argument("--force", action="store_true", help="Zieldatei ueberschreiben") export_alt.add_argument("--force", action="store_true", help="Zieldatei überschreiben")
export_alt.set_defaults(func=cmd_legacy_export) export_alt.set_defaults(func=cmd_legacy_export)
import_neu = sub.add_parser( import_neu = sub.add_parser(
"legacy-import", "legacy-import",
help="Datei im Format dieser Anwendung einlesen", help="Datei im Format dieser Anwendung einlesen",
description=( description=(
"Liest eine Sicherungs- oder uebersetzte Datei ein -- derselbe Weg wie unter " "Liest eine Sicherungs- oder übersetzte Datei ein – derselbe Weg wie unter "
"Verwaltung -> Sicherung, nur ohne Browser. Ohne --categories werden alle " "Verwaltung -> Sicherung, nur ohne Browser. Ohne --categories werden alle "
"Kategorien uebernommen, die in der Datei stehen." "Kategorien übernommen, die in der Datei stehen."
), ),
) )
import_neu.add_argument("datei", help="Datei im Format dieser Anwendung") import_neu.add_argument("datei", help="Datei im Format dieser Anwendung")
+12 -12
View File
@@ -45,10 +45,10 @@ def _has_fileshare_mounts(user: Any) -> bool:
NAVIGATION = NavTree( NAVIGATION = NavTree(
[ [
NavItem(key="index", label="Statusuebersicht", icon="grid", endpoint="dashboard.index"), NavItem(key="index", label="Statusübersicht", icon="grid", endpoint="dashboard.index"),
NavItem( NavItem(
key="monitoring", key="monitoring",
label="Ueberwachung", label="Überwachung",
icon="cpu", icon="cpu",
children=( children=(
NavItem( NavItem(
@@ -74,21 +74,21 @@ NAVIGATION = NavTree(
), ),
NavItem( NavItem(
key="hostkeys", key="hostkeys",
label="Host-Schluessel", label="Host-Schlüssel",
icon="shield", icon="shield",
endpoint="hostkeys.overview", endpoint="hostkeys.overview",
permissions=("devices.view", "switches.view"), permissions=("devices.view", "switches.view"),
), ),
NavItem(
key="maintenance",
label="Wartung",
icon="wrench",
endpoint="maintenance.overview",
permissions=("maintenance.view", "maintenance.execute"),
condition=_module("maintenance"),
),
), ),
), ),
NavItem(
key="maintenance",
label="Wartung",
icon="wrench",
endpoint="maintenance.overview",
permissions=("maintenance.view", "maintenance.execute"),
condition=_module("maintenance"),
),
NavItem( NavItem(
key="dhcp", key="dhcp",
label="DHCP-Server", label="DHCP-Server",
@@ -147,7 +147,7 @@ NAVIGATION = NavTree(
), ),
NavItem( NavItem(
key="logs_dhcp", key="logs_dhcp",
label="Kea-DHCP", label="DHCP",
icon="network", icon="network",
endpoint="tesmlogs.dhcp", endpoint="tesmlogs.dhcp",
permissions=("logs_dhcp.view",), permissions=("logs_dhcp.view",),
+64 -24
View File
@@ -31,11 +31,11 @@ DEVICES = Resource(
key="devices", key="devices",
label="Clients", label="Clients",
actions=(A.VIEW, A.CREATE, A.EDIT, A.DELETE, A.EXECUTE), actions=(A.VIEW, A.CREATE, A.EDIT, A.DELETE, A.EXECUTE),
hint="Ueberwachte PoE-Endgeraete", hint="Überwachte PoE-Endgeräte",
action_hints={ action_hints={
A.EXECUTE: "PoE-Neustart eines Geraets manuell ausloesen. Der automatische " A.EXECUTE: "PoE-Neustart eines Geräts manuell auslösen. Der automatische "
"Neustart bei Ausfall ist davon unabhaengig und laeuft immer.", "Neustart bei Ausfall ist davon unabhängig und läuft immer.",
A.DELETE: "Geraet in den Papierkorb legen.", A.DELETE: "Gerät in den Papierkorb legen.",
}, },
) )
@@ -45,9 +45,9 @@ SWITCHES = Resource(
actions=(A.VIEW, A.CREATE, A.EDIT, A.DELETE, A.EXECUTE), actions=(A.VIEW, A.CREATE, A.EDIT, A.DELETE, A.EXECUTE),
hint="Aruba-Switche und ihre Erreichbarkeit", hint="Aruba-Switche und ihre Erreichbarkeit",
action_hints={ action_hints={
A.EXECUTE: "Browser-Terminal oeffnen und Kommandos auf dem Switch ausfuehren. " A.EXECUTE: "Browser-Terminal öffnen und Kommandos auf dem Switch ausführen. "
"Verlangt zusaetzlich eine frische Passwortbestaetigung; jede Sitzung wird " "Verlangt zusätzlich eine frische Passwortbestätigung; jede Sitzung wird "
"vollstaendig protokolliert.", "vollständig protokolliert.",
}, },
) )
@@ -57,27 +57,28 @@ CREDENTIALS = Resource(
actions=(A.VIEW, A.CREATE, A.EDIT, A.DELETE, A.SECRETS), actions=(A.VIEW, A.CREATE, A.EDIT, A.DELETE, A.SECRETS),
hint="Wiederverwendbare SSH-Logins", hint="Wiederverwendbare SSH-Logins",
action_hints={ action_hints={
A.VIEW: "Namen und Benutzer sehen -- nicht das Passwort.", A.VIEW: "Namen und Benutzer sehen – nicht das Passwort.",
A.SECRETS: "Passwort im Klartext anzeigen. Verlangt eine frische " A.SECRETS: "Passwort im Klartext anzeigen. Verlangt eine frische "
"Passwortbestaetigung und wird protokolliert.", "Passwortbestätigung und wird protokolliert.",
}, },
) )
MAINTENANCE = Resource( MAINTENANCE = Resource(
key="maintenance", key="maintenance",
label="Wartung", # Nicht "Wartung": so heisst der Bereich. Die Zeile sagt, was sie erlaubt.
label="Aktualisierung und Neustart",
actions=(A.VIEW, A.EXECUTE), actions=(A.VIEW, A.EXECUTE),
hint="Paketaktualisierung und Neustart per SSH (lizenzpflichtiges Modul)", hint="Paketaktualisierung und Neustart per SSH (lizenzpflichtiges Modul)",
action_hints={A.EXECUTE: "Aktualisierung oder Neustart auf ausgewaehlten Geraeten starten."}, action_hints={A.EXECUTE: "Aktualisierung oder Neustart auf ausgewählten Geräten starten."},
) )
DHCP = Resource( DHCP = Resource(
key="dhcp", key="dhcp",
label="DHCP-Server", label="DHCP-Server",
actions=(A.VIEW, A.EDIT, A.EXECUTE), actions=(A.VIEW, A.EDIT, A.EXECUTE),
hint="Kea-DHCP: Subnetze, Optionen, Reservierungen (lizenzpflichtiges Modul)", hint="DHCP: Subnetze, Optionen, Reservierungen (lizenzpflichtiges Modul)",
action_hints={ action_hints={
A.EDIT: "Konfiguration aendern und schreiben.", A.EDIT: "Konfiguration ändern und schreiben.",
A.EXECUTE: "Dienst installieren, starten oder stoppen.", A.EXECUTE: "Dienst installieren, starten oder stoppen.",
}, },
) )
@@ -85,27 +86,65 @@ DHCP = Resource(
FILESHARE = Resource( FILESHARE = Resource(
key="fileshare", key="fileshare",
label="Dateifreigaben", label="Dateifreigaben",
actions=(A.VIEW, A.CREATE, A.EDIT, A.DELETE), actions=(A.VIEW, A.CREATE, A.EDIT, A.DELETE, A.EXPORT),
hint="SMB-Freigaben aus dem Verzeichnisdienst (lizenzpflichtiges Modul)", hint="SMB-Freigaben aus dem Verzeichnisdienst (lizenzpflichtiges Modul)",
action_hints={ action_hints={
A.CREATE: "Ordner anlegen und Dateien hochladen.", A.CREATE: "Ordner anlegen und Dateien hochladen.",
A.EDIT: "Umbenennen und verschieben.", A.EDIT: "Umbenennen und verschieben.",
# Genau die Bedeutung aus der Legende: Daten aus dem System
# heraustragen. Ansehen und Mitnehmen sind zweierlei -- eine Vorschau
# bleibt im Haus, eine heruntergeladene Datei nicht.
A.EXPORT: "Dateien herunterladen.",
},
# Exporte verlangen sonst eine frische Passwortbestaetigung. Hier nicht:
# Herunterladen ist in einer Dateifreigabe der Alltag, nicht der
# Sonderfall. Alle 15 Minuten ein Passwort waere Schikane -- und wuerde
# nur dazu fuehren, dass jemand das Recht pauschal vergibt oder ganz
# weglaesst. Die Grenze zieht hier das Recht selbst, nicht die Abfrage.
ohne_wiederanmeldung=(A.EXPORT,),
)
#: Eigene Zeile statt Anhaengsel von "Dateifreigaben": ein Formular auszufuellen
#: ist ein anderer Vorgang als eine Datei hochzuladen, und wer umbenennen darf,
#: soll deshalb nicht automatisch veraenderbare Formulare ablegen duerfen.
FILESHARE_PDF = Resource(
key="fileshare_pdf",
label="PDF-Formulare",
actions=(A.VIEW, A.CREATE, A.EDIT),
hint="Formulare in der Vorschau ausfüllen und in der Freigabe ablegen",
action_hints={
A.VIEW: "In der Vorschau ausfüllen (ohne Speichern).",
A.CREATE: "Ausgefüllt speichern – abgeschlossen, Felder gesperrt.",
A.EDIT: "Ausgefüllt speichern – weiter ausfüllbar, inklusive Bild.",
}, },
) )
LOGS_DHCP = Resource( LOGS_DHCP = Resource(
key="logs_dhcp", key="logs_dhcp",
label="Kea-DHCP-Protokoll", label="DHCP-Protokoll",
actions=(A.VIEW,), actions=(A.VIEW,),
) )
MONITORING_AREA = Area( MONITORING_AREA = Area(
key="monitoring", key="monitoring",
label="Ueberwachung", label="Überwachung",
icon="cpu", icon="cpu",
hint="Geraete, Switche, Zugangsdaten und Wartung. Ohne diesen Zugang bleibt der " hint="Geräte, Switche und Zugangsdaten. Ohne diesen Zugang bleibt der "
"gesamte Bereich verborgen.", "gesamte Bereich verborgen.",
resources=(DEVICES, SWITCHES, CREDENTIALS, MAINTENANCE), resources=(DEVICES, SWITCHES, CREDENTIALS),
)
#: Wartung ueberwacht nichts, sie greift ein -- und sie steht seit 2.5.0 als
#: eigener Punkt im Menue. Der Bereichsschluessel heisst ``servicing``, weil
#: ``maintenance`` schon die Ressource ist und Bereiche und Ressourcen sich
#: einen Namensraum teilen. Nach aussen heisst beides "Wartung".
MAINTENANCE_AREA = Area(
key="servicing",
label="Wartung",
icon="wrench",
hint="Paketpflege und Neustarts. Ohne diesen Zugang bleibt der Bereich "
"verborgen – auch wenn einzelne Rechte darunter gesetzt sind.",
resources=(MAINTENANCE,),
) )
NETWORK_AREA = Area( NETWORK_AREA = Area(
@@ -119,12 +158,13 @@ FILES_AREA = Area(
key="files", key="files",
label="Dateifreigaben", label="Dateifreigaben",
icon="folder", icon="folder",
resources=(FILESHARE,), resources=(FILESHARE, FILESHARE_PDF),
) )
PERMISSIONS = PermissionTree( PERMISSIONS = PermissionTree(
[ [
MONITORING_AREA, MONITORING_AREA,
MAINTENANCE_AREA,
NETWORK_AREA, NETWORK_AREA,
FILES_AREA, FILES_AREA,
observability_area(LOGS_DHCP), observability_area(LOGS_DHCP),
@@ -137,7 +177,7 @@ ROLE_PRESETS: tuple[RolePreset, ...] = (
RolePreset( RolePreset(
key="viewer", key="viewer",
name="Benutzer", name="Benutzer",
description="Standardgruppe: Geraete und Switche ansehen, laufendes Protokoll lesen.", description="Standardgruppe: Geräte und Switche ansehen, laufendes Protokoll lesen.",
grants=( grants=(
("devices", (A.VIEW,)), ("devices", (A.VIEW,)),
("switches", (A.VIEW,)), ("switches", (A.VIEW,)),
@@ -148,7 +188,7 @@ ROLE_PRESETS: tuple[RolePreset, ...] = (
RolePreset( RolePreset(
key="operations", key="operations",
name="Betrieb", name="Betrieb",
description="Geraete und Switche pflegen, Neustarts ausloesen, Wartung fahren.", description="Geräte und Switche pflegen, Neustarts auslösen, Wartung fahren.",
grants=( grants=(
("devices", "*"), ("devices", "*"),
("switches", (A.VIEW, A.CREATE, A.EDIT, A.EXECUTE)), ("switches", (A.VIEW, A.CREATE, A.EDIT, A.EXECUTE)),
@@ -162,7 +202,7 @@ ROLE_PRESETS: tuple[RolePreset, ...] = (
RolePreset( RolePreset(
key="support", key="support",
name="Support", name="Support",
description="Erste Hilfe am Telefon: Status sehen, PoE-Neustart ausloesen.", description="Erste Hilfe am Telefon: Status sehen, PoE-Neustart auslösen.",
grants=( grants=(
("devices", (A.VIEW, A.EXECUTE)), ("devices", (A.VIEW, A.EXECUTE)),
("switches", (A.VIEW,)), ("switches", (A.VIEW,)),
@@ -184,7 +224,7 @@ ROLE_PRESETS: tuple[RolePreset, ...] = (
RolePreset( RolePreset(
key="auditor", key="auditor",
name="Revision", name="Revision",
description="Nur lesen -- inklusive Aenderungsprotokoll und Diagnose.", description="Nur lesen – inklusive Änderungsprotokoll und Diagnose.",
grants=( grants=(
("devices", (A.VIEW,)), ("devices", (A.VIEW,)),
("switches", (A.VIEW,)), ("switches", (A.VIEW,)),
@@ -202,7 +242,7 @@ ROLE_PRESETS: tuple[RolePreset, ...] = (
RolePreset( RolePreset(
key="administration", key="administration",
name="Administration", name="Administration",
description="Benutzer, Gruppen und Einstellungen -- ohne Admin-Kennzeichen.", description="Benutzer, Gruppen und Einstellungen – ohne Admin-Kennzeichen.",
grants=( grants=(
("users", "*"), ("users", "*"),
("groups", "*"), ("groups", "*"),
+125
View File
@@ -250,8 +250,133 @@ CREATE INDEX IF NOT EXISTS idx_restart_events_method ON restart_events(method, t
""" """
# Neue Rechte sind nach einem Update bei niemandem gesetzt -- ohne diesen
# Schritt koennte niemand mehr herunterladen oder ein Formular speichern,
# obwohl sich an den Gruppen nichts geaendert hat. Die Zuordnung bildet ab, was
# vorher galt.
V4 = """
INSERT OR IGNORE INTO group_permissions (group_id, permission)
SELECT group_id, 'fileshare.export' FROM group_permissions
WHERE permission = 'fileshare.view';
INSERT OR IGNORE INTO group_permissions (group_id, permission)
SELECT group_id, 'fileshare_pdf.view' FROM group_permissions
WHERE permission = 'fileshare.view';
INSERT OR IGNORE INTO group_permissions (group_id, permission)
SELECT group_id, 'fileshare_pdf.create' FROM group_permissions
WHERE permission = 'fileshare.create';
INSERT OR IGNORE INTO group_permissions (group_id, permission)
SELECT group_id, 'fileshare_pdf.edit' FROM group_permissions
WHERE permission = 'fileshare.edit';
"""
# Aus einem Schalter werden zwei. Wer die Konsole abgeschaltet hatte, soll sie
# nicht durch ein Update zurueckbekommen -- der alte Wert gilt deshalb fuer
# beide weiter. Fehlt er, greift die Vorgabe der Einstellung.
V5 = """
INSERT OR IGNORE INTO settings (key, value, updated_at, updated_by)
SELECT 'terminal_switches_enabled', value, datetime('now'), 'migration'
FROM settings WHERE key = 'terminal_enabled';
INSERT OR IGNORE INTO settings (key, value, updated_at, updated_by)
SELECT 'terminal_clients_enabled', value, datetime('now'), 'migration'
FROM settings WHERE key = 'terminal_enabled';
DELETE FROM settings WHERE key = 'terminal_enabled';
"""
# Wartung ist ein eigener Bereich geworden und hat damit einen eigenen
# Kill-Switch. Ohne diesen Schritt waere sie nach dem Update bei allen weg:
# das neue Bereichsrecht ist bei niemandem gesetzt, und ohne Bereichsrecht
# greift kein Recht darunter.
#
# Bewusst nicht "alle, die ein Wartungsrecht haben": wer den Bereich
# Ueberwachung nicht hatte, kam auch vorher nicht an die Wartung. Ein Update
# darf Zugang erhalten, aber keinen neuen schaffen.
V6 = """
INSERT OR IGNORE INTO group_permissions (group_id, permission)
SELECT DISTINCT group_id, 'servicing.view'
FROM group_permissions
WHERE permission IN ('maintenance.view', 'maintenance.execute')
AND group_id IN (
SELECT group_id FROM group_permissions WHERE permission = 'monitoring.view'
);
"""
# Die Pruefung "unveraendert seit der Unterschrift" hing am Aenderungsprotokoll
# -- und das wird ab 20.000 Zeilen tageweise in Dateien ausgelagert. Mit dem
# ausgelagerten Tag waere die Pruefsumme aus der Datenbank verschwunden und
# jede aeltere unterschriebene Fassung waere still zu "unbekannt" geworden.
# Schlimmer noch: auch eine **veraenderte** Fassung, denn ohne Vergleichswert
# gibt es keinen Widerspruch.
#
# Deshalb eine eigene, winzige Tabelle: eine Zeile je Unterschrift, nie
# archiviert. Sie ist ein Verzeichnis, keine Wahrheit -- die Wahrheit bleibt
# das Aenderungsprotokoll mit seiner Hash-Kette, auch im Archiv.
V7 = """
CREATE TABLE IF NOT EXISTS document_signatures (
id INTEGER PRIMARY KEY AUTOINCREMENT,
sha256 TEXT NOT NULL,
share_label TEXT NOT NULL DEFAULT '',
path TEXT NOT NULL DEFAULT '',
signed_at TEXT NOT NULL,
actor TEXT NOT NULL DEFAULT '',
names TEXT NOT NULL DEFAULT '',
audit_id INTEGER
);
CREATE INDEX IF NOT EXISTS idx_document_signatures_sha
ON document_signatures(sha256);
CREATE INDEX IF NOT EXISTS idx_document_signatures_ort
ON document_signatures(share_label, path);
"""
def uebernehme_signaturen(conn) -> None: # type: ignore[no-untyped-def]
"""Traegt die bereits protokollierten Unterschriften ins Verzeichnis ein.
Ohne diesen Schritt waeren alle vor dem Update unterschriebenen Fassungen
ab sofort "unbekannt" -- richtig waere das nicht, die Eintraege stehen ja
noch da.
"""
from .services import fileshare as service
zeilen = conn.execute(
"SELECT id, ts, actor, detail FROM audit_log WHERE action='fileshare.form_signed' "
"ORDER BY id"
).fetchall()
for zeile in zeilen:
detail = str(zeile["detail"] or "")
gelesen = service.signaturdetail_lesen(detail)
if not gelesen["sha256"]:
continue
conn.execute(
"INSERT INTO document_signatures (sha256, share_label, path, signed_at, actor, "
"names, audit_id) VALUES (?,?,?,?,?,?,?)",
(
gelesen["sha256"],
gelesen["label"],
gelesen["pfad"],
str(zeile["ts"]),
str(zeile["actor"] or ""),
", ".join(gelesen["namen"]),
int(zeile["id"]),
),
)
TESM_MIGRATIONS: tuple[Migration, ...] = ( TESM_MIGRATIONS: tuple[Migration, ...] = (
Migration(version=1, name="baseline", sql=V1), Migration(version=1, name="baseline", sql=V1),
Migration(version=2, name="seed-dhcp-options", fn=seed_dhcp_options), Migration(version=2, name="seed-dhcp-options", fn=seed_dhcp_options),
Migration(version=3, name="restart-events", sql=V3), Migration(version=3, name="restart-events", sql=V3),
Migration(version=4, name="fileshare-pdf-rechte", sql=V4),
Migration(version=5, name="terminal-getrennt", sql=V5),
Migration(version=6, name="wartung-eigener-bereich", sql=V6),
Migration(version=7, name="signaturverzeichnis", sql=V7, fn=uebernehme_signaturen),
) )
+509 -28
View File
@@ -21,6 +21,9 @@ Sicherheitsrelevante Aenderungen gegenueber dem Vorgaenger:
from __future__ import annotations from __future__ import annotations
import hashlib
import zlib
import json
import logging import logging
import mimetypes import mimetypes
import os import os
@@ -32,7 +35,7 @@ import sqlite3
from dataclasses import dataclass from dataclasses import dataclass
from datetime import datetime, timedelta, timezone from datetime import datetime, timedelta, timezone
from pathlib import Path, PurePosixPath from pathlib import Path, PurePosixPath
from typing import Any, Iterable from typing import Any, Iterable, Mapping
from tesm_core.db import Database from tesm_core.db import Database
from tesm_core.extension import core from tesm_core.extension import core
@@ -40,7 +43,7 @@ from tesm_core.extension import core
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
_UNC_HINT = ( _UNC_HINT = (
"Der Freigabepfad muss die Form \\\\server\\freigabe haben. Schraegstriche " "Der Freigabepfad muss die Form \\\\server\\freigabe haben. Schrägstriche "
"gehen auch: //server/freigabe." "gehen auch: //server/freigabe."
) )
@@ -57,6 +60,10 @@ PREVIEW_MIME: dict[str, str] = {
".gif": "image/gif", ".gif": "image/gif",
".webp": "image/webp", ".webp": "image/webp",
".bmp": "image/bmp", ".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", ".txt": "text/plain; charset=utf-8",
".csv": "text/plain; charset=utf-8", ".csv": "text/plain; charset=utf-8",
".log": "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. #: 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): class FileshareError(Exception):
"""Fehler mit Klartextmeldung fuer die Oberflaeche.""" """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('.')}" kandidat = f"{host}.{suffix.strip('.')}"
if _resolvable(kandidat): if _resolvable(kandidat):
neu = chr(92) * 2 + kandidat + (chr(92) + rest if rest else "") neu = chr(92) * 2 + kandidat + (chr(92) + rest if rest else "")
return neu, f"Name {host} ueber {kandidat} aufgeloest" return neu, f"Name {host} über {kandidat} aufgelöst"
return unc, ( return unc, (
f"Der Name {host} ist auf diesem Server nicht aufloesbar. Bitte den " f"Der Name {host} ist auf diesem Server nicht auflösbar. Bitte den "
"vollqualifizierten Namen oder die IP-Adresse eintragen -- oder unter " "vollqualifizierten Namen oder die IP-Adresse eintragen – oder unter "
"Netzwerk eine Suchdomaene setzen." "Netzwerk eine Suchdomäne setzen."
) )
@@ -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( def add_mapping(
conn: sqlite3.Connection, *, ad_group_dn: str, ad_group_name: str, label: str, unc: str conn: sqlite3.Connection, *, ad_group_dn: str, ad_group_name: str, label: str, unc: str
) -> int: ) -> int:
@@ -196,7 +262,7 @@ def add_mapping(
if not clean_label: if not clean_label:
raise FileshareError("Der Anzeigename darf nicht leer sein.") raise FileshareError("Der Anzeigename darf nicht leer sein.")
if not (ad_group_dn or "").strip(): if not (ad_group_dn or "").strip():
raise FileshareError("Es ist keine Verzeichnisgruppe ausgewaehlt.") raise FileshareError("Es ist keine Verzeichnisgruppe ausgewählt.")
cursor = conn.execute( cursor = conn.execute(
"INSERT INTO fileshare_mappings (ad_group_dn, ad_group_name, label, unc, created_at) " "INSERT INTO fileshare_mappings (ad_group_dn, ad_group_name, label, unc, created_at) "
"VALUES (?,?,?,?,?) ON CONFLICT(ad_group_dn, unc) DO NOTHING", "VALUES (?,?,?,?,?) ON CONFLICT(ad_group_dn, unc) DO NOTHING",
@@ -228,7 +294,7 @@ def update_mapping(
if not clean_label: if not clean_label:
raise FileshareError("Der Anzeigename darf nicht leer sein.") raise FileshareError("Der Anzeigename darf nicht leer sein.")
if not clean_dn: if not clean_dn:
raise FileshareError("Es ist keine Verzeichnisgruppe ausgewaehlt.") raise FileshareError("Es ist keine Verzeichnisgruppe ausgewählt.")
vorhanden = Database.one( vorhanden = Database.one(
conn, "SELECT id FROM fileshare_mappings WHERE id=?", (mapping_id,) conn, "SELECT id FROM fileshare_mappings WHERE id=?", (mapping_id,)
@@ -299,12 +365,32 @@ def mount_root() -> Path:
return Path(str(core().settings.get(db(), "fileshare_mount_root") or "/mnt/tesm-shares")) return Path(str(core().settings.get(db(), "fileshare_mount_root") or "/mnt/tesm-shares"))
def active_mounts(conn: sqlite3.Connection, user_id: int) -> list[Mount]: def aktuelle_sitzung() -> str:
"""Die ID der laufenden Sitzung -- oder leer ausserhalb einer Anfrage."""
from flask import session
return str(getattr(session, "sid", "") or "")
def active_mounts(
conn: sqlite3.Connection, user_id: int, session_id: str | None = None
) -> list[Mount]:
"""Die offenen Einbindungen dieses Kontos.
Mit ``session_id`` nur die dieser Sitzung. Das braucht das Abmelden: wer
an zwei Geraeten angemeldet ist, hat je Geraet eine eigene Einbindung mit
eigenem Einhaengepunkt, und das Abmelden am einen darf das andere nicht
aushaengen.
"""
bedingung = "AND session_id = ? " if session_id else ""
werte: list[Any] = [user_id, _iso(_now())]
if session_id:
werte.insert(1, session_id)
rows = Database.all( rows = Database.all(
conn, conn,
"SELECT * FROM fileshare_mounts WHERE user_id=? AND released_at IS NULL " "SELECT * FROM fileshare_mounts WHERE user_id=? " + bedingung
"AND expires_at > ? ORDER BY label COLLATE NOCASE", + "AND released_at IS NULL AND expires_at > ? ORDER BY label COLLATE NOCASE, id DESC",
(user_id, _iso(_now())), tuple(werte),
) )
return [ return [
Mount( Mount(
@@ -325,6 +411,38 @@ def has_mounts(user_id: int) -> bool:
return bool(active_mounts(db(), user_id)) return bool(active_mounts(db(), user_id))
def doppelte_zusammenfassen(
conn: sqlite3.Connection, mounts: list[Mount], session_id: str = ""
) -> list[Mount]:
"""Je Freigabe eine Karteikarte -- vorzugsweise die dieser Sitzung.
Wer an zwei Geraeten angemeldet ist, hat zwei Einbindungen derselben
Freigabe: eine je Sitzung, jede mit eigenem Einhaengepunkt. In der Leiste
stand sie deshalb doppelt, einmal "Share" und einmal wieder "Share" -- ohne
jeden Unterschied fuer den Betrachter.
Genommen wird die Einbindung der eigenen Sitzung. Nur wenn es die nicht
gibt (alte Zeile, Hintergrundprozess), bleibt eine fremde stehen.
"""
eigene: set[int] = set()
if session_id:
eigene = {
int(zeile["id"])
for zeile in Database.all(
conn,
"SELECT id FROM fileshare_mounts WHERE session_id=? AND released_at IS NULL",
(session_id,),
)
}
gewaehlt: dict[str, Mount] = {}
for mount in mounts:
schluessel = mount.unc.lower()
vorhanden = gewaehlt.get(schluessel)
if vorhanden is None or (mount.id in eigene and vorhanden.id not in eigene):
gewaehlt[schluessel] = mount
return sorted(gewaehlt.values(), key=lambda m: m.label.lower())
def mount_for_login( def mount_for_login(
conn: sqlite3.Connection, conn: sqlite3.Connection,
*, *,
@@ -344,7 +462,7 @@ def mount_for_login(
extension = core() extension = core()
sysops = extension.sysops sysops = extension.sysops
if sysops is None or not sysops.available: if sysops is None or not sysops.available:
return ["Systemhelfer nicht verfuegbar -- keine Einbindungen."] return ["Systemhelfer nicht verfügbar – keine Einbindungen."]
dns = [dn for dn in group_dns if dn and dn.strip()] dns = [dn for dn in group_dns if dn and dn.strip()]
shares = shares_for_groups(conn, dns) shares = shares_for_groups(conn, dns)
@@ -367,9 +485,41 @@ def mount_for_login(
# eingebunden, aber leer. Das betrifft die Anmeldung mit zweitem Faktor, bei # eingebunden, aber leer. Das betrifft die Anmeldung mit zweitem Faktor, bei
# der das Passwort im zweiten Schritt nicht mehr vorliegt. # der das Passwort im zweiten Schritt nicht mehr vorliegt.
if not password: if not password:
return ["Kein Passwort fuer den Fileserver verfuegbar -- keine Einbindungen."] return ["Kein Passwort für den Fileserver verfügbar – keine Einbindungen."]
# Was zuerst kommt: das Ende der Sitzung oder die eigene Obergrenze. Die
# Sitzung ist die eigentliche Klammer -- mit ihr endet die Einbindung; die
# Obergrenze sorgt dafuer, dass eine sehr lange Sitzung nicht beliebig
# lange auf fremden Zugangsdaten sitzt.
hours = int(extension.settings.get(conn, "fileshare_max_age_hours") or 12) hours = int(extension.settings.get(conn, "fileshare_max_age_hours") or 12)
frist = _now() + timedelta(hours=hours)
# Die Sitzungszeile gibt es hier noch nicht: eingebunden wird waehrend der
# Anmeldung, geschrieben wird sie erst beim Speichern der Antwort. Die
# Dauer steht aber in derselben Einstellung, aus der die Sitzung sie
# gleich bekommt -- und falls die Zeile doch schon steht (erneutes
# Einbinden), gilt deren Ende.
zeile = (
Database.one(
conn, "SELECT absolute_expires_at FROM user_sessions WHERE id=?", (session_id,)
)
if session_id
else None
)
if zeile is not None and zeile["absolute_expires_at"]:
try:
sitzungsende = datetime.fromisoformat(str(zeile["absolute_expires_at"]))
if sitzungsende.tzinfo is None:
sitzungsende = sitzungsende.replace(tzinfo=timezone.utc)
frist = min(frist, sitzungsende)
except ValueError:
pass
else:
try:
dauer = int(extension.settings.get(conn, "session_absolute_hours") or 0)
except (TypeError, ValueError):
dauer = 0
if dauer > 0:
frist = min(frist, _now() + timedelta(hours=dauer))
root = Path(str(extension.settings.get(conn, "fileshare_mount_root") or "/mnt/tesm-shares")) root = Path(str(extension.settings.get(conn, "fileshare_mount_root") or "/mnt/tesm-shares"))
messages: list[str] = [] messages: list[str] = []
@@ -399,7 +549,7 @@ def mount_for_login(
str(share["unc"]), str(share["unc"]),
str(target), str(target),
_iso(_now()), _iso(_now()),
_iso(_now() + timedelta(hours=hours)), _iso(frist),
), ),
) )
messages.append(f"{share['label']}: eingebunden") messages.append(f"{share['label']}: eingebunden")
@@ -436,14 +586,30 @@ def forget_all_mounts(conn: sqlite3.Connection) -> int:
return int(cursor.rowcount or 0) return int(cursor.rowcount or 0)
def release_all_for_user(conn: sqlite3.Connection, user_id: int) -> int: def release_all_for_user(conn: sqlite3.Connection, user_id: int, session_id: str = "") -> int:
"""Loest die Einbindungen -- ohne Sitzung die des ganzen Kontos.
Beim Abmelden wird die Sitzung uebergeben: das andere Geraet desselben
Benutzers arbeitet weiter, seine Einbindung bleibt. Ohne Sitzung (etwa
beim Loeschen eines Kontos) faellt alles.
"""
count = 0 count = 0
for mount in active_mounts(conn, user_id): for mount in active_mounts(conn, user_id, session_id):
if session_id and not _gehoert_zur_sitzung(conn, mount.id, session_id):
continue
release_mount(conn, mount.id) release_mount(conn, mount.id)
count += 1 count += 1
return count return count
def _gehoert_zur_sitzung(conn: sqlite3.Connection, mount_id: int, session_id: str) -> bool:
"""Zeilen ohne Sitzung werden beim Abmelden nicht mitgerissen."""
zeile = Database.one(
conn, "SELECT session_id FROM fileshare_mounts WHERE id=?", (mount_id,)
)
return zeile is not None and str(zeile["session_id"] or "") == session_id
def sweep_expired(database: Any) -> int: def sweep_expired(database: Any) -> int:
"""Loest abgelaufene Einbindungen -- laeuft im Hintergrundprozess. """Loest abgelaufene Einbindungen -- laeuft im Hintergrundprozess.
@@ -454,7 +620,10 @@ def sweep_expired(database: Any) -> int:
released = 0 released = 0
with database.session() as conn: with database.session() as conn:
# Eine Einbindung lebt genau so lange wie die Sitzung, aus der sie # Eine Einbindung lebt genau so lange wie die Sitzung, aus der sie
# entstanden ist. Vorher hing sie an einer eigenen Frist von zwoelf # entstanden ist. Ohne Sitzungsangabe gilt allein die eigene Frist:
# "keine Sitzung eingetragen" ist etwas anderes als "Sitzung
# abgelaufen". Diese Verwechslung hat jede frische Einbindung binnen
# Minuten wieder geloest, weil die ID beim Anmelden noch nicht stand. Vorher hing sie an einer eigenen Frist von zwoelf
# Stunden: nach dem Abmelden -- oder nach der automatischen Abmeldung # Stunden: nach dem Abmelden -- oder nach der automatischen Abmeldung
# bei Leerlauf -- blieb die Freigabe mit den Zugangsdaten des Benutzers # bei Leerlauf -- blieb die Freigabe mit den Zugangsdaten des Benutzers
# weiter eingebunden. Die eigene Frist bleibt als Obergrenze bestehen; # weiter eingebunden. Die eigene Frist bleibt als Obergrenze bestehen;
@@ -467,10 +636,11 @@ def sweep_expired(database: Any) -> int:
LEFT JOIN user_sessions s ON s.id = m.session_id LEFT JOIN user_sessions s ON s.id = m.session_id
WHERE m.released_at IS NULL WHERE m.released_at IS NULL
AND (m.expires_at <= ? AND (m.expires_at <= ?
OR s.id IS NULL OR (COALESCE(m.session_id, '') <> ''
OR s.revoked_at IS NOT NULL AND (s.id IS NULL
OR s.idle_expires_at <= ? OR s.revoked_at IS NOT NULL
OR s.absolute_expires_at <= ?) OR s.idle_expires_at <= ?
OR s.absolute_expires_at <= ?)))
""", """,
(_iso(_now()), _iso(_now()), _iso(_now())), (_iso(_now()), _iso(_now()), _iso(_now())),
) )
@@ -496,14 +666,232 @@ def resolve_path(mount: Mount, relative: str) -> Path:
""" """
candidate = PurePosixPath((relative or "").strip().lstrip("/")) candidate = PurePosixPath((relative or "").strip().lstrip("/"))
if any(part == ".." for part in candidate.parts): if any(part == ".." for part in candidate.parts):
raise FileshareError("Ungueltiger Pfad.") raise FileshareError("Ungültiger Pfad.")
root = mount.path.resolve() root = mount.path.resolve()
target = (root / str(candidate)).resolve() target = (root / str(candidate)).resolve()
if target != root and root not in target.parents: if target != root and root not in target.parents:
raise FileshareError("Der Pfad liegt ausserhalb der Freigabe.") raise FileshareError("Der Pfad liegt außerhalb der Freigabe.")
return target return target
#: Wie eine unterschriebene Fassung im Aenderungsprotokoll steht. Die
#: Pruefsumme ist der Anker -- sie steht im selben Eintrag wie der Name, den
#: der Unterschreibende angegeben hat.
SIGNATUR_AKTION = "fileshare.form_signed"
def pruefsumme(pfad: Path) -> str:
"""SHA-256 der Datei, blockweise gelesen."""
kessel = hashlib.sha256()
with pfad.open("rb") as strom:
for block in iter(lambda: strom.read(1024 * 1024), b""):
kessel.update(block)
return kessel.hexdigest()
def signaturdetail_lesen(detail: str) -> dict[str, Any]:
"""Zerlegt die Protokollzeile einer Unterschrift.
Aufbau: ``<Freigabe>:<Pfad> sha256=<Summe> -- angegeben unterschrieben
von: <Namen>``. Gelesen wird sie an zwei Stellen -- beim Uebernehmen
alter Eintraege und wenn das Verzeichnis nichts weiss.
"""
ort, _, rest = detail.partition(" sha256=")
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()]
def list_directory(mount: Mount, relative: str = "") -> dict[str, Any]: def list_directory(mount: Mount, relative: str = "") -> dict[str, Any]:
path = resolve_path(mount, relative) path = resolve_path(mount, relative)
if not path.is_dir(): if not path.is_dir():
@@ -528,6 +916,11 @@ def list_directory(mount: Mount, relative: str = "") -> dict[str, Any]:
directories.append(record) directories.append(record)
else: else:
record["previewable"] = entry.suffix.lower() in PREVIEW_MIME 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) files.append(record)
except OSError as exc: except OSError as exc:
raise FileshareError(f"Verzeichnis nicht lesbar ({exc}).") from exc raise FileshareError(f"Verzeichnis nicht lesbar ({exc}).") from exc
@@ -550,7 +943,7 @@ def preview_mime(name: str) -> str:
suffix = Path(name).suffix.lower() suffix = Path(name).suffix.lower()
if suffix in PREVIEW_MIME: if suffix in PREVIEW_MIME:
return PREVIEW_MIME[suffix] return PREVIEW_MIME[suffix]
raise FileshareError("Fuer diesen Dateityp gibt es keine Vorschau -- bitte herunterladen.") raise FileshareError("Für diesen Dateityp gibt es keine Vorschau – bitte herunterladen.")
def download_mime(name: str) -> str: def download_mime(name: str) -> str:
@@ -562,16 +955,86 @@ def safe_filename(name: str) -> str:
"""Verhindert Pfadanteile und versteckte Dateien in Uploads/Neuanlagen.""" """Verhindert Pfadanteile und versteckte Dateien in Uploads/Neuanlagen."""
cleaned = os.path.basename((name or "").strip().replace("\\", "/")) cleaned = os.path.basename((name or "").strip().replace("\\", "/"))
if not cleaned or cleaned in {".", ".."} or cleaned.startswith("."): if not cleaned or cleaned in {".", ".."} or cleaned.startswith("."):
raise FileshareError("Ungueltiger Dateiname.") raise FileshareError("Ungültiger Dateiname.")
if len(cleaned) > 200: if len(cleaned) > 200:
raise FileshareError("Der Dateiname ist zu lang.") raise FileshareError("Der Dateiname ist zu lang.")
return cleaned return cleaned
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: def delete_entry(mount: Mount, relative: str) -> str:
path = resolve_path(mount, relative) path = resolve_path(mount, relative)
if path == mount.path.resolve(): if path == mount.path.resolve():
raise FileshareError("Das Wurzelverzeichnis der Freigabe kann nicht geloescht werden.") raise FileshareError("Das Wurzelverzeichnis der Freigabe kann nicht gelöscht werden.")
if path.is_dir(): if path.is_dir():
shutil.rmtree(path) shutil.rmtree(path)
return "Verzeichnis" return "Verzeichnis"
@@ -589,8 +1052,26 @@ def make_directory(mount: Mount, relative: str, name: str) -> Path:
def rename_entry(mount: Mount, relative: str, new_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) 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(): if target.exists():
raise FileshareError("Ein Eintrag mit diesem Namen existiert bereits.") raise FileshareError("Ein Eintrag mit diesem Namen existiert bereits.")
path.rename(target) path.rename(target)
+23 -23
View File
@@ -54,13 +54,13 @@ def is_ssh_managed(category: str | None) -> bool:
#: war "Windows-Client" vorher -- waehlbar, aber ohne jede Wirkung. #: war "Windows-Client" vorher -- waehlbar, aber ohne jede Wirkung.
CATEGORY_EFFECTS: dict[str, str] = { CATEGORY_EFFECTS: dict[str, str] = {
"switch": "Anmeldung an Switchen: PoE schalten, Konsole, Statusabfragen.", "switch": "Anmeldung an Switchen: PoE schalten, Konsole, Statusabfragen.",
"linux": "Wartung ueber SSH: Paketaktualisierung (apt) und Neustart. Konsole.", "linux": "Wartung über SSH: Paketaktualisierung (apt) und Neustart. Konsole.",
"windows": ( "windows": (
"Neustart ueber RPC (wie 'shutdown /m'). Kein SSH, kein Host-Schluessel. Das " "Neustart über RPC (wie 'shutdown /m'). Kein SSH, kein Host-Schlüssel. Das "
"Geraet braucht offene Datei- und Druckerfreigabe (Port 445); das Konto braucht " "Gerät braucht offene Datei- und Druckerfreigabe (Port 445); das Konto braucht "
"das Recht, aus der Ferne herunterzufahren. Paketaktualisierung gibt es hier nicht." "das Recht, aus der Ferne herunterzufahren. Paketaktualisierung gibt es hier nicht."
), ),
"router": "Nur Dokumentation -- diese Kategorie loest keine Aktion aus.", "router": "Nur Dokumentation – diese Kategorie löst keine Aktion aus.",
} }
DEVICE_CATEGORIES: tuple[tuple[str, str], ...] = ( DEVICE_CATEGORIES: tuple[tuple[str, str], ...] = (
@@ -95,7 +95,7 @@ def normalize_mac(value: str) -> str:
cleaned = re.sub(r"[^0-9a-fA-F]", "", value or "") cleaned = re.sub(r"[^0-9a-fA-F]", "", value or "")
if len(cleaned) != 12: if len(cleaned) != 12:
raise ValidationError( raise ValidationError(
"Die MAC-Adresse muss zwoelf hexadezimale Zeichen enthalten " "Die MAC-Adresse muss zwölf hexadezimale Zeichen enthalten "
"(z. B. aa:bb:cc:dd:ee:ff)." "(z. B. aa:bb:cc:dd:ee:ff)."
) )
return ":".join(cleaned[index : index + 2] for index in range(0, 12, 2)).lower() return ":".join(cleaned[index : index + 2] for index in range(0, 12, 2)).lower()
@@ -106,7 +106,7 @@ def validate_ip(value: str, *, field: str = "IP-Adresse") -> str:
try: try:
ipaddress.ip_address(text) ipaddress.ip_address(text)
except ValueError as exc: except ValueError as exc:
raise ValidationError(f"{field}: {text!r} ist keine gueltige IP-Adresse.") from exc raise ValidationError(f"{field}: {text!r} ist keine gültige IP-Adresse.") from exc
return text return text
@@ -116,7 +116,7 @@ def validate_port_name(value: str) -> str:
return "" return ""
if not PORT_RE.match(text): if not PORT_RE.match(text):
raise ValidationError( raise ValidationError(
"Die Portbezeichnung enthaelt unerlaubte Zeichen (erlaubt: Buchstaben, " "Die Portbezeichnung enthält unerlaubte Zeichen (erlaubt: Buchstaben, "
"Ziffern, / . : -)." "Ziffern, / . : -)."
) )
return text return text
@@ -125,7 +125,7 @@ def validate_port_name(value: str) -> str:
def validate_hostname(value: str) -> str: def validate_hostname(value: str) -> str:
text = (value or "").strip() text = (value or "").strip()
if not HOSTNAME_RE.match(text): if not HOSTNAME_RE.match(text):
raise ValidationError("Der Hostname enthaelt unerlaubte Zeichen oder ist leer.") raise ValidationError("Der Hostname enthält unerlaubte Zeichen oder ist leer.")
return text return text
@@ -208,7 +208,7 @@ def create_credential(
if existing["deleted_at"]: if existing["deleted_at"]:
raise ValidationError( raise ValidationError(
f"Zugangsdaten namens {clean_name!r} liegen im Papierkorb. Stellen Sie sie " f"Zugangsdaten namens {clean_name!r} liegen im Papierkorb. Stellen Sie sie "
"wieder her oder loeschen Sie sie endgueltig." "wieder her oder löschen Sie sie endgültig."
) )
raise ValidationError(f"Zugangsdaten namens {clean_name!r} existieren bereits.") raise ValidationError(f"Zugangsdaten namens {clean_name!r} existieren bereits.")
@@ -258,11 +258,11 @@ def update_credential(
if clean_name != str(row["name"]): if clean_name != str(row["name"]):
changes.append(f"Name: {row['name']} -> {clean_name}") changes.append(f"Name: {row['name']} -> {clean_name}")
if (username or "").strip() != str(row["username"] or ""): if (username or "").strip() != str(row["username"] or ""):
changes.append("Benutzer geaendert") changes.append("Benutzer geändert")
if category != str(row["category"]): if category != str(row["category"]):
changes.append(f"Kategorie: {row['category']} -> {category}") changes.append(f"Kategorie: {row['category']} -> {category}")
if (description or "").strip() != str(row["description"] or ""): if (description or "").strip() != str(row["description"] or ""):
changes.append("Beschreibung geaendert") changes.append("Beschreibung geändert")
conn.execute( conn.execute(
"UPDATE credentials SET name=?, username=?, category=?, description=?, updated_at=?, " "UPDATE credentials SET name=?, username=?, category=?, description=?, updated_at=?, "
@@ -274,7 +274,7 @@ def update_credential(
"UPDATE credentials SET secret_encrypted=? WHERE id=?", "UPDATE credentials SET secret_encrypted=? WHERE id=?",
(keystore.encrypt(secret, aad=aad_for_credential(credential_id)), credential_id), (keystore.encrypt(secret, aad=aad_for_credential(credential_id)), credential_id),
) )
changes.append("Passwort geaendert") changes.append("Passwort geändert")
return changes return changes
@@ -296,8 +296,8 @@ def credential_secret(
secret = keystore.decrypt(encrypted, aad=aad_for_credential(int(credential_id))) secret = keystore.decrypt(encrypted, aad=aad_for_credential(int(credential_id)))
except DecryptionError as exc: except DecryptionError as exc:
raise ValidationError( raise ValidationError(
"Das gespeicherte Passwort konnte nicht entschluesselt werden. Wurde der " "Das gespeicherte Passwort konnte nicht entschlüsselt werden. Wurde der "
"Datenschluessel ausgetauscht? Bitte die Zugangsdaten neu eingeben." "Datenschlüssel ausgetauscht? Bitte die Zugangsdaten neu eingeben."
) from exc ) from exc
return str(row["username"] or ""), secret return str(row["username"] or ""), secret
@@ -315,7 +315,7 @@ def delete_credential(conn: sqlite3.Connection, credential_id: int) -> None:
if int(row.get("switches") or 0) or int(row.get("devices") or 0): if int(row.get("switches") or 0) or int(row.get("devices") or 0):
raise ValidationError( raise ValidationError(
f"Diese Zugangsdaten werden noch von {row.get('switches')} Switch(en) und " f"Diese Zugangsdaten werden noch von {row.get('switches')} Switch(en) und "
f"{row.get('devices')} Geraet(en) verwendet." f"{row.get('devices')} Gerät(en) verwendet."
) )
conn.execute("UPDATE credentials SET deleted_at=? WHERE id=?", (_now(), credential_id)) conn.execute("UPDATE credentials SET deleted_at=? WHERE id=?", (_now(), credential_id))
@@ -509,7 +509,7 @@ def delete_switch(conn: sqlite3.Connection, switch_id: int) -> None:
) )
if int(count or 0): if int(count or 0):
raise ValidationError( raise ValidationError(
f"Diesem Switch sind noch {count} Geraet(e) zugeordnet. Bitte zuerst umhaengen." f"Diesem Switch sind noch {count} Gerät(e) zugeordnet. Bitte zuerst umhängen."
) )
conn.execute("UPDATE switches SET deleted_at=? WHERE id=?", (_now(), switch_id)) conn.execute("UPDATE switches SET deleted_at=? WHERE id=?", (_now(), switch_id))
@@ -590,13 +590,13 @@ def create_device(
) )
if existing is not None: if existing is not None:
if existing["deleted_at"]: if existing["deleted_at"]:
raise ValidationError(f"Ein Geraet mit der MAC {clean_mac} liegt im Papierkorb.") raise ValidationError(f"Ein Gerät mit der MAC {clean_mac} liegt im Papierkorb.")
raise ValidationError(f"Ein Geraet mit der MAC {clean_mac} existiert bereits.") raise ValidationError(f"Ein Gerät mit der MAC {clean_mac} existiert bereits.")
if switch_id and not clean_port: if switch_id and not clean_port:
raise ValidationError( raise ValidationError(
"Zu einem zugeordneten Switch gehoert eine Portbezeichnung -- sonst ist kein " "Zu einem zugeordneten Switch gehört eine Portbezeichnung – sonst ist kein "
"PoE-Neustart moeglich." "PoE-Neustart möglich."
) )
cursor = conn.execute( cursor = conn.execute(
@@ -651,7 +651,7 @@ def update_device(
conn, "SELECT * FROM devices WHERE id=? AND deleted_at IS NULL", (device_id,) conn, "SELECT * FROM devices WHERE id=? AND deleted_at IS NULL", (device_id,)
) )
if row is None: if row is None:
raise ValidationError("Dieses Geraet existiert nicht (mehr).") raise ValidationError("Dieses Gerät existiert nicht (mehr).")
clean_name = (name or "").strip() clean_name = (name or "").strip()
if not clean_name: if not clean_name:
raise ValidationError("Der Name darf nicht leer sein.") raise ValidationError("Der Name darf nicht leer sein.")
@@ -664,9 +664,9 @@ def update_device(
(clean_mac, device_id), (clean_mac, device_id),
) )
if clash is not None: if clash is not None:
raise ValidationError(f"Ein Geraet mit der MAC {clean_mac} existiert bereits.") raise ValidationError(f"Ein Gerät mit der MAC {clean_mac} existiert bereits.")
if switch_id and not clean_port: if switch_id and not clean_port:
raise ValidationError("Zu einem zugeordneten Switch gehoert eine Portbezeichnung.") raise ValidationError("Zu einem zugeordneten Switch gehört eine Portbezeichnung.")
changes: list[str] = [] changes: list[str] = []
for label, before, after in ( for label, before, after in (
+12 -12
View File
@@ -46,7 +46,7 @@ STANDARD_OPTIONS: tuple[tuple[int, str, str, str], ...] = (
(9, "lpr-servers", "ipv4-address", "LPR-Druckserver"), (9, "lpr-servers", "ipv4-address", "LPR-Druckserver"),
(10, "impress-servers", "ipv4-address", "Impress-Server"), (10, "impress-servers", "ipv4-address", "Impress-Server"),
(11, "resource-location-servers", "ipv4-address", "Resource Location Server"), (11, "resource-location-servers", "ipv4-address", "Resource Location Server"),
(13, "boot-size", "uint16", "Boot-Image-Groesse (Bloecke)"), (13, "boot-size", "uint16", "Boot-Image-Größe (Blöcke)"),
(14, "merit-dump", "string", "Crash-Dump-Datei"), (14, "merit-dump", "string", "Crash-Dump-Datei"),
(16, "swap-server", "ipv4-address", "Swap-Server"), (16, "swap-server", "ipv4-address", "Swap-Server"),
(17, "root-path", "string", "Root-Pfad (NFS)"), (17, "root-path", "string", "Root-Pfad (NFS)"),
@@ -54,8 +54,8 @@ STANDARD_OPTIONS: tuple[tuple[int, str, str, str], ...] = (
(19, "ip-forwarding", "boolean", "IP-Forwarding aktivieren"), (19, "ip-forwarding", "boolean", "IP-Forwarding aktivieren"),
(20, "non-local-source-routing", "boolean", "Non-Local Source Routing"), (20, "non-local-source-routing", "boolean", "Non-Local Source Routing"),
(21, "policy-filter", "string", "Policy Filter (Ziel,Maske,...)"), (21, "policy-filter", "string", "Policy Filter (Ziel,Maske,...)"),
(22, "max-dgram-reassembly", "uint16", "Maximale Datagramm-Reassemblierungsgroesse"), (22, "max-dgram-reassembly", "uint16", "Maximale Datagramm-Reassemblierungsgröße"),
(23, "default-ip-ttl", "uint8", "Standard-TTL fuer IP"), (23, "default-ip-ttl", "uint8", "Standard-TTL für IP"),
(24, "path-mtu-aging-timeout", "uint32", "Path-MTU-Aging-Timeout (Sek.)"), (24, "path-mtu-aging-timeout", "uint32", "Path-MTU-Aging-Timeout (Sek.)"),
(25, "path-mtu-plateau-table", "uint16", "Path-MTU-Plateau-Tabelle"), (25, "path-mtu-plateau-table", "uint16", "Path-MTU-Plateau-Tabelle"),
(26, "interface-mtu", "uint16", "MTU des Interfaces"), (26, "interface-mtu", "uint16", "MTU des Interfaces"),
@@ -69,7 +69,7 @@ STANDARD_OPTIONS: tuple[tuple[int, str, str, str], ...] = (
(34, "trailer-encapsulation", "boolean", "Trailer Encapsulation"), (34, "trailer-encapsulation", "boolean", "Trailer Encapsulation"),
(35, "arp-cache-timeout", "uint32", "ARP-Cache-Timeout (Sek.)"), (35, "arp-cache-timeout", "uint32", "ARP-Cache-Timeout (Sek.)"),
(36, "ieee802-3-encapsulation", "boolean", "Ethernet-Encapsulation (IEEE 802.3)"), (36, "ieee802-3-encapsulation", "boolean", "Ethernet-Encapsulation (IEEE 802.3)"),
(37, "default-tcp-ttl", "uint8", "Standard-TTL fuer TCP"), (37, "default-tcp-ttl", "uint8", "Standard-TTL für TCP"),
(38, "tcp-keepalive-interval", "uint32", "TCP-Keepalive-Intervall (Sek.)"), (38, "tcp-keepalive-interval", "uint32", "TCP-Keepalive-Intervall (Sek.)"),
(40, "nis-domain", "string", "NIS-Domain"), (40, "nis-domain", "string", "NIS-Domain"),
(41, "nis-servers", "ipv4-address", "NIS-Server"), (41, "nis-servers", "ipv4-address", "NIS-Server"),
@@ -190,12 +190,12 @@ def validate_subnet(
) -> str: ) -> str:
"""Rueckgabe: leerer String bei Erfolg, sonst die Fehlermeldung.""" """Rueckgabe: leerer String bei Erfolg, sonst die Fehlermeldung."""
if not interface: if not interface:
return "Es ist kein Interface ausgewaehlt." return "Es ist kein Interface ausgewählt."
try: try:
start = ipaddress.ip_address(range_start) start = ipaddress.ip_address(range_start)
end = ipaddress.ip_address(range_end) end = ipaddress.ip_address(range_end)
except ValueError: except ValueError:
return "Start- und Endadresse muessen gueltige IPv4-Adressen sein." return "Start- und Endadresse müssen gültige IPv4-Adressen sein."
if int(end) < int(start): if int(end) < int(start):
return "Die Endadresse liegt vor der Startadresse." return "Die Endadresse liegt vor der Startadresse."
if matching_network(networks, interface, range_start, range_end) is None: if matching_network(networks, interface, range_start, range_end) is None:
@@ -405,7 +405,7 @@ def render_config(
# fuehren, dass gar keine Konfiguration mehr geschrieben wird. # fuehren, dass gar keine Konfiguration mehr geschrieben wird.
warnings.append( warnings.append(
f"Subnetz {subnet['range_start']}-{subnet['range_end']} auf {interface} " f"Subnetz {subnet['range_start']}-{subnet['range_end']} auf {interface} "
"passt in kein aktuelles Netz und wurde uebersprungen." "passt in kein aktuelles Netz und wurde übersprungen."
) )
continue continue
interfaces.add(interface) interfaces.add(interface)
@@ -444,7 +444,7 @@ def render_config(
) )
if not subnet_entries: if not subnet_entries:
warnings.append("Es ist kein gueltiges Subnetz konfiguriert -- Kea wuerde nicht starten.") warnings.append("Es ist kein gültiges Subnetz konfiguriert | Kea würde nicht starten.")
global_option_data = option_data(global_values) global_option_data = option_data(global_values)
domain = str(settings.get("dhcp_domain") or "").strip() domain = str(settings.get("dhcp_domain") or "").strip()
@@ -560,14 +560,14 @@ class Lease:
#: benutzte, und ein Geraet, das noch auf einer alten Adresse haengt, faellt #: benutzte, und ein Geraet, das noch auf einer alten Adresse haengt, faellt
#: ueberhaupt nicht auf. #: ueberhaupt nicht auf.
RESERVATION_STATES: dict[str, tuple[str, str]] = { RESERVATION_STATES: dict[str, tuple[str, str]] = {
"aktiv": ("online", "Das Geraet benutzt diese Adresse gerade."), "aktiv": ("online", "Das Gerät benutzt diese Adresse gerade."),
"abgelaufen": ("", "Lease auf dieser Adresse ist abgelaufen -- Geraet derzeit nicht da."), "abgelaufen": ("", "Lease auf dieser Adresse ist abgelaufen | Gerät derzeit nicht da."),
"andere_adresse": ( "andere_adresse": (
"warning", "warning",
"Das Geraet haelt noch eine Lease auf einer anderen Adresse. Es uebernimmt die " "Das Gerät hält noch eine Lease auf einer anderen Adresse. Es übernimmt die "
"Reservierung erst nach Ablauf oder Neuanforderung.", "Reservierung erst nach Ablauf oder Neuanforderung.",
), ),
"unbenutzt": ("", "Fuer dieses Geraet liegt keine Lease vor."), "unbenutzt": ("", "Für dieses Gerät liegt keine Lease vor."),
} }
+31 -31
View File
@@ -42,19 +42,19 @@ SUPPORTED = ("credentials", "switches", "devices", "dhcp")
KNOWN_UNSUPPORTED: dict[str, str] = { KNOWN_UNSUPPORTED: dict[str, str] = {
"users": ( "users": (
"Lokale Konten: der alte Passwort-Hash ist bcrypt, diese Anwendung " "Lokale Konten: der alte Passwort-Hash ist bcrypt, diese Anwendung "
"verwendet Argon2id. Ein Hash laesst sich nicht umrechnen -- das ist sein " "verwendet Argon2id. Ein Hash lässt sich nicht umrechnen – das ist sein "
"Zweck. Konten neu anlegen." "Zweck. Konten neu anlegen."
), ),
"groups": "Gruppen und Rechte: die Rechteschluessel wurden neu geschnitten.", "groups": "Gruppen und Rechte: die Rechteschlüssel wurden neu geschnitten.",
"ldap": ( "ldap": (
"Verzeichnisdienst: Einstellungen, Gruppen- und Freigabe-Zuordnungen von " "Verzeichnisdienst: Einstellungen, Gruppen- und Freigabe-Zuordnungen von "
"Hand uebertragen." "Hand übertragen."
), ),
"nginx": "Webserver: Domain, Ports und Zertifikat von Hand uebertragen.", "nginx": "Webserver: Domain, Ports und Zertifikat von Hand übertragen.",
"logs": "Auditlog: bleibt bewusst beim alten System.", "logs": "Auditlog: bleibt bewusst beim alten System.",
"known_hosts": ( "known_hosts": (
"SSH-Host-Schluessel: werden hier je Geraet mit Fingerprint gefuehrt und " "SSH-Host-Schlüssel: werden hier je Gerät mit Fingerprint geführt und "
"ueber die Seite 'Host-Schluessel' freigegeben." "über die Seite 'Host-Schlüssel' freigegeben."
), ),
} }
@@ -119,10 +119,10 @@ def open_legacy(raw: bytes | str, passphrase: str) -> dict[str, Any]:
except ValueError as exc: except ValueError as exc:
raise LegacyTransferError("Die Datei ist kein JSON.") from exc raise LegacyTransferError("Die Datei ist kein JSON.") from exc
if not isinstance(envelope, dict): if not isinstance(envelope, dict):
raise LegacyTransferError("Die Datei enthaelt kein Objekt.") raise LegacyTransferError("Die Datei enthält kein Objekt.")
if str(envelope.get("app") or "") != LEGACY_APP_TAG: if str(envelope.get("app") or "") != LEGACY_APP_TAG:
raise LegacyTransferError( raise LegacyTransferError(
f"Diese Datei gehoert zu {envelope.get('app') or 'einer unbekannten Anwendung'!r}, " f"Diese Datei gehört zu {envelope.get('app') or 'einer unbekannten Anwendung'!r}, "
"nicht zu TESM." "nicht zu TESM."
) )
for schluessel in ("salt", "payload"): for schluessel in ("salt", "payload"):
@@ -142,15 +142,15 @@ def open_legacy(raw: bytes | str, passphrase: str) -> dict[str, Any]:
klartext = Fernet(key).decrypt(str(envelope["payload"]).encode("utf-8")) klartext = Fernet(key).decrypt(str(envelope["payload"]).encode("utf-8"))
except (InvalidToken, ValueError) as exc: except (InvalidToken, ValueError) as exc:
raise LegacyTransferError( raise LegacyTransferError(
"Entschluesselung fehlgeschlagen -- falsche Passphrase oder beschaedigte Datei." "Entschlüsselung fehlgeschlagen – falsche Passphrase oder beschädigte Datei."
) from exc ) from exc
try: try:
payload = json.loads(klartext.decode("utf-8")) payload = json.loads(klartext.decode("utf-8"))
except ValueError as exc: except ValueError as exc:
raise LegacyTransferError("Die entschluesselte Nutzlast ist kein JSON.") from exc raise LegacyTransferError("Die entschlüsselte Nutzlast ist kein JSON.") from exc
if not isinstance(payload, dict): if not isinstance(payload, dict):
raise LegacyTransferError("Die Nutzlast enthaelt kein Objekt.") raise LegacyTransferError("Die Nutzlast enthält kein Objekt.")
return payload return payload
@@ -180,14 +180,14 @@ def _credentials(items: Any, report: Report) -> list[dict[str, Any]]:
for entry in _liste(items): for entry in _liste(items):
name = str(entry.get("name") or "").strip() name = str(entry.get("name") or "").strip()
if not name: if not name:
report.note("Zugangsdaten ohne Namen uebersprungen.") report.note("Zugangsdaten ohne Namen übersprungen.")
continue continue
umgesetzt: dict[str, Any] = { umgesetzt: dict[str, Any] = {
"name": name, "name": name,
"username": str(entry.get("username") or ""), "username": str(entry.get("username") or ""),
"category": _category(entry.get("category"), report, f"Zugangsdaten {name!r}"), "category": _category(entry.get("category"), report, f"Zugangsdaten {name!r}"),
# Der Vorgaenger kennt kein Beschreibungsfeld. # Der Vorgaenger kennt kein Beschreibungsfeld.
"description": "aus altem TESM uebernommen", "description": "aus altem TESM übernommen",
} }
# Der alte Export entschluesselt das Passwort und legt es im Klartext in # Der alte Export entschluesselt das Passwort und legt es im Klartext in
# die -- als Ganzes passphrasenverschluesselte -- Datei. Beim Import # die -- als Ganzes passphrasenverschluesselte -- Datei. Beim Import
@@ -199,8 +199,8 @@ def _credentials(items: Any, report: Report) -> list[dict[str, Any]]:
ergebnis.append(umgesetzt) ergebnis.append(umgesetzt)
if ohne_passwort: if ohne_passwort:
report.note( report.note(
f"{ohne_passwort} Zugangsdaten ohne Passwort in der Datei -- " f"{ohne_passwort} Zugangsdaten ohne Passwort in der Datei – "
"sie muessen nachgetragen werden." "sie müssen nachgetragen werden."
) )
report.took("credentials", len(ergebnis)) report.took("credentials", len(ergebnis))
return ergebnis return ergebnis
@@ -211,7 +211,7 @@ def _switches(items: Any, report: Report) -> list[dict[str, Any]]:
for entry in _liste(items): for entry in _liste(items):
hostname = str(entry.get("hostname") or "").strip() hostname = str(entry.get("hostname") or "").strip()
if not hostname: if not hostname:
report.note("Switch ohne Hostnamen uebersprungen.") report.note("Switch ohne Hostnamen übersprungen.")
continue continue
ergebnis.append( ergebnis.append(
{ {
@@ -240,7 +240,7 @@ def _devices(items: Any, credentials: list[dict[str, Any]], report: Report) -> l
for entry in _liste(items): for entry in _liste(items):
mac = str(entry.get("mac") or "").strip() mac = str(entry.get("mac") or "").strip()
if not mac: if not mac:
report.note("Client ohne MAC-Adresse uebersprungen.") report.note("Client ohne MAC-Adresse übersprungen.")
continue continue
zugang = entry.get("credential_name") zugang = entry.get("credential_name")
kategorie = kategorie_je_zugang.get(str(zugang or ""), "") kategorie = kategorie_je_zugang.get(str(zugang or ""), "")
@@ -266,7 +266,7 @@ def _devices(items: Any, credentials: list[dict[str, Any]], report: Report) -> l
if abgeleitet: if abgeleitet:
report.note( report.note(
f"Kategorie von {abgeleitet} Client(s) aus den zugeordneten Zugangsdaten " f"Kategorie von {abgeleitet} Client(s) aus den zugeordneten Zugangsdaten "
"abgeleitet -- der Vorgaenger kennt kein solches Feld. Bitte pruefen." "abgeleitet – der Vorgänger kennt kein solches Feld. Bitte prüfen."
) )
report.took("devices", len(ergebnis)) report.took("devices", len(ergebnis))
return ergebnis return ergebnis
@@ -298,14 +298,14 @@ def _dhcp(data: Any, report: Report) -> tuple[dict[str, Any], dict[str, str]]:
for entry in _liste(data.get("reservations")): for entry in _liste(data.get("reservations")):
mac = str(entry.get("mac") or "").strip() mac = str(entry.get("mac") or "").strip()
if not mac: if not mac:
report.note("Manuelle Reservierung ohne MAC-Adresse uebersprungen.") report.note("Manuelle Reservierung ohne MAC-Adresse übersprungen.")
continue continue
reservations.append( reservations.append(
{ {
"mac": mac, "mac": mac,
"ip": str(entry.get("ip") or ""), "ip": str(entry.get("ip") or ""),
"hostname": str(entry.get("name") or ""), "hostname": str(entry.get("name") or ""),
"comment": "aus altem TESM uebernommen", "comment": "aus altem TESM übernommen",
} }
) )
@@ -322,7 +322,7 @@ def _dhcp(data: Any, report: Report) -> tuple[dict[str, Any], dict[str, str]]:
"is_standard": int(bool(definition.get("is_standard"))), "is_standard": int(bool(definition.get("is_standard"))),
} }
if not gemeinsam["code"] or not gemeinsam["name"]: if not gemeinsam["code"] or not gemeinsam["name"]:
report.note("DHCP-Option ohne Code oder Namen uebersprungen.") report.note("DHCP-Option ohne Code oder Namen übersprungen.")
continue continue
werte = _liste(definition.get("values")) werte = _liste(definition.get("values"))
if not werte: if not werte:
@@ -339,7 +339,7 @@ def _dhcp(data: Any, report: Report) -> tuple[dict[str, Any], dict[str, str]]:
) )
if ohne_wert: if ohne_wert:
report.note( report.note(
f"{ohne_wert} eigene DHCP-Option(en) ohne Wert uebersprungen -- ohne Wert " f"{ohne_wert} eigene DHCP-Option(en) ohne Wert übersprungen – ohne Wert "
"gibt es nichts zu senden. Bei Bedarf von Hand anlegen." "gibt es nichts zu senden. Bei Bedarf von Hand anlegen."
) )
@@ -405,7 +405,7 @@ def convert(legacy: dict[str, Any]) -> tuple[dict[str, Any], Report]:
report.note( report.note(
"Diese Zugangsdaten sind zugeordnet, fehlen aber in der Datei und bleiben " "Diese Zugangsdaten sind zugeordnet, fehlen aber in der Datei und bleiben "
f"deshalb leer: {', '.join(fehlend)}. Beim Export im alten TESM die " f"deshalb leer: {', '.join(fehlend)}. Beim Export im alten TESM die "
"Kategorie 'Zugangsdaten' mitwaehlen." "Kategorie 'Zugangsdaten' mitwählen."
) )
for schluessel, grund in KNOWN_UNSUPPORTED.items(): for schluessel, grund in KNOWN_UNSUPPORTED.items():
@@ -413,12 +413,12 @@ def convert(legacy: dict[str, Any]) -> tuple[dict[str, Any], Report]:
report.note(grund) report.note(grund)
unbekannt = sorted(set(legacy) - set(SUPPORTED) - set(KNOWN_UNSUPPORTED)) unbekannt = sorted(set(legacy) - set(SUPPORTED) - set(KNOWN_UNSUPPORTED))
if unbekannt: if unbekannt:
report.note(f"Unbekannte Abschnitte uebersprungen: {', '.join(unbekannt)}.") report.note(f"Unbekannte Abschnitte übersprungen: {', '.join(unbekannt)}.")
if not payload: if not payload:
raise LegacyTransferError( raise LegacyTransferError(
"Die Datei enthaelt keine Clients, Switche, Zugangsdaten oder DHCP-Daten. " "Die Datei enthält keine Clients, Switche, Zugangsdaten oder DHCP-Daten. "
"Beim Export im alten TESM diese Kategorien mitwaehlen." "Beim Export im alten TESM diese Kategorien mitwählen."
) )
return payload, report return payload, report
@@ -488,7 +488,7 @@ def _entschluessler(root: Path, report: Report) -> Any:
schluesseldatei = root / LEGACY_FERNET_NAME schluesseldatei = root / LEGACY_FERNET_NAME
if not schluesseldatei.is_file(): if not schluesseldatei.is_file():
report.note( report.note(
f"{LEGACY_FERNET_NAME} nicht gefunden -- die Passwoerter der Zugangsdaten " f"{LEGACY_FERNET_NAME} nicht gefunden – die Passwörter der Zugangsdaten "
"bleiben leer und sind nachzutragen." "bleiben leer und sind nachzutragen."
) )
return lambda _token: "" return lambda _token: ""
@@ -499,7 +499,7 @@ def _entschluessler(root: Path, report: Report) -> Any:
fernet = Fernet(schluesseldatei.read_bytes().strip()) fernet = Fernet(schluesseldatei.read_bytes().strip())
except (ValueError, TypeError): except (ValueError, TypeError):
report.note( report.note(
f"{LEGACY_FERNET_NAME} ist unlesbar -- die Passwoerter der Zugangsdaten " f"{LEGACY_FERNET_NAME} ist unlesbar – die Passwörter der Zugangsdaten "
"bleiben leer und sind nachzutragen." "bleiben leer und sind nachzutragen."
) )
return lambda _token: "" return lambda _token: ""
@@ -515,8 +515,8 @@ def _entschluessler(root: Path, report: Report) -> Any:
gescheitert.append(1) gescheitert.append(1)
if len(gescheitert) == 1: if len(gescheitert) == 1:
report.note( report.note(
"Mindestens ein Passwort liess sich mit dem gefundenen Schluessel " "Mindestens ein Passwort ließ sich mit dem gefundenen Schlüssel "
"nicht entschluesseln -- es bleibt leer." "nicht entschlüsseln – es bleibt leer."
) )
return "" return ""
@@ -643,7 +643,7 @@ def read_installation(root: Path) -> tuple[dict[str, Any], Report]:
payload = {key: value for key, value in payload.items() if value} payload = {key: value for key, value in payload.items() if value}
if not payload: if not payload:
raise LegacyTransferError( raise LegacyTransferError(
f"In {root} stehen keine uebernehmbaren Daten (Clients, Switche, " f"In {root} stehen keine übernehmbaren Daten (Clients, Switche, "
"Zugangsdaten, DHCP)." "Zugangsdaten, DHCP)."
) )
for abschnitt, inhalt in payload.items(): for abschnitt, inhalt in payload.items():
+32 -32
View File
@@ -110,47 +110,47 @@ def is_noise(line: str) -> bool:
HINTS: tuple[tuple[str, str], ...] = ( HINTS: tuple[tuple[str, str], ...] = (
( (
"incorrect password attempt", "incorrect password attempt",
"Das Passwort aus den Zugangsdaten wurde von 'sudo' abgewiesen. Fuer die " "Das Passwort aus den Zugangsdaten wurde von 'sudo' abgewiesen. Für die "
"SSH-Anmeldung hat es gereicht -- 'sudo' auf diesem Geraet verlangt also ein " "SSH-Anmeldung hat es gereicht – 'sudo' auf diesem Gerät verlangt also ein "
"anderes Passwort, oder das Konto steht nicht in der sudoers-Datei.", "anderes Passwort, oder das Konto steht nicht in der sudoers-Datei.",
), ),
( (
"is not in the sudoers file", "is not in the sudoers file",
"Das Wartungskonto darf auf diesem Geraet kein 'sudo' ausfuehren. Auf dem " "Das Wartungskonto darf auf diesem Gerät kein 'sudo' ausführen. Auf dem "
"Zielgeraet eintragen, z. B. in /etc/sudoers.d/tesm-wartung: " "Zielgerät eintragen, z. B. in /etc/sudoers.d/tesm-wartung: "
"'<benutzer> ALL=(root) /usr/bin/apt-get, /sbin/reboot'. Mit vorangestelltem " "'<benutzer> ALL=(root) /usr/bin/apt-get, /sbin/reboot'. Mit vorangestelltem "
"NOPASSWD: entfaellt die Passwortabfrage ganz.", "NOPASSWD: entfällt die Passwortabfrage ganz.",
), ),
( (
"a password is required", "a password is required",
"'sudo' verlangt ein Passwort, und in den Zugangsdaten dieses Geraets ist keines " "'sudo' verlangt ein Passwort, und in den Zugangsdaten dieses Geräts ist keines "
"hinterlegt. Tragen Sie dort das Passwort des Wartungskontos ein -- TESM reicht " "hinterlegt. Tragen Sie dort das Passwort des Wartungskontos ein | TESM reicht "
"es ueber die bereits verschluesselte SSH-Verbindung an 'sudo' weiter.", "es über die bereits verschlüsselte SSH-Verbindung an 'sudo' weiter.",
), ),
( (
"no tty present", "no tty present",
"Das Wartungskonto verlangt fuer 'sudo' ein Terminal. In der sudoers-Datei des " "Das Wartungskonto verlangt für 'sudo' ein Terminal. In der sudoers-Datei des "
"Zielgeraets 'Defaults requiretty' entfernen oder fuer dieses Konto abschalten.", "Zielgeräts 'Defaults requiretty' entfernen oder für dieses Konto abschalten.",
), ),
( (
"command not found", "command not found",
"Das Kommando gibt es auf dem Zielgeraet nicht. Die Wartung erwartet ein " "Das Kommando gibt es auf dem Zielgerät nicht. Die Wartung erwartet ein "
"Debian- oder Ubuntu-System mit apt-get.", "Debian- oder Ubuntu-System mit apt-get.",
), ),
( (
"Access is denied", "Access is denied",
"Das Konto darf auf diesem Windows-Geraet keinen Neustart ausloesen. Es muss " "Das Konto darf auf diesem Windows-Gerät keinen Neustart auslösen. Es muss "
"Mitglied der lokalen Administratoren sein.", "Mitglied der lokalen Administratoren sein.",
), ),
( (
"is not recognized as an internal or external command", "is not recognized as an internal or external command",
"Das Kommando gibt es auf diesem Geraet nicht. Ist bei den Zugangsdaten die " "Das Kommando gibt es auf diesem Gerät nicht. Ist bei den Zugangsdaten die "
"richtige Kategorie gewaehlt (Linux-Client bzw. Windows-Client)?", "richtige Kategorie gewählt (Linux-Client bzw. Windows-Client)?",
), ),
( (
"Could not get lock", "Could not get lock",
"Ein anderer Paketvorgang laeuft bereits auf dem Zielgeraet (etwa " "Ein anderer Paketvorgang läuft bereits auf dem Zielgerät (etwa "
"unattended-upgrades). Spaeter erneut versuchen.", "unattended-upgrades). Später erneut versuchen.",
), ),
) )
@@ -256,8 +256,8 @@ def mark_orphaned_jobs(conn: sqlite3.Connection) -> int:
"UPDATE jobs SET state='unknown', message=?, finished_at=?, updated_at=? " "UPDATE jobs SET state='unknown', message=?, finished_at=?, updated_at=? "
"WHERE state IN ('pending','running')", "WHERE state IN ('pending','running')",
( (
"Der Dienst wurde waehrend des Laufs neu gestartet. Was auf dem Zielgeraet " "Der Dienst wurde während des Laufs neu gestartet. Was auf dem Zielgerät "
"passiert ist, laesst sich von hier nicht mehr feststellen.", "passiert ist, lässt sich von hier nicht mehr feststellen.",
_now(), _now(),
_now(), _now(),
), ),
@@ -293,7 +293,7 @@ def _run_remote_command(
channel.sendall(stdin_text.encode("utf-8")) channel.sendall(stdin_text.encode("utf-8"))
channel.shutdown_write() channel.shutdown_write()
except Exception as exc: # noqa: BLE001 except Exception as exc: # noqa: BLE001
on_line(f"[Eingabe konnte nicht uebergeben werden: {exc}]") on_line(f"[Eingabe konnte nicht übergeben werden: {exc}]")
deadline = time.monotonic() + timeout deadline = time.monotonic() + timeout
pending = b"" pending = b""
while True: while True:
@@ -308,7 +308,7 @@ def _run_remote_command(
elif channel.exit_status_ready(): elif channel.exit_status_ready():
break break
elif time.monotonic() > deadline: elif time.monotonic() > deadline:
on_line("[Zeitlimit ueberschritten -- Verbindung wird getrennt]") on_line("[Zeitlimit überschritten | Verbindung wird getrennt]")
channel.close() channel.close()
return 124 return 124
else: else:
@@ -341,14 +341,14 @@ def _sudo_mode(
except Exception: # noqa: BLE001 - dann eben der Weg mit Passwort except Exception: # noqa: BLE001 - dann eben der Weg mit Passwort
status = 1 status = 1
if status == 0: if status == 0:
on_line("sudo ist ohne Passwort moeglich.") on_line("sudo ist ohne Passwort möglich.")
return SUDO_PASSWORDLESS, "" return SUDO_PASSWORDLESS, ""
if not password: if not password:
on_line( on_line(
"sudo verlangt ein Passwort, in den Zugangsdaten ist keines hinterlegt." "sudo verlangt ein Passwort, in den Zugangsdaten ist keines hinterlegt."
) )
return SUDO_PASSWORDLESS, "" return SUDO_PASSWORDLESS, ""
on_line("sudo verlangt ein Passwort -- es wird aus den Zugangsdaten uebergeben.") on_line("sudo verlangt ein Passwort – es wird aus den Zugangsdaten übergeben.")
return SUDO_WITH_PASSWORD, password + "\n" return SUDO_WITH_PASSWORD, password + "\n"
@@ -411,7 +411,7 @@ def run_update(
) )
if status != 0: if status != 0:
failed = True failed = True
emit(f"Schritt {label!r} endete mit Rueckgabewert {status}.") emit(f"Schritt {label!r} endete mit Rückgabewert {status}.")
problem = hint_for("\n".join(collected)) problem = hint_for("\n".join(collected))
if problem: if problem:
emit("") emit("")
@@ -465,7 +465,7 @@ def _reboot_windows(
Rueckkehr danach ist dasselbe -- und das steht beim Aufrufer. Rueckkehr danach ist dasselbe -- und das steht beim Aufrufer.
""" """
host = str(device["ip"]) host = str(device["ip"])
emit(f"Windows-Geraet -- Neustart ueber RPC an {host} (Port {WINDOWS_PROBE_PORT}).") emit(f"Windows-Gerät | Neustart über RPC an {host} (Port {WINDOWS_PROBE_PORT}).")
result = windowsops.reboot(host, username, password) result = windowsops.reboot(host, username, password)
for line in result.output.splitlines(): for line in result.output.splitlines():
@@ -582,13 +582,13 @@ def run_reboot(
sudo, sudo_stdin = _sudo_mode(transport, password, emit) sudo, sudo_stdin = _sudo_mode(transport, password, emit)
command = REBOOT_COMMAND.format(sudo=sudo) command = REBOOT_COMMAND.format(sudo=sudo)
try: try:
emit(f"Neustart wird ausgeloest ({command})") emit(f"Neustart wird ausgelöst ({command})")
try: try:
_run_remote_command( _run_remote_command(
transport, command, timeout=20, on_line=capture, stdin_text=sudo_stdin transport, command, timeout=20, on_line=capture, stdin_text=sudo_stdin
) )
except Exception as exc: # noqa: BLE001 - Abbruch ist hier normal except Exception as exc: # noqa: BLE001 - Abbruch ist hier normal
emit(f"Verbindung getrennt ({exc}) -- das ist beim Neustart zu erwarten.") emit(f"Verbindung getrennt ({exc}) – das ist beim Neustart zu erwarten.")
finally: finally:
try: try:
transport.close() transport.close()
@@ -621,7 +621,7 @@ def run_reboot(
) )
return return
update_job(database, job_id, progress=40, message="Warte auf Rueckkehr des Geraets") update_job(database, job_id, progress=40, message="Warte auf Rückkehr des Geräts")
time.sleep(REBOOT_GRACE_SECONDS) time.sleep(REBOOT_GRACE_SECONDS)
deadline = time.monotonic() + wait_seconds deadline = time.monotonic() + wait_seconds
ssh_port = probe_port ssh_port = probe_port
@@ -632,7 +632,7 @@ def run_reboot(
# Zeitueberschreitung, obwohl das Geraet zurueck war. Ausserdem ist # Zeitueberschreitung, obwohl das Geraet zurueck war. Ausserdem ist
# "SSH antwortet wieder" die Aussage, auf die es hier ankommt. # "SSH antwortet wieder" die Aussage, auf die es hier ankommt.
if tcp_reachable(host, ssh_port): if tcp_reachable(host, ssh_port):
emit(f"Geraet ist wieder erreichbar (Port {ssh_port} antwortet).") emit(f"Gerät ist wieder erreichbar (Port {ssh_port} antwortet).")
update_job( update_job(
database, database,
job_id, job_id,
@@ -656,7 +656,7 @@ def run_reboot(
result="ok", result="ok",
actor=actor, actor=actor,
duration_ms=elapsed(), duration_ms=elapsed(),
detail="Neustart ueber SSH, Geraet ist zurueckgekehrt.", detail="Neustart über SSH, Gerät ist zurückgekehrt.",
method=method, method=method,
) )
return return
@@ -668,7 +668,7 @@ def run_reboot(
job_id, job_id,
state="failed", state="failed",
progress=100, progress=100,
message=f"Geraet nicht zurueckgekehrt ({host}:{ssh_port} antwortet nicht).", message=f"Gerät nicht zurückgekehrt ({host}:{ssh_port} antwortet nicht).",
finished=True, finished=True,
) )
_log_restart( _log_restart(
@@ -678,7 +678,7 @@ def run_reboot(
actor=actor, actor=actor,
duration_ms=elapsed(), duration_ms=elapsed(),
detail=( detail=(
f"Neustart ausgeloest, aber {host}:{ssh_port} hat innerhalb von " f"Neustart ausgelöst, aber {host}:{ssh_port} hat innerhalb von "
f"{wait_seconds}s nicht wieder geantwortet." f"{wait_seconds}s nicht wieder geantwortet."
), ),
method=method, method=method,
@@ -718,7 +718,7 @@ def _log_restart(
method=method, method=method,
) )
except Exception: # noqa: BLE001 - Protokollieren darf den Auftrag nie kippen except Exception: # noqa: BLE001 - Protokollieren darf den Auftrag nie kippen
logger.exception("Neustart konnte nicht protokolliert werden (Geraet %s)", device_id) logger.exception("Neustart konnte nicht protokolliert werden (Gerät %s)", device_id)
def start_background(target: Callable[..., None], **kwargs: Any) -> None: def start_background(target: Callable[..., None], **kwargs: Any) -> None:
+7 -7
View File
@@ -115,7 +115,7 @@ def ping(host: str, *, timeout: int = PING_TIMEOUT_SECONDS) -> PingResult:
check=False, check=False,
) )
except subprocess.TimeoutExpired: except subprocess.TimeoutExpired:
return PingResult(False, detail="Zeitlimit ueberschritten") return PingResult(False, detail="Zeitlimit überschritten")
except OSError as exc: except OSError as exc:
return PingResult(False, detail=str(exc)) return PingResult(False, detail=str(exc))
@@ -274,8 +274,8 @@ METHOD_SSH = "ssh"
METHOD_RPC = "rpc" METHOD_RPC = "rpc"
METHOD_LABELS = { METHOD_LABELS = {
METHOD_POE: "PoE-Port", METHOD_POE: "PoE-Port",
METHOD_SSH: "Neustart ueber SSH", METHOD_SSH: "Neustart über SSH",
METHOD_RPC: "Neustart ueber RPC", METHOD_RPC: "Neustart über RPC",
} }
#: Zustand zwischen ausgeloestem Neustart und Rueckkehr. #: Zustand zwischen ausgeloestem Neustart und Rueckkehr.
@@ -406,7 +406,7 @@ def restart_device_poe(
if not port or not switch_ip: if not port or not switch_ip:
message = ( message = (
"Kein Switch oder kein Port zugeordnet -- ein PoE-Neustart ist nicht moeglich." "Kein Switch oder kein Port zugeordnet – ein PoE-Neustart ist nicht möglich."
) )
with write(): with write():
record_restart( record_restart(
@@ -422,7 +422,7 @@ def restart_device_poe(
return {"ok": False, "message": message, "transcript": ""} return {"ok": False, "message": message, "transcript": ""}
if not credential_id: if not credential_id:
message = f"Fuer Switch {device.get('switch_hostname') or switch_ip} sind keine Zugangsdaten hinterlegt." message = f"Für Switch {device.get('switch_hostname') or switch_ip} sind keine Zugangsdaten hinterlegt."
with write(): with write():
record_restart( record_restart(
conn, conn,
@@ -486,7 +486,7 @@ def restart_device_poe(
live.error("poe", f"{name}: PoE-Neustart fehlgeschlagen -- {outcome.error}") live.error("poe", f"{name}: PoE-Neustart fehlgeschlagen -- {outcome.error}")
return { return {
"ok": outcome.ok, "ok": outcome.ok,
"message": outcome.error or "PoE-Neustart durchgefuehrt.", "message": outcome.error or "PoE-Neustart durchgeführt.",
"transcript": outcome.transcript, "transcript": outcome.transcript,
} }
@@ -567,7 +567,7 @@ def run_sweep(
report.skipped_unlicensed += 1 report.skipped_unlicensed += 1
live.warning( live.warning(
"poe", "poe",
f"{name}: automatischer PoE-Neustart uebersprungen -- keine gueltige Lizenz.", f"{name}: automatischer PoE-Neustart übersprungen – keine gültige Lizenz.",
) )
continue continue
report.restarts_triggered += 1 report.restarts_triggered += 1
+6 -6
View File
@@ -54,24 +54,24 @@ DEFINITIONS: dict[str, Method] = {
POWER_ON: Method( POWER_ON: Method(
key=METHOD_POE, key=METHOD_POE,
label="Einschalten (PoE)", label="Einschalten (PoE)",
hint="Schaltet den Switch-Port aus und wieder ein. Bei einem Geraet, das nicht " hint="Schaltet den Switch-Port aus und wieder ein. Bei einem Gerät, das nicht "
"antwortet, ist das der einzige Weg, der noch etwas bewirkt.", "antwortet, ist das der einzige Weg, der noch etwas bewirkt.",
), ),
METHOD_POE: Method( METHOD_POE: Method(
key=METHOD_POE, key=METHOD_POE,
label="PoE-Port", label="PoE-Port",
hint="Schaltet den Switch-Port kurz ab. Wirkt auch, wenn das Geraet haengt.", hint="Schaltet den Switch-Port kurz ab. Wirkt auch, wenn das Gerät hängt.",
), ),
METHOD_SSH: Method( METHOD_SSH: Method(
key=METHOD_SSH, key=METHOD_SSH,
label="Neustart ueber SSH", label="Neustart über SSH",
hint="Faehrt das Betriebssystem sauber herunter. Setzt ein antwortendes Geraet " hint="Fährt das Betriebssystem sauber herunter. Setzt ein antwortendes Gerät "
"und einen freigegebenen Host-Schluessel voraus.", "und einen freigegebenen Host-Schlüssel voraus.",
licensed=True, licensed=True,
), ),
METHOD_RPC: Method( METHOD_RPC: Method(
key=METHOD_RPC, key=METHOD_RPC,
label="Neustart ueber RPC", label="Neustart über RPC",
hint="Windows: wie 'shutdown /m'. Setzt offene Datei- und Druckerfreigabe voraus.", hint="Windows: wie 'shutdown /m'. Setzt offene Datei- und Druckerfreigabe voraus.",
licensed=True, licensed=True,
), ),
+1 -1
View File
@@ -128,7 +128,7 @@ class ShellSession:
elif time.monotonic() > deadline: elif time.monotonic() > deadline:
channel.close() channel.close()
raise SshError( raise SshError(
f"Das Kommando hat das Zeitlimit von {timeout}s ueberschritten." f"Das Kommando hat das Zeitlimit von {timeout}s überschritten."
) )
else: else:
time.sleep(0.05) time.sleep(0.05)
+11 -11
View File
@@ -48,8 +48,8 @@ class UnknownHostKey(SshError):
def __init__(self, host: str, port: int, key_type: str, fingerprint: str) -> None: def __init__(self, host: str, port: int, key_type: str, fingerprint: str) -> None:
super().__init__( super().__init__(
f"Der SSH-Host-Schluessel von {host}:{port} ist unbekannt " f"Der SSH-Host-Schlüssel von {host}:{port} ist unbekannt "
f"({key_type}, {fingerprint}). Bitte pruefen und ausdruecklich freigeben." f"({key_type}, {fingerprint}). Bitte prüfen und ausdrücklich freigeben."
) )
self.host = host self.host = host
self.port = port self.port = port
@@ -62,9 +62,9 @@ class HostKeyChanged(SshError):
def __init__(self, host: str, port: int, expected: str, received: str) -> None: def __init__(self, host: str, port: int, expected: str, received: str) -> None:
super().__init__( super().__init__(
f"Der SSH-Host-Schluessel von {host}:{port} hat sich geaendert " f"Der SSH-Host-Schlüssel von {host}:{port} hat sich geändert "
f"(erwartet {expected}, erhalten {received}). Der Aufbau wurde abgebrochen. " f"(erwartet {expected}, erhalten {received}). Der Aufbau wurde abgebrochen. "
"Das kann ein Geraetetausch sein -- oder ein Angriff." "Das kann ein Gerätetausch sein – oder ein Angriff."
) )
self.host = host self.host = host
self.port = port self.port = port
@@ -187,17 +187,17 @@ def describe_probe(
if known is None: if known is None:
return ( return (
"info", "info",
f"Host-Schluessel gelesen: {record.key_type} {record.fingerprint}. " f"Host-Schlüssel gelesen: {record.key_type} {record.fingerprint}. "
"Bitte gegen eine unabhaengige Quelle pruefen und dann freigeben.", "Bitte gegen eine unabhängige Quelle prüfen und dann freigeben.",
) )
if known.fingerprint != record.fingerprint: if known.fingerprint != record.fingerprint:
return ( return (
"danger", "danger",
f"Achtung: der Schluessel weicht vom gespeicherten ab. Gespeichert " f"Achtung: der Schlüssel weicht vom gespeicherten ab. Gespeichert "
f"{known.fingerprint}, gelesen {record.fingerprint}. Das kann ein " f"{known.fingerprint}, gelesen {record.fingerprint}. Das kann ein "
"Geraetetausch sein -- oder ein Angriff.", "Gerätetausch sein – oder ein Angriff.",
) )
return ("success", "Der Host-Schluessel stimmt mit dem gespeicherten ueberein.") return ("success", "Der Host-Schlüssel stimmt mit dem gespeicherten überein.")
def probe_host_key(host: str, port: int = 22, *, timeout: int = CONNECT_TIMEOUT) -> HostKeyRecord: def probe_host_key(host: str, port: int = 22, *, timeout: int = CONNECT_TIMEOUT) -> HostKeyRecord:
@@ -281,8 +281,8 @@ def open_transport(
raise SshError( raise SshError(
f"SSH-Handshake mit {host}:{port} fehlgeschlagen ({exc}). " f"SSH-Handshake mit {host}:{port} fehlgeschlagen ({exc}). "
f"Freigegeben sind nur {', '.join(sorted(set(trusted_types)))} -- " f"Freigegeben sind nur {', '.join(sorted(set(trusted_types)))} -- "
"bietet das Geraet inzwischen einen anderen Schluesseltyp an, muss der " "bietet das Gerät inzwischen einen anderen Schlüsseltyp an, muss der "
"Schluessel neu ausgelesen und freigegeben werden." "Schlüssel neu ausgelesen und freigegeben werden."
) from exc ) from exc
raise SshError(f"SSH-Handshake mit {host}:{port} fehlgeschlagen ({exc}).") from exc raise SshError(f"SSH-Handshake mit {host}:{port} fehlgeschlagen ({exc}).") from exc
+4 -4
View File
@@ -149,7 +149,7 @@ class SwitchCli:
``tolerate_error`` (z. B. ``no page``, das nicht jede Firmware kennt). ``tolerate_error`` (z. B. ``no page``, das nicht jede Firmware kennt).
""" """
if self._channel is None: if self._channel is None:
raise SshError("Die Sitzung ist nicht geoeffnet.") raise SshError("Die Sitzung ist nicht geöffnet.")
self.transcript.append(f"\n$ {command}\n") self.transcript.append(f"\n$ {command}\n")
self._channel.send(command + "\r\n") self._channel.send(command + "\r\n")
output = self._read_until_prompt(timeout=timeout) output = self._read_until_prompt(timeout=timeout)
@@ -207,7 +207,7 @@ class SwitchCli:
off_state = self.poe_state(port) off_state = self.poe_state(port)
if off_state == "enabled": if off_state == "enabled":
result.error = ( result.error = (
"Der Switch meldet den Port nach dem Abschalten weiter als aktiv -- " "Der Switch meldet den Port nach dem Abschalten weiter als aktiv – "
"der Neustart hat nicht stattgefunden." "der Neustart hat nicht stattgefunden."
) )
result.transcript = self.full_transcript result.transcript = self.full_transcript
@@ -245,11 +245,11 @@ class SwitchCli:
#: Kommandos, die die Oberflaeche ohne freies Eingabefeld anbietet. #: Kommandos, die die Oberflaeche ohne freies Eingabefeld anbietet.
SAFE_COMMANDS: tuple[tuple[str, str], ...] = ( SAFE_COMMANDS: tuple[tuple[str, str], ...] = (
("show system information", "Systeminformationen"), ("show system information", "Systeminformationen"),
("show power-over-ethernet brief", "PoE-Uebersicht aller Ports"), ("show power-over-ethernet brief", "PoE-Übersicht aller Ports"),
("show interfaces brief", "Portstatus"), ("show interfaces brief", "Portstatus"),
("show vlans", "VLAN-Uebersicht"), ("show vlans", "VLAN-Uebersicht"),
("show mac-address", "MAC-Adresstabelle"), ("show mac-address", "MAC-Adresstabelle"),
("show lldp info remote-device", "Nachbargeraete (LLDP)"), ("show lldp info remote-device", "Nachbargeräte (LLDP)"),
("show version", "Firmware-Version"), ("show version", "Firmware-Version"),
("show logging", "Switch-Protokoll"), ("show logging", "Switch-Protokoll"),
) )
+4 -4
View File
@@ -50,13 +50,13 @@ def unseal(raw: bytes | str, passphrase: str) -> tuple[dict[str, Any], dict[str,
CATEGORIES: tuple[Category, ...] = ( CATEGORIES: tuple[Category, ...] = (
Category("devices", "Clients", "Geraetestammdaten inklusive Switch- und Portzuordnung"), Category("devices", "Clients", "Gerätestammdaten inklusive Switch- und Portzuordnung"),
Category("switches", "Switche", "Switch-Inventar"), Category("switches", "Switche", "Switch-Inventar"),
Category( Category(
"credentials", "credentials",
"Zugangsdaten", "Zugangsdaten",
"SSH-Logins. Die Passwoerter liegen im Export im Klartext -- geschuetzt " "SSH-Logins. Die Passwörter liegen im Export im Klartext – geschützt "
"ausschliesslich durch die Passphrase.", "ausschließlich durch die Passphrase.",
sensitive=True, sensitive=True,
), ),
Category("dhcp", "DHCP-Konfiguration", "Subnetze, Optionen und manuelle Reservierungen"), Category("dhcp", "DHCP-Konfiguration", "Subnetze, Optionen und manuelle Reservierungen"),
@@ -123,7 +123,7 @@ def build_export(
str(row["secret_encrypted"]), aad=aad_for_credential(int(row["id"])) str(row["secret_encrypted"]), aad=aad_for_credential(int(row["id"]))
) )
except Exception: # noqa: BLE001 except Exception: # noqa: BLE001
entry["secret_error"] = "nicht entschluesselbar" entry["secret_error"] = "nicht entschlüsselbar"
entries.append(entry) entries.append(entry)
payload["credentials"] = entries payload["credentials"] = entries
if "dhcp" in wanted: if "dhcp" in wanted:
+20 -20
View File
@@ -51,28 +51,28 @@ def available() -> bool:
HINTS: tuple[tuple[str, str], ...] = ( HINTS: tuple[tuple[str, str], ...] = (
( (
"NT_STATUS_LOGON_FAILURE", "NT_STATUS_LOGON_FAILURE",
"Benutzername oder Passwort wurden vom Windows-Geraet abgewiesen. Bei einem " "Benutzername oder Passwort wurden vom Windows-Gerät abgewiesen. Bei einem "
"Domaenenkonto die Form DOMAENE\\benutzer verwenden -- mit *einem* " "Domänenkonto die Form DOMAENE\\benutzer verwenden – mit *einem* "
"Backslash.", "Backslash.",
), ),
( (
"NT_STATUS_ACCESS_DENIED", "NT_STATUS_ACCESS_DENIED",
"Das Konto darf auf diesem Geraet keinen Neustart ausloesen. Es braucht das Recht " "Das Konto darf auf diesem Gerät keinen Neustart auslösen. Es braucht das Recht "
"'Herunterfahren von einem Remotesystem aus' -- Mitglied der lokalen " "'Herunterfahren von einem Remotesystem aus' | Mitglied der lokalen "
"Administratoren genuegt dafuer in der Regel.", "Administratoren genügt dafür in der Regel.",
), ),
( (
"NT_STATUS_CONNECTION_REFUSED", "NT_STATUS_CONNECTION_REFUSED",
"Das Geraet nimmt keine RPC-Verbindung an. Die Datei- und Druckerfreigabe muss " "Das Gerät nimmt keine RPC-Verbindung an. Die Datei- und Druckerfreigabe muss "
"aktiv und Port 445 in der Windows-Firewall freigegeben sein.", "aktiv und Port 445 in der Windows-Firewall freigegeben sein.",
), ),
( (
"NT_STATUS_IO_TIMEOUT", "NT_STATUS_IO_TIMEOUT",
"Keine Antwort auf Port 445. Ist das Geraet erreichbar und die Firewall offen?", "Keine Antwort auf Port 445. Ist das Gerät erreichbar und die Firewall offen?",
), ),
( (
"NT_STATUS_HOST_UNREACHABLE", "NT_STATUS_HOST_UNREACHABLE",
"Das Geraet ist nicht erreichbar.", "Das Gerät ist nicht erreichbar.",
), ),
# Diese Meldung liest sich wie ein technisches Detail, hat aber fast immer # Diese Meldung liest sich wie ein technisches Detail, hat aber fast immer
# eine einzige Ursache: das Konto ist auf dem Zielgeraet kein Administrator. # eine einzige Ursache: das Konto ist auf dem Zielgeraet kein Administrator.
@@ -81,14 +81,14 @@ HINTS: tuple[tuple[str, str], ...] = (
# bekommt ein Standardkonto die winreg-Pipe gar nicht zu sehen. # bekommt ein Standardkonto die winreg-Pipe gar nicht zu sehen.
( (
"pipe winreg", "pipe winreg",
"Das Konto hat auf diesem Geraet keine Administratorrechte. Ein Neustart ueber " "Das Konto hat auf diesem Gerät keine Administratorrechte. Ein Neustart über "
"RPC braucht sie: die Pipe, ueber die er laeuft, ist fuer Standardkonten nicht " "RPC braucht sie: die Pipe, über die er läuft, ist für Standardkonten nicht "
"sichtbar. Ein Konto der lokalen Administratoren hinterlegen -- pruefen laesst " "sichtbar. Ein Konto der lokalen Administratoren hinterlegen – prüfen lässt "
"sich das mit 'net rpc service status RemoteRegistry'.", "sich das mit 'net rpc service status RemoteRegistry'.",
), ),
( (
"NT_STATUS_OBJECT_NAME_NOT_FOUND", "NT_STATUS_OBJECT_NAME_NOT_FOUND",
"Der benoetigte RPC-Dienst antwortet auf dem Geraet nicht. Meist fehlen dem " "Der benötigte RPC-Dienst antwortet auf dem Gerät nicht. Meist fehlen dem "
"Konto die Administratorrechte; sonst ist der Dienst 'Remoteregistrierung' " "Konto die Administratorrechte; sonst ist der Dienst 'Remoteregistrierung' "
"gestoppt.", "gestoppt.",
), ),
@@ -143,8 +143,8 @@ def _net_with_credentials(
return RemoteResult( return RemoteResult(
False, False,
"", "",
f"Auf diesem Server fehlt das Paket {REQUIRED_PACKAGE}. Ohne 'net' laesst sich " f"Auf diesem Server fehlt das Paket {REQUIRED_PACKAGE}. Ohne 'net' lässt sich "
"ein Windows-Geraet von hier nicht ansprechen.", "ein Windows-Gerät von hier nicht ansprechen.",
) )
if not username: if not username:
return RemoteResult(False, "", "Es sind keine Zugangsdaten hinterlegt.") return RemoteResult(False, "", "Es sind keine Zugangsdaten hinterlegt.")
@@ -176,7 +176,7 @@ def _net_with_credentials(
check=False, check=False,
) )
except subprocess.TimeoutExpired: except subprocess.TimeoutExpired:
return RemoteResult(False, "", f"Zeitlimit von {timeout}s ueberschritten.") return RemoteResult(False, "", f"Zeitlimit von {timeout}s überschritten.")
except OSError as exc: except OSError as exc:
return RemoteResult(False, "", str(exc)) return RemoteResult(False, "", str(exc))
return completed.returncode, (completed.stdout + completed.stderr).strip() return completed.returncode, (completed.stdout + completed.stderr).strip()
@@ -190,7 +190,7 @@ def _net_with_credentials(
stream.flush() stream.flush()
os.fsync(stream.fileno()) os.fsync(stream.fileno())
except OSError: # pragma: no cover - defensiv except OSError: # pragma: no cover - defensiv
logger.warning("Anmeldedatei konnte nicht ueberschrieben werden: %s", path) logger.warning("Anmeldedatei konnte nicht überschrieben werden: %s", path)
try: try:
os.unlink(path) os.unlink(path)
except OSError: # pragma: no cover - defensiv except OSError: # pragma: no cover - defensiv
@@ -236,10 +236,10 @@ def check(
return RemoteResult( return RemoteResult(
False, False,
output, output,
"Anmeldung erfolgreich, aber das Konto hat auf diesem Geraet keine " "Anmeldung erfolgreich, aber das Konto hat auf diesem Gerät keine "
"Administratorrechte. Fuer einen Neustart ueber RPC braucht es sie.", "Administratorrechte. Für einen Neustart über RPC braucht es sie.",
) )
return RemoteResult(False, output, hint_for(output) or "Pruefung fehlgeschlagen.") return RemoteResult(False, output, hint_for(output) or "Prüfung fehlgeschlagen.")
def reboot( def reboot(
@@ -275,5 +275,5 @@ def reboot(
return ergebnis return ergebnis
code, output = ergebnis code, output = ergebnis
if code == 0: if code == 0:
return RemoteResult(True, output or "Neustart ausgeloest.") return RemoteResult(True, output or "Neustart ausgelöst.")
return RemoteResult(False, output, hint_for(output)) return RemoteResult(False, output, hint_for(output))
+38 -23
View File
@@ -12,8 +12,8 @@ TESM_SETTINGS: tuple[SettingSpec, ...] = (
"monitor_interval_seconds", "monitor_interval_seconds",
300, 300,
"int", "int",
"Pruefintervall (Sekunden)", "Prüfintervall (Sekunden)",
hint="Abstand zwischen zwei Erreichbarkeitspruefungen. Lizenzpflichtig.", hint="Abstand zwischen zwei Erreichbarkeitsprüfungen. Lizenzpflichtig.",
section="monitoring", section="monitoring",
minimum=30, minimum=30,
maximum=86400, maximum=86400,
@@ -23,8 +23,8 @@ TESM_SETTINGS: tuple[SettingSpec, ...] = (
2, 2,
"int", "int",
"Fehlversuche bis zum automatischen PoE-Neustart", "Fehlversuche bis zum automatischen PoE-Neustart",
hint="Erst nach so vielen aufeinanderfolgenden Fehlschlaegen wird geschaltet. " hint="Erst nach so vielen aufeinanderfolgenden Fehlschlägen wird geschaltet. "
"Der Vorgaenger schaltete sofort beim ersten Ausfall.", "Der Vorgänger schaltete sofort beim ersten Ausfall.",
section="monitoring", section="monitoring",
minimum=1, minimum=1,
maximum=10, maximum=10,
@@ -40,8 +40,8 @@ TESM_SETTINGS: tuple[SettingSpec, ...] = (
"dashboard_public", "dashboard_public",
True, True,
"bool", "bool",
"Statusuebersicht ohne Anmeldung erreichbar", "Statusübersicht ohne Anmeldung erreichbar",
hint="Zeigt ausschliesslich Name und Erreichbarkeit aktiver Geraete -- keine " hint="Zeigt ausschließlich Name und Erreichbarkeit aktiver Geräte – keine "
"IP-Adressen, keine Switch-Zuordnung, keine Verwaltungsfunktionen.", "IP-Adressen, keine Switch-Zuordnung, keine Verwaltungsfunktionen.",
section="monitoring", section="monitoring",
), ),
@@ -74,20 +74,11 @@ TESM_SETTINGS: tuple[SettingSpec, ...] = (
"fileshare_max_age_hours", "fileshare_max_age_hours",
12, 12,
"int", "int",
"Einbindungen automatisch loesen nach (Stunden)", "Einbindungen automatisch lösen nach (Stunden)",
section="fileshare", section="fileshare",
minimum=1, minimum=1,
maximum=168, maximum=168,
), ),
SettingSpec(
"fileshare_upload_max_mb",
512,
"int",
"Maximale Dateigroesse beim Hochladen (MB)",
section="fileshare",
minimum=1,
maximum=8192,
),
# -- Wartung ------------------------------------------------------------ # # -- Wartung ------------------------------------------------------------ #
SettingSpec( SettingSpec(
"maintenance_command_timeout", "maintenance_command_timeout",
@@ -102,26 +93,50 @@ TESM_SETTINGS: tuple[SettingSpec, ...] = (
"maintenance_reboot_wait", "maintenance_reboot_wait",
300, 300,
"int", "int",
"Wartezeit auf die Rueckkehr nach einem Neustart (Sekunden)", "Wartezeit auf die Rückkehr nach einem Neustart (Sekunden)",
section="maintenance", section="maintenance",
minimum=30, minimum=30,
maximum=1800, maximum=1800,
), ),
# -- Terminal ----------------------------------------------------------- # # -- Unterschriften ------------------------------------------------------ #
SettingSpec( SettingSpec(
"terminal_enabled", "signature_witness_enabled",
True, True,
"bool", "bool",
"Browser-Terminal fuer Switche aktiv", "Unterschriften mit dem ablegenden Konto bezeugen",
hint="Auch wenn aktiv: erfordert das Recht 'Ausfuehren' auf Switche, eine frische " hint="Eine Zeile unter der **ersten** Unterschrift nennt das Konto, das das "
"Passwortbestaetigung und wird vollstaendig protokolliert.", "Dokument in TESM abgelegt hat. In den Dokumenteigenschaften und im "
"Änderungsprotokoll steht es ohnehin, unabhängig von diesem Schalter. Der Name in "
"der Unterschrift ist eine Angabe des Unterschreibenden – wie auf Papier; das "
"Konto ist belegt. Eine Passwortabfrage gibt es dafür nicht.",
section="fileshare",
),
# -- Terminal ----------------------------------------------------------- #
SettingSpec(
"terminal_switches_enabled",
True,
"bool",
"Browser-Konsole für Switche zulassen",
hint="Auch wenn aktiv: erfordert das Recht 'Ausführen' auf Switche, eine frische "
"Passwortbestätigung und wird vollständig protokolliert. Das Freigeben von "
"Host-Schlüsseln bleibt davon unberührt – es wird für den PoE-Neustart "
"gebraucht.",
section="monitoring",
),
SettingSpec(
"terminal_clients_enabled",
True,
"bool",
"Browser-Konsole für Clients zulassen",
hint="Braucht zusätzlich das Modul Wartung und das Recht 'Ausführen' auf Wartung. "
"Das Freigeben von Host-Schlüsseln bleibt davon unberührt.",
section="monitoring", section="monitoring",
), ),
SettingSpec( SettingSpec(
"terminal_idle_timeout", "terminal_idle_timeout",
300, 300,
"int", "int",
"Terminal bei Untaetigkeit trennen nach (Sekunden)", "Terminal bei Untätigkeit trennen nach (Sekunden)",
section="monitoring", section="monitoring",
minimum=60, minimum=60,
maximum=3600, maximum=3600,
@@ -149,10 +149,10 @@ sie getrennt von der Sicherung auf.', 'warning', 'Passphrase') }}
</div> </div>
<div class="card__body"> <div class="card__body">
<ul class="stack stack--tight fs-sm muted"> <ul class="stack stack--tight fs-sm muted">
<li>Verzeichniskonten werden nie exportiert -- sie entstehen am Ziel bei der ersten Anmeldung neu.</li> <li>Verzeichniskonten werden nie exportiert – sie entstehen am Ziel bei der ersten Anmeldung neu.</li>
<li>Lizenz- und Aktivierungszustand bleiben ausgenommen: sie gehören zur Maschine, nicht zur Sicherung.</li> <li>Lizenz- und Aktivierungszustand bleiben ausgenommen: sie gehören zur Maschine, nicht zur Sicherung.</li>
<li>Sitzungen, Ratenbegrenzung und das Änderungsprotokoll sind nicht Teil der Sicherung.</li> <li>Sitzungen, Ratenbegrenzung und das Änderungsprotokoll sind nicht Teil der Sicherung.</li>
<li>Der Zwischenstand des Imports liegt in der Datenbank -- ein Dienst-Neustall verliert ihn nicht.</li> <li>Der Zwischenstand des Imports liegt in der Datenbank – ein Dienst-Neustall verliert ihn nicht.</li>
</ul> </ul>
</div> </div>
</section> </section>
@@ -88,7 +88,7 @@
<div class="flex-1"> <div class="flex-1">
<div class="card__title">{{ icon('power', size=17) }} Neustarts</div> <div class="card__title">{{ icon('power', size=17) }} Neustarts</div>
<div class="card__sub"> <div class="card__sub">
Die letzten {{ events | length }} Neustarts -- über den PoE-Port wie per SSH Die letzten {{ events | length }} Neustarts – über den PoE-Port wie per SSH
</div> </div>
</div> </div>
</div> </div>
+1 -1
View File
@@ -72,7 +72,7 @@
<div class="flex-1"> <div class="flex-1">
<div class="card__title">{{ icon('network', size=17) }} Subnetze</div> <div class="card__title">{{ icon('network', size=17) }} Subnetze</div>
<div class="card__sub"> <div class="card__sub">
Subnetz und Maske kommen vom laufenden System -- eingegeben wird nur der Adressbereich. Subnetz und Maske kommen vom laufenden System – eingegeben wird nur der Adressbereich.
</div> </div>
</div> </div>
</div> </div>
+196 -32
View File
@@ -23,7 +23,7 @@
<div class="tabs"> <div class="tabs">
{% for mount in mounts %} {% for mount in mounts %}
<a class="tab {{ 'is-active' if mount.token == token else '' }}" <a class="tab {{ 'is-active' if mount.token == token else '' }}"
href="{{ url_for('fileshare.browse', freigabe=mount.token) }}">{{ mount.label }}</a> href="{{ url_for('fileshare.browse', share=mount.token) }}">{{ mount.label }}</a>
{% endfor %} {% endfor %}
</div> </div>
@@ -33,9 +33,9 @@
<div class="card__header"> <div class="card__header">
<div class="flex-1"> <div class="flex-1">
<nav class="breadcrumb"> <nav class="breadcrumb">
<span><a href="{{ url_for('fileshare.browse', freigabe=token) }}">{{ current.label }}</a></span> <span><a href="{{ url_for('fileshare.browse', share=token) }}">{{ current.label }}</a></span>
{% for crumb in listing.breadcrumb %} {% for crumb in listing.breadcrumb %}
<span><a href="{{ url_for('fileshare.browse', freigabe=token, pfad=crumb.relative) }}">{{ crumb.name }}</a></span> <span><a href="{{ url_for('fileshare.browse', share=token, path=crumb.relative) }}">{{ crumb.name }}</a></span>
{% endfor %} {% endfor %}
</nav> </nav>
<div class="card__sub mono fs-xs">{{ current.unc }}</div> <div class="card__sub mono fs-xs">{{ current.unc }}</div>
@@ -69,8 +69,8 @@
<tr> <tr>
{% if current_user.has('fileshare.delete') %}<td></td>{% endif %} {% if current_user.has('fileshare.delete') %}<td></td>{% endif %}
<td colspan="4"> <td colspan="4">
<a class="link-icon" href="{{ url_for('fileshare.browse', freigabe=token, <a class="link-icon" href="{{ url_for('fileshare.browse', share=token,
pfad=(listing.breadcrumb[-2].relative if listing.breadcrumb | length > 1 else '')) }}"> path=(listing.breadcrumb[-2].relative if listing.breadcrumb | length > 1 else '')) }}">
{{ icon('chevron-right', size=14) }} eine Ebene höher {{ icon('chevron-right', size=14) }} eine Ebene höher
</a> </a>
</td> </td>
@@ -79,11 +79,11 @@
{% for entry in listing.directories %} {% for entry in listing.directories %}
<tr> <tr>
{% if current_user.has('fileshare.delete') %} {% if current_user.has('fileshare.delete') %}
<td><label class="check"><input type="checkbox" name="pfade" value="{{ entry.relative }}"><span class="sr-only">auswählen</span></label></td> <td><label class="check"><input type="checkbox" name="paths" value="{{ entry.relative }}"><span class="sr-only">auswählen</span></label></td>
{% endif %} {% endif %}
<td> <td>
{{ icon('folder', size=15) }} {{ icon('folder', size=15) }}
<a href="{{ url_for('fileshare.browse', freigabe=token, pfad=entry.relative) }}">{{ entry.name }}</a> <a href="{{ url_for('fileshare.browse', share=token, path=entry.relative) }}">{{ entry.name }}</a>
</td> </td>
<td class="numeric faint">&ndash;</td> <td class="numeric faint">&ndash;</td>
<td class="fs-sm">{{ entry.modified | dt }}</td> <td class="fs-sm">{{ entry.modified | dt }}</td>
@@ -93,7 +93,7 @@
{% for entry in listing.files %} {% for entry in listing.files %}
<tr> <tr>
{% if current_user.has('fileshare.delete') %} {% if current_user.has('fileshare.delete') %}
<td><label class="check"><input type="checkbox" name="pfade" value="{{ entry.relative }}"><span class="sr-only">auswählen</span></label></td> <td><label class="check"><input type="checkbox" name="paths" value="{{ entry.relative }}"><span class="sr-only">auswählen</span></label></td>
{% endif %} {% endif %}
{# Der Name ist die grösste Fläche der Zeile -- er soll auch {# Der Name ist die grösste Fläche der Zeile -- er soll auch
das Naheliegende tun. Was sich anzeigen lässt, öffnet die das Naheliegende tun. Was sich anzeigen lässt, öffnet die
@@ -102,16 +102,27 @@
{% if entry.previewable %} {% if entry.previewable %}
<button class="link-icon file-name" type="button" <button class="link-icon file-name" type="button"
data-dialog-open="file-preview" data-dialog-open="file-preview"
data-fill-Src="{{ url_for('fileshare.preview', token=token, pfad=entry.relative) }}" data-fill-Src="{{ url_for('fileshare.preview', token=token, path=entry.relative) }}"
data-fill-Pruefung="{{ url_for('fileshare.signature_state', token=token, path=entry.relative) }}"
data-fill-Kind="{{ entry.preview_kind }}"
data-fill-Pfad="{{ entry.relative }}"
data-fill-Name="{{ entry.name }}" data-fill-Name="{{ entry.name }}"
data-fill-Download="{{ url_for('fileshare.download', token=token, pfad=entry.relative) }}"> {% if current_user.has('fileshare.export') %}data-fill-Download="{{ url_for('fileshare.download', token=token, path=entry.relative) }}"{% endif %}>
{{ icon('file-text', size=15) }} {{ entry.name }} {{ icon('file-text', size=15) }} {{ entry.name }}
</button> </button>
{% else %} {% else %}
<a class="link-icon" {% if current_user.has('fileshare.export') %}
href="{{ url_for('fileshare.download', token=token, pfad=entry.relative) }}"> <a class="link-icon"
{{ icon('file-text', size=15) }} {{ entry.name }} href="{{ url_for('fileshare.download', token=token, path=entry.relative) }}">
</a> {{ icon('file-text', size=15) }} {{ entry.name }}
</a>
{% else %}
{# Ohne Exportrecht bleibt der Name Text: es gibt hier
nichts zu holen, und ein toter Link waere Spott. #}
<span class="link-icon faint">
{{ icon('file-text', size=15) }} {{ entry.name }}
</span>
{% endif %}
{% endif %} {% endif %}
</td> </td>
<td class="numeric fs-sm">{{ entry.size | bytes }}</td> <td class="numeric fs-sm">{{ entry.size | bytes }}</td>
@@ -120,21 +131,27 @@
{% if entry.previewable %} {% if entry.previewable %}
<button class="btn btn--sm btn--ghost" type="button" <button class="btn btn--sm btn--ghost" type="button"
data-dialog-open="file-preview" data-dialog-open="file-preview"
data-fill-Src="{{ url_for('fileshare.preview', token=token, pfad=entry.relative) }}" data-fill-Src="{{ url_for('fileshare.preview', token=token, path=entry.relative) }}"
data-fill-Pruefung="{{ url_for('fileshare.signature_state', token=token, path=entry.relative) }}"
data-fill-Kind="{{ entry.preview_kind }}"
data-fill-Pfad="{{ entry.relative }}"
data-fill-Name="{{ entry.name }}" data-fill-Name="{{ entry.name }}"
data-fill-Download="{{ url_for('fileshare.download', token=token, pfad=entry.relative) }}" {% if current_user.has('fileshare.export') %}data-fill-Download="{{ url_for('fileshare.download', token=token, path=entry.relative) }}"{% endif %}
aria-label="Vorschau">{{ icon('eye', size=15) }}</button> aria-label="Vorschau">{{ icon('eye', size=15) }}</button>
{% endif %} {% endif %}
{% if current_user.has('fileshare.edit') %} {% if current_user.has('fileshare.edit') %}
<button class="btn btn--sm btn--ghost" type="button" <button class="btn btn--sm btn--ghost" type="button"
data-dialog-open="file-rename" data-dialog-open="file-rename"
data-fill-Pfad="{{ entry.relative }}" 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> aria-label="Umbenennen">{{ icon('pencil', size=15) }}</button>
{% endif %} {% endif %}
<a class="btn btn--sm btn--ghost" {% if current_user.has('fileshare.export') %}
href="{{ url_for('fileshare.download', token=token, pfad=entry.relative) }}" <a class="btn btn--sm btn--ghost"
aria-label="Herunterladen">{{ icon('download', size=15) }}</a> href="{{ url_for('fileshare.download', token=token, path=entry.relative) }}"
aria-label="Herunterladen">{{ icon('download', size=15) }}</a>
{% endif %}
</td> </td>
</tr> </tr>
{% endfor %} {% endfor %}
@@ -167,7 +184,7 @@
<form method="post" enctype="multipart/form-data" data-no-guard <form method="post" enctype="multipart/form-data" data-no-guard
action="{{ url_for('fileshare.upload', token=token) }}"> action="{{ url_for('fileshare.upload', token=token) }}">
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}"> <input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
<input type="hidden" name="pfad" value="{{ listing.relative }}"> <input type="hidden" name="path" value="{{ listing.relative }}">
<div class="card__body row"> <div class="card__body row">
<input class="input" type="file" name="files" multiple required style="flex:1"> <input class="input" type="file" name="files" multiple required style="flex:1">
<button class="btn btn--primary" type="submit" data-busy-label="Lädt ...">Hochladen</button> <button class="btn btn--primary" type="submit" data-busy-label="Lädt ...">Hochladen</button>
@@ -184,7 +201,7 @@
{% call modal('folder-new', 'Ordner anlegen', listing.relative or 'Wurzelverzeichnis', 'narrow') %} {% call modal('folder-new', 'Ordner anlegen', listing.relative or 'Wurzelverzeichnis', 'narrow') %}
<form method="post" action="{{ url_for('fileshare.make_directory', token=token) }}"> <form method="post" action="{{ url_for('fileshare.make_directory', token=token) }}">
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}"> <input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
<input type="hidden" name="pfad" value="{{ listing.relative }}"> <input type="hidden" name="path" value="{{ listing.relative }}">
<div class="modal__body">{{ field('name', 'Name', required=True) }}</div> <div class="modal__body">{{ field('name', 'Name', required=True) }}</div>
<div class="modal__footer"> <div class="modal__footer">
<button class="btn" type="button" data-dialog-close>Abbrechen</button> <button class="btn" type="button" data-dialog-close>Abbrechen</button>
@@ -201,15 +218,159 @@
Dialog für alle Dateien statt einer je Zeile. #} Dialog für alle Dateien statt einer je Zeile. #}
{% call modal('file-preview', 'Vorschau', '', 'wide') %} {% call modal('file-preview', 'Vorschau', '', 'wide') %}
<div class="modal__caption" data-fill-target="Name"></div> <div class="modal__caption" data-fill-target="Name"></div>
<div class="modal__body modal__body--flush"> {# Drei Anzeigen, eine je Art. Ein Rahmen für alles zeigte ein Foto vom
<iframe class="file-preview" title="Vorschau" data-fill-target="Src" data-fill-as="src" iPad in Originalgrösse (also einen Ausschnitt) und ein PDF auf iPadOS
src="about:blank" referrerpolicy="same-origin"></iframe> 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.view') 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 }}"
data-viewer-ordnerliste="{{ verzeichnisse | tojson }}">
<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.edit') %}
<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>
<div class="modal__footer"> <div class="modal__footer">
<a class="btn btn--sm" data-fill-target="Download" data-fill-as="href" href="#" download> {% if current_user.has('fileshare.export') %}
{{ icon('download', size=15) }} Herunterladen <a class="btn btn--sm" data-fill-target="Download" data-fill-as="href" href="#" download>
</a> {{ icon('download', size=15) }} Herunterladen
<button class="btn btn--primary" type="button" data-dialog-close>Schliessen</button> </a>
{% endif %}
{% if current_user.has('fileshare_pdf.create') %}
<button class="btn btn--sm" type="button" data-viewer-unterschreiben hidden>
{{ icon('pencil', size=15) }} Unterschreiben
</button>
<button class="btn btn--sm" type="button" data-viewer-speichern hidden
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> </div>
{% endcall %} {% endcall %}
{% endif %} {% endif %}
@@ -219,11 +380,14 @@
<form method="post" action="{{ url_for('fileshare.rename', token=token) }}"> <form method="post" action="{{ url_for('fileshare.rename', token=token) }}">
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}"> <input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
<input type="hidden" name="parent" value="{{ listing.relative }}"> <input type="hidden" name="parent" value="{{ listing.relative }}">
<input type="hidden" name="pfad" data-fill-target="Pfad" value=""> <input type="hidden" name="path" data-fill-target="Pfad" value="">
<div class="modal__body"> <div class="modal__body">
{{ field('name', 'Neuer Name', 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"> <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> </p>
</div> </div>
<div class="modal__footer"> <div class="modal__footer">
@@ -37,7 +37,7 @@ wird nie stillschweigend übernommen: das ist der Fall, der bei einem Angriff au
<div class="alert__title">Gelesener Schlüssel von {{ probed_host }}:{{ probed_port }}</div> <div class="alert__title">Gelesener Schlüssel von {{ probed_host }}:{{ probed_port }}</div>
<div class="mono fs-sm break-all">{{ probed_type }} {{ probed_fingerprint }}</div> <div class="mono fs-sm break-all">{{ probed_type }} {{ probed_fingerprint }}</div>
<p class="fs-sm mt-2"> <p class="fs-sm mt-2">
Vergleichen Sie den Fingerprint mit einer unabhängigen Quelle -- Konsolenzugang am Vergleichen Sie den Fingerprint mit einer unabhängigen Quelle | Konsolenzugang am
Gerät, Inventardokumentation --, bevor Sie ihn unten freigeben. Gerät, Inventardokumentation --, bevor Sie ihn unten freigeben.
</p> </p>
</div> </div>
@@ -145,7 +145,7 @@ wird nie stillschweigend übernommen: das ist der Fall, der bei einem Angriff au
<div class="flex-1"> <div class="flex-1">
<div class="card__title">{{ icon('alert', size=17) }} Verwaiste Freigaben</div> <div class="card__title">{{ icon('alert', size=17) }} Verwaiste Freigaben</div>
<div class="card__sub"> <div class="card__sub">
Zu diesen Einträgen gibt es kein Ziel mehr -- gelöscht oder mit neuer IP. Zu diesen Einträgen gibt es kein Ziel mehr – gelöscht oder mit neuer IP.
</div> </div>
</div> </div>
</div> </div>
@@ -125,7 +125,7 @@
{% if pending.code %} {% if pending.code %}
<div class="field"> <div class="field">
<span class="field__label"> <span class="field__label">
Anfragecode -- an den Anbieter übermitteln Anfragecode – an den Anbieter übermitteln
</span> </span>
<pre class="log-view" id="offline-request" style="max-height:14dvh">{{ pending.code }}</pre> <pre class="log-view" id="offline-request" style="max-height:14dvh">{{ pending.code }}</pre>
<div class="row row--end mt-2"> <div class="row row--end mt-2">
@@ -234,7 +234,7 @@
</dl> </dl>
<p class="fs-xs faint mt-3"> <p class="fs-xs faint mt-3">
Der Fingerprint stammt aus der stabilen Maschinenidentität. Eine Umbenennung des Hosts Der Fingerprint stammt aus der stabilen Maschinenidentität. Eine Umbenennung des Hosts
ändert ihn <strong>nicht</strong> mehr -- im Vorgänger führte genau das zu ändert ihn <strong>nicht</strong> mehr – im Vorgänger führte genau das zu
&bdquo;bereits anderweitig gebunden&ldquo;. &bdquo;bereits anderweitig gebunden&ldquo;.
</p> </p>
</div> </div>
@@ -1,6 +1,6 @@
{% extends "tesm_core/base.html" %} {% extends "tesm_core/base.html" %}
{% from "tesm_core/macros.html" import alert %} {% from "tesm_core/macros.html" import alert %}
{% block page_title %}Kea-DHCP-Protokoll{% endblock %} {% block page_title %}DHCP-Protokoll{% endblock %}
{% block page_sub %}{{ path }}{% endblock %} {% block page_sub %}{{ path }}{% endblock %}
{% block content %} {% block content %}
{% if error %}{{ alert(error, 'warning') }}{% endif %} {% if error %}{{ alert(error, 'warning') }}{% endif %}
@@ -15,7 +15,7 @@ Der Vorgänger protokollierte Neustarts ausdrücklich nicht.', 'info') }}
<div class="card__sub"> <div class="card__sub">
{% for value, label in methods %} {% for value, label in methods %}
<a class="btn btn--sm {{ 'btn--ghost' if method != value else '' }}" <a class="btn btn--sm {{ 'btn--ghost' if method != value else '' }}"
href="{{ url_for('tesmlogs.restarts', methode=value) }}">{{ label }}</a> href="{{ url_for('tesmlogs.restarts', method=value) }}">{{ label }}</a>
{% endfor %} {% endfor %}
<a class="btn btn--sm {{ 'btn--ghost' if method else '' }}" <a class="btn btn--sm {{ 'btn--ghost' if method else '' }}"
href="{{ url_for('tesmlogs.restarts') }}">Alle</a> href="{{ url_for('tesmlogs.restarts') }}">Alle</a>
@@ -151,7 +151,7 @@ Druckerfreigabe. Der PoE-Neustart ist davon getrennt: er schaltet den Switch-Por
<div class="flex-1"> <div class="flex-1">
<div class="card__title">{{ icon('clock', size=17) }} Aufträge</div> <div class="card__title">{{ icon('clock', size=17) }} Aufträge</div>
<div class="card__sub"> <div class="card__sub">
Zustand liegt in der Datenbank -- ein Dienst-Neustart verliert ihn nicht mehr Zustand liegt in der Datenbank – ein Dienst-Neustart verliert ihn nicht mehr
</div> </div>
</div> </div>
</div> </div>
@@ -194,7 +194,7 @@ Druckerfreigabe. Der PoE-Neustart ist davon getrennt: er schaltet den Switch-Por
{% block scripts %} {% block scripts %}
<script type="module" nonce="{{ csp_nonce }}"> <script type="module" nonce="{{ csp_nonce }}">
// Auswahl beim Laden bewusst zurücksetzen: Browser stellen den Checked-Zustand // Auswahl beim Laden bewusst zurücksetzen: Browser stellen den Checked-Zustand
// sonst aus dem Verlauf wieder her -- und dann wird bei "Neustarten" // sonst aus dem Verlauf wieder her – und dann wird bei "Neustarten"
// versehentlich das falsche Gerät erwischt. // versehentlich das falsche Gerät erwischt.
const boxes = Array.from(document.querySelectorAll(".device-select")); const boxes = Array.from(document.querySelectorAll(".device-select"));
boxes.forEach((box) => (box.checked = false)); boxes.forEach((box) => (box.checked = false));
@@ -211,7 +211,7 @@ Druckerfreigabe. Der PoE-Neustart ist davon getrennt: er schaltet den Switch-Por
const state = await window.tesm.api("{{ url_for('maintenance.status') }}"); const state = await window.tesm.api("{{ url_for('maintenance.status') }}");
if (state.running) window.setTimeout(() => location.reload(), state.poll_ms); if (state.running) window.setTimeout(() => location.reload(), state.poll_ms);
} catch { } catch {
/* stumm bleiben -- die Seite ist auch ohne Aktualisierung nutzbar */ /* stumm bleiben – die Seite ist auch ohne Aktualisierung nutzbar */
} }
} }
poll(); poll();
@@ -1,5 +1,5 @@
{% extends "tesm_core/base.html" %} {% extends "tesm_core/base.html" %}
{% from "tesm_core/macros.html" import field, select_field, checkbox, alert, pill %} {% from "tesm_core/macros.html" import field, select_field, checkbox, alert, pill, nav_order_card %}
{% block page_title %}Systemeinstellungen{% endblock %} {% block page_title %}Systemeinstellungen{% endblock %}
{% block page_sub %}Überwachung, Protokolle, Host, Navigation{% endblock %} {% block page_sub %}Überwachung, Protokolle, Host, Navigation{% endblock %}
@@ -54,18 +54,23 @@
<section class="card"> <section class="card">
<div class="card__header"> <div class="card__header">
<div class="flex-1"> <div class="flex-1">
<div class="card__title">{{ icon('terminal', size=17) }} Switch-Konsole</div> <div class="card__title">{{ icon('terminal', size=17) }} Terminal</div>
<div class="card__sub"> <div class="card__sub">
Auch bei aktiver Konsole gilt: eigenes Recht, frische Passwortbestätigung, Auch bei aktiver Konsole gilt: eigenes Recht, frische Passwortbestätigung,
vollständige Mitschrift vollständige Mitschrift. Abgeschaltet bleibt das Freigeben von
SSH-Host-Schlüsseln möglich – es wird für den PoE-Neustart gebraucht.
</div> </div>
</div> </div>
</div> </div>
<form method="post" action="{{ url_for('settings.system_save') }}"> <form method="post" action="{{ url_for('settings.system_save') }}">
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}"> <input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
<div class="card__body stack"> <div class="card__body stack">
{{ checkbox('terminal_enabled', 'Browser-Konsole für Switche zulassen', {{ checkbox('terminal_switches_enabled', 'Browser-Konsole für Switche zulassen',
checked=values.terminal_enabled) }} checked=values.terminal_switches_enabled,
hint_text=specs['terminal_switches_enabled'].hint) }}
{{ checkbox('terminal_clients_enabled', 'Browser-Konsole für Clients zulassen',
checked=values.terminal_clients_enabled,
hint_text=specs['terminal_clients_enabled'].hint) }}
{{ field('terminal_idle_timeout', 'Trennen bei Untätigkeit (Sekunden)', {{ field('terminal_idle_timeout', 'Trennen bei Untätigkeit (Sekunden)',
value=values.terminal_idle_timeout, type='number') }} value=values.terminal_idle_timeout, type='number') }}
</div> </div>
@@ -73,6 +78,60 @@
</form> </form>
</section> </section>
<section class="card">
<div class="card__header">
<div class="flex-1">
<div class="card__title">{{ icon('pencil', size=17) }} Unterschriften</div>
<div class="card__sub">
Der Name in der Unterschrift ist eine Angabe des Unterschreibenden – wie auf
Papier. Wer das Dokument abgelegt hat, ist dagegen belegt: in den
Dokumenteigenschaften und im Änderungsprotokoll, auf Wunsch zusätzlich
sichtbar unter der ersten Unterschrift.
</div>
</div>
</div>
<form method="post" action="{{ url_for('settings.system_save') }}">
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
<div class="card__body stack">
{{ checkbox('signature_witness_enabled', 'Unterschriften mit dem ablegenden Konto bezeugen',
checked=values.signature_witness_enabled,
hint_text=specs['signature_witness_enabled'].hint) }}
</div>
<div class="card__footer"><button class="btn btn--primary" type="submit">Speichern</button></div>
</form>
</section>
{# Eigene Karte: die Abmeldung stand zwischen Protokollrotation und
Papierkorb -- drei Dinge ohne Zusammenhang. Wer die Sitzungsdauer sucht,
sucht sie nicht unter "Protokolle". #}
<section class="card">
<div class="card__header">
<div class="flex-1">
<div class="card__title">{{ icon('clock', size=17) }} Sitzungen</div>
<div class="card__sub">
Wie lange eine Anmeldung gilt. Beides bestimmt auch, wie lange eine
eingebundene Dateifreigabe bestehen bleibt – sie endet mit der Sitzung.
</div>
</div>
</div>
<form method="post" action="{{ url_for('settings.system_save') }}">
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
<div class="card__body">
<div class="form-grid">
{{ field('session_idle_minutes', 'Automatische Abmeldung nach Leerlauf (Minuten)',
value=values.session_idle_minutes, type='number',
attrs='min="5" max="1440" step="1"',
hint_text=specs['session_idle_minutes'].hint) }}
{{ field('session_absolute_hours', 'Längstmögliche Sitzungsdauer (Stunden)',
value=values.session_absolute_hours, type='number',
attrs='min="1" max="168" step="1"',
hint_text=specs['session_absolute_hours'].hint) }}
</div>
</div>
<div class="card__footer"><button class="btn btn--primary" type="submit">Speichern</button></div>
</form>
</section>
<section class="card"> <section class="card">
<div class="card__header"> <div class="card__header">
<div class="flex-1"> <div class="flex-1">
@@ -87,9 +146,6 @@
specs['log_rotate_interval'].choices, selected=values.log_rotate_interval) }} specs['log_rotate_interval'].choices, selected=values.log_rotate_interval) }}
{{ field('log_rotate_keep', 'Aufbewahrte Rotationen', value=values.log_rotate_keep, {{ field('log_rotate_keep', 'Aufbewahrte Rotationen', value=values.log_rotate_keep,
type='number') }} type='number') }}
{{ field('session_idle_minutes', 'Automatische Abmeldung (Minuten)',
value=values.session_idle_minutes, type='number',
hint_text=specs['session_idle_minutes'].hint) }}
{{ field('trash_retention_days', 'Papierkorb-Aufbewahrung (Tage)', {{ field('trash_retention_days', 'Papierkorb-Aufbewahrung (Tage)',
value=values.trash_retention_days, type='number', value=values.trash_retention_days, type='number',
hint_text=specs['trash_retention_days'].hint) }} hint_text=specs['trash_retention_days'].hint) }}
@@ -133,39 +189,8 @@
</section> </section>
{% if current_user.is_admin %} {% if current_user.is_admin %}
<section class="card"> {{ nav_order_card(nav_items, nav_order, nav_child_order,
<div class="card__header"> url_for('admin.nav_order'), csrf_field_name, csrf_token()) }}
<div class="flex-1">
<div class="card__title">{{ icon('sliders', size=17) }} Navigationsreihenfolge</div>
<div class="card__sub">Gilt für alle Benutzer; jeder sieht weiter nur, was er darf</div>
</div>
</div>
<form method="post" action="{{ url_for('admin.nav_order') }}">
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
<div class="card__body">
<ol class="stack stack--tight">
{% for key in nav_order %}
{% set item = nav_items | selectattr('key', 'equalto', key) | list | first %}
{% if item %}
<li class="row row--tight">
<input class="input" type="number" name="position_{{ item.key }}" value="{{ loop.index }}"
min="1" style="width:70px" disabled>
<input type="hidden" name="order" value="{{ item.key }}">
<span class="flex-1">{{ icon(item.icon, size=15) }} {{ item.label }}</span>
</li>
{% endif %}
{% endfor %}
</ol>
<p class="fs-xs faint mt-3">
Die Reihenfolge folgt der Liste. Zum Umsortieren die Einträge in der gewünschten
Reihenfolge speichern (Drag-and-drop folgt in einer späteren Version).
</p>
</div>
<div class="card__footer">
<button class="btn btn--sm" type="submit">Reihenfolge speichern</button>
</div>
</form>
</section>
{% endif %} {% endif %}
</div> </div>
</div> </div>
+7 -7
View File
@@ -36,7 +36,7 @@ class MonitorDaemon:
self.running = True self.running = True
def stop(self, signum: int, _frame: FrameType | None) -> None: def stop(self, signum: int, _frame: FrameType | None) -> None:
logger.info("Signal %s empfangen -- beende nach dem laufenden Durchlauf.", signum) logger.info("Signal %s empfangen – beende nach dem laufenden Durchlauf.", signum)
self.running = False self.running = False
# ------------------------------------------------------------------ # # ------------------------------------------------------------------ #
@@ -68,8 +68,8 @@ class MonitorDaemon:
def run(self) -> int: def run(self) -> int:
live = self.extension.registry["live_log"] live = self.extension.registry["live_log"]
live.info("monitor", "Ueberwachungsprozess gestartet") live.info("monitor", "Überwachungsprozess gestartet")
logger.info("Ueberwachung laeuft, Datenbank: %s", self.extension.config.db_path) logger.info("Überwachung läuft, Datenbank: %s", self.extension.config.db_path)
while self.running: while self.running:
interval = self._interval() interval = self._interval()
@@ -84,8 +84,8 @@ class MonitorDaemon:
failures_before_restart=self._failures_before_restart(), failures_before_restart=self._failures_before_restart(),
) )
logger.info( logger.info(
"Durchlauf: %s geprueft, %s online, %s offline, %s Neustarts (%s erfolgreich), " "Durchlauf: %s geprüft, %s online, %s offline, %s Neustarts (%s erfolgreich), "
"%s ohne Lizenz uebersprungen, %sms", "%s ohne Lizenz übersprungen, %sms",
report.checked, report.checked,
report.online, report.online,
report.offline, report.offline,
@@ -96,7 +96,7 @@ class MonitorDaemon:
) )
except Exception: # noqa: BLE001 - der Dienst darf nie sterben except Exception: # noqa: BLE001 - der Dienst darf nie sterben
logger.exception("Durchlauf fehlgeschlagen") logger.exception("Durchlauf fehlgeschlagen")
live.error("monitor", "Durchlauf fehlgeschlagen -- siehe app.log") live.error("monitor", "Durchlauf fehlgeschlagen – siehe app.log")
# Das Aufraeumen abgelaufener Freigabe-Einbindungen laeuft **nicht** # Das Aufraeumen abgelaufener Freigabe-Einbindungen laeuft **nicht**
# hier, sondern im Webprozess: eingebunden wird in dessen # hier, sondern im Webprozess: eingebunden wird in dessen
@@ -112,7 +112,7 @@ class MonitorDaemon:
time.sleep(min(1.0, remaining)) time.sleep(min(1.0, remaining))
remaining -= 1.0 remaining -= 1.0
live.info("monitor", "Ueberwachungsprozess beendet") live.info("monitor", "Überwachungsprozess beendet")
return 0 return 0
+193 -20
View File
@@ -28,6 +28,9 @@ set -euo pipefail
APP="" APP=""
INSTANCE="" INSTANCE=""
#: Verzeichnis der Umgebungsdateien. Ueberschreibbar, damit sich die
#: Portsuche in einem Test wirklich ausfuehren laesst.
ETC_TESM="${TESM_ETC_DIR:-/etc/tesm}"
PORT="" PORT=""
HTTP_PORT="" HTTP_PORT=""
SKIP_PACKAGES=0 SKIP_PACKAGES=0
@@ -46,6 +49,62 @@ warn() { printf '%s !!%s %s\n' "$COLOR_WARN" "$COLOR_OFF" "$*"; }
fail() { printf '%s !!%s %s\n' "$COLOR_ERR" "$COLOR_OFF" "$*" >&2; exit 1; } fail() { printf '%s !!%s %s\n' "$COLOR_ERR" "$COLOR_OFF" "$*" >&2; exit 1; }
info() { printf ' %s\n' "$*"; } info() { printf ' %s\n' "$*"; }
#: 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() { usage() {
cat <<'USAGE' cat <<'USAGE'
Verwendung: install.sh --app <tesm|tesm-license> [Optionen] Verwendung: install.sh --app <tesm|tesm-license> [Optionen]
@@ -109,16 +168,35 @@ LOG_DIR="/var/log/$NAME"
SERVICE_USER="$APP" SERVICE_USER="$APP"
UNIT="$NAME.service" UNIT="$NAME.service"
MONITOR_UNIT="$NAME-monitor.service" MONITOR_UNIT="$NAME-monitor.service"
CLEANUP_UNIT="$NAME-cleanup.service"
CLEANUP_TIMER="$NAME-cleanup.timer"
NGINX_SITE="/etc/nginx/sites-available/$NAME" NGINX_SITE="/etc/nginx/sites-available/$NAME"
ENV_PREFIX="TESM_" ENV_PREFIX="TESM_"
[[ "$APP" == "tesm-license" ]] && ENV_PREFIX="TESM_LICENSE_" [[ "$APP" == "tesm-license" ]] && ENV_PREFIX="TESM_LICENSE_"
# 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 # Bei einer Aktualisierung gilt zuerst, was schon eingerichtet ist. Sonst
# verschiebt ein Update ohne Portangaben die Anwendung: eine benannte Instanz # verschiebt ein Update ohne Portangaben die Anwendung: eine benannte Instanz
# faellt auf 8080/8443 zurueck, auch wenn sie seit der Installation auf Port 80 # 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 # lief -- und ein Reverse Proxy davor zeigt dann ins Leere. Dieselbe Regel wie
# beim Secure-Flag weiter unten: eine Aktualisierung nimmt nichts weg, was # beim Secure-Flag weiter unten: eine Aktualisierung nimmt nichts weg, was
# nicht ausdruecklich neu gesetzt wird. # 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 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)" PORT="$(sed -n "s/^${ENV_PREFIX}BIND=127\.0\.0\.1:\([0-9]\{1,5\}\)$/\1/p" "$ENV_FILE" | head -1)"
fi fi
@@ -127,10 +205,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)" [[ -z "$HTTPS_PORT" ]] && HTTPS_PORT="$(sed -n 's/^[[:space:]]*listen[[:space:]]\([0-9]\{1,5\}\)[[:space:]]\+ssl.*/\1/p' "$NGINX_SITE" | head -1)"
fi 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 if [[ -z "$PORT" ]]; then
PORT=5000 BASISPORT=5000
[[ "$APP" == "tesm-license" ]] && PORT=5001 [[ "$APP" == "tesm-license" ]] && BASISPORT=5001
[[ -n "$INSTANCE" ]] && PORT=$((PORT + 100)) 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 fi
if [[ -z "$HTTP_PORT" ]]; then if [[ -z "$HTTP_PORT" ]]; then
HTTP_PORT=80 HTTP_PORT=80
@@ -212,12 +310,41 @@ fi
# Der Vorgaenger lief als root. Hier laeuft der Dienst unprivilegiert; Rootrechte # Der Vorgaenger lief als root. Hier laeuft der Dienst unprivilegiert; Rootrechte
# gibt es ausschliesslich ueber den Helfer. # 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 ------------------------------------------------------------------ # -- Backup ------------------------------------------------------------------
if [[ $IS_UPDATE -eq 1 ]]; then if [[ $IS_UPDATE -eq 1 ]]; then
step "Sicherung vor der Aktualisierung" step "Sicherung vor der Aktualisierung"
systemctl stop "$UNIT" 2>/dev/null || true systemctl stop "$UNIT" 2>/dev/null || true
[[ "$APP" == "tesm" ]] && systemctl stop "$MONITOR_UNIT" 2>/dev/null || true [[ "$APP" == "tesm" ]] && systemctl stop "$MONITOR_UNIT" 2>/dev/null || true
cp -a "$ROOT" "$BACKUP_DIR" cp -a "$ROOT" "$BACKUP_DIR"
SICHERUNG_STEHT=1
info "Sicherung: $BACKUP_DIR" info "Sicherung: $BACKUP_DIR"
fi fi
@@ -347,8 +474,8 @@ step "Umgebung"
# EnvironmentFile, die Kommandozeilenwerkzeuge ueber ihren Wrapper. Ohne das # EnvironmentFile, die Kommandozeilenwerkzeuge ueber ihren Wrapper. Ohne das
# wuesste "tesm-admin" nicht, wo die Instanz liegt, und wuerde sich eine leere # wuesste "tesm-admin" nicht, wo die Instanz liegt, und wuerde sich eine leere
# Datenbank im Arbeitsverzeichnis anlegen. # Datenbank im Arbeitsverzeichnis anlegen.
install -d -o root -g root -m 0755 /etc/tesm install -d -o root -g root -m 0755 "$ETC_TESM"
ENV_FILE="/etc/tesm/$NAME.env" ENV_FILE="$ETC_TESM/$NAME.env"
TMP_ENV="$(mktemp)" TMP_ENV="$(mktemp)"
{ {
echo "# Von install.sh erzeugt fuer $NAME -- nicht von Hand bearbeiten." echo "# Von install.sh erzeugt fuer $NAME -- nicht von Hand bearbeiten."
@@ -414,6 +541,11 @@ render_unit "$ROOT/deploy/systemd/$APP.service" "/etc/systemd/system/$UNIT"
if [[ "$APP" == "tesm" ]]; then if [[ "$APP" == "tesm" ]]; then
render_unit "$ROOT/deploy/systemd/tesm-monitor.service" "/etc/systemd/system/$MONITOR_UNIT" render_unit "$ROOT/deploy/systemd/tesm-monitor.service" "/etc/systemd/system/$MONITOR_UNIT"
fi fi
# Aufraeumen lief bisher nur von Hand -- also praktisch nie. Auf einer
# laufenden Installation lagen deshalb hunderte abgelaufene Sitzungszeilen in
# der Datenbank, und das Aenderungsprotokoll waere nie archiviert worden.
render_unit "$ROOT/deploy/systemd/$APP-cleanup.service" "/etc/systemd/system/$CLEANUP_UNIT"
render_unit "$ROOT/deploy/systemd/$APP-cleanup.timer" "/etc/systemd/system/$CLEANUP_TIMER"
[[ $UNITS_CHANGED -eq 1 ]] && systemctl daemon-reload && info "Units neu geladen" \ [[ $UNITS_CHANGED -eq 1 ]] && systemctl daemon-reload && info "Units neu geladen" \
|| info "Units unveraendert" || info "Units unveraendert"
@@ -486,12 +618,18 @@ systemctl enable --now "$UNIT" >/dev/null
if [[ "$APP" == "tesm" ]]; then if [[ "$APP" == "tesm" ]]; then
systemctl enable --now "$MONITOR_UNIT" >/dev/null systemctl enable --now "$MONITOR_UNIT" >/dev/null
fi fi
# Der Timer, nicht der Dienst: der Dienst laeuft einmal und ist fertig.
systemctl enable --now "$CLEANUP_TIMER" >/dev/null 2>&1 && info "Aufraeumen: taeglich 03:30" || info "Aufraeum-Timer nicht aktiviert -- 'systemctl status $CLEANUP_TIMER' zeigt warum"
# -- Health-Check und Rollback ---------------------------------------------- # -- Health-Check und Rollback ----------------------------------------------
step "Gesundheitspruefung" step "Gesundheitspruefung"
HEALTHY=0 HEALTHY=0
for attempt in $(seq 1 20); do 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 HEALTHY=1
info "Antwort nach ${attempt}x" info "Antwort nach ${attempt}x"
break break
@@ -502,13 +640,9 @@ done
if [[ $HEALTHY -eq 0 ]]; then if [[ $HEALTHY -eq 0 ]]; then
warn "Die Anwendung antwortet nicht." warn "Die Anwendung antwortet nicht."
if [[ $IS_UPDATE -eq 1 ]]; then if [[ $IS_UPDATE -eq 1 ]]; then
warn "Rollback wird ausgefuehrt." # Dieselbe Funktion wie die EXIT-Falle -- zwei Wege, die dasselbe tun
systemctl stop "$UNIT" 2>/dev/null || true # sollen, laufen sonst irgendwann auseinander.
[[ "$APP" == "tesm" ]] && systemctl stop "$MONITOR_UNIT" 2>/dev/null || true zurueckrollen_install
mv "$ROOT" "$FAILED_DIR"
cp -a "$BACKUP_DIR" "$ROOT"
systemctl start "$UNIT" || true
[[ "$APP" == "tesm" ]] && systemctl start "$MONITOR_UNIT" || true
fail "Aktualisierung fehlgeschlagen. Alter Stand wiederhergestellt. fail "Aktualisierung fehlgeschlagen. Alter Stand wiederhergestellt.
Fehlgeschlagene Version : $FAILED_DIR Fehlgeschlagene Version : $FAILED_DIR
Sicherung : $BACKUP_DIR Sicherung : $BACKUP_DIR
@@ -568,12 +702,41 @@ if [[ $HTTPS -eq 1 ]]; then
fi fi
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 if [[ $IS_UPDATE -eq 1 ]]; then
rm -rf "$BACKUP_DIR" rm -rf "$BACKUP_DIR"
info "Sicherung entfernt (Aktualisierung erfolgreich)" info "Sicherung entfernt (Aktualisierung erfolgreich)"
fi fi
# -- Abschluss --------------------------------------------------------------- # -- Abschluss ---------------------------------------------------------------
INSTALL_FERTIG=1
step "Fertig" step "Fertig"
ADMIN_CMD="tesm-admin" ADMIN_CMD="tesm-admin"
[[ "$APP" == "tesm-license" ]] && ADMIN_CMD="tesm-license-admin" [[ "$APP" == "tesm-license" ]] && ADMIN_CMD="tesm-license-admin"
@@ -589,14 +752,24 @@ cat <<SUMMARY
Naechste Schritte: Naechste Schritte:
SUMMARY SUMMARY
if [[ "$APP" == "tesm-license" ]]; then if [[ "$APP" == "tesm-license" && "$EDITION" == "master" ]]; then
cat <<SUMMARY cat <<SUMMARY
1. Signaturschluessel erzeugen und sofort sichern: 1. Administrator anlegen:
sudo -u $SERVICE_USER $NAME-admin init-key
2. Eigene Master-Lizenz ausstellen:
sudo -u $SERVICE_USER $NAME-admin bootstrap
3. Administrator anlegen:
sudo -u $SERVICE_USER $NAME-admin create-admin 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 SUMMARY
else else
cat <<SUMMARY cat <<SUMMARY
+12 -3
View File
@@ -14,12 +14,21 @@ TAG="${1:?Verwendung: release.sh vX.Y.Z [name]}"
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
REPO_DIR="$(cd "$SCRIPT_DIR/.." && 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 if [[ -n "${2:-}" ]]; then
NAME="$2" NAME="$2"
else else
mapfile -t APPS < <(ls -1 "$REPO_DIR/apps" 2>/dev/null || true) HERKUNFT="$(git -C "$REPO_DIR" config --get remote.origin.url 2>/dev/null || true)"
if [[ ${#APPS[@]} -eq 1 ]]; then NAME="${APPS[0]}"; else NAME="$(basename "$REPO_DIR")"; fi 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 fi
OUT="$NAME-$TAG.tar.gz" OUT="$NAME-$TAG.tar.gz"
+3 -3
View File
@@ -3,8 +3,8 @@
# Benennt eine Installation um -- aus einer Instanz wird die Hauptinstallation # Benennt eine Installation um -- aus einer Instanz wird die Hauptinstallation
# (oder umgekehrt). # (oder umgekehrt).
# #
# sudo bash deploy/rename.sh --app tesm --from tesm-opus --to tesm # sudo bash deploy/rename.sh --app tesm --from tesm-test --to tesm
# sudo bash deploy/rename.sh --app tesm-license --from tesm-license-opus \ # sudo bash deploy/rename.sh --app tesm-license --from tesm-license-test \
# --to tesm-license # --to tesm-license
# #
# Der Name steckt an mehr Stellen, als man beim Verschieben eines Verzeichnisses # 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] Verwendung: rename.sh --app <tesm|tesm-license> --from <alt> --to <neu> [Optionen]
--app <name> Welche Anwendung (Pflicht) --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) --to <name> Neuer Installationsname, z.B. tesm (Pflicht)
--suffix <text> Namenszusatz fuer eine beiseite geschobene Installation --suffix <text> Namenszusatz fuer eine beiseite geschobene Installation
der Fassung 1.x (Vorgabe: sonnet5) der Fassung 1.x (Vorgabe: sonnet5)
+7 -1
View File
@@ -5,5 +5,11 @@
# #
# Bewusst KEIN "ALL=(ALL) NOPASSWD: ALL". Der Vorgaenger loeste dasselbe # Bewusst KEIN "ALL=(ALL) NOPASSWD: ALL". Der Vorgaenger loeste dasselbe
# Problem, indem die gesamte Anwendung als root lief. # 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 tesm ALL=(root) NOPASSWD: /usr/local/lib/tesm/tesm-helper
+7 -1
View File
@@ -5,5 +5,11 @@
# #
# Bewusst KEIN "ALL=(ALL) NOPASSWD: ALL". Der Vorgaenger loeste dasselbe # Bewusst KEIN "ALL=(ALL) NOPASSWD: ALL". Der Vorgaenger loeste dasselbe
# Problem, indem die gesamte Anwendung als root lief. # 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 tesm-license ALL=(root) NOPASSWD: /usr/local/lib/tesm/tesm-helper
+34
View File
@@ -0,0 +1,34 @@
[Unit]
Description=TESM -- Aufraeumen: Sitzungen, Ratenereignisse, Protokollarchiv
After=tesm.service
# Ohne die laufende Anwendung ist nichts kaputt -- der Befehl arbeitet direkt
# auf der Datenbank. "After" sorgt nur fuer eine sinnvolle Reihenfolge beim
# Hochfahren.
[Service]
Type=oneshot
User=tesm
Group=tesm
WorkingDirectory=/srv/tesm
Environment=TESM_BASE_DIR=/srv/tesm
Environment=TESM_DATA_DIR=/srv/tesm/data
Environment=TESM_LOG_DIR=/var/log/tesm
Environment=PYTHONUNBUFFERED=1
ExecStart=/srv/tesm/venv/bin/tesm-admin housekeeping
# Hier darf voll gehaertet werden: dieser Aufruf fasst nur die Datenbank und
# das Protokollverzeichnis an, keine Systemdateien und keinen Helfer per sudo.
NoNewPrivileges=yes
PrivateTmp=yes
ProtectSystem=strict
ProtectHome=yes
ProtectKernelTunables=yes
ProtectKernelModules=yes
ProtectControlGroups=yes
RestrictSUIDSGID=yes
RestrictNamespaces=yes
RestrictAddressFamilies=AF_UNIX
LockPersonality=yes
SystemCallArchitectures=native
UMask=0027
ReadWritePaths=/srv/tesm/data /var/log/tesm
+18
View File
@@ -0,0 +1,18 @@
[Unit]
Description=TESM -- taegliches Aufraeumen
[Timer]
# Nachts, wenn niemand arbeitet: abgelaufene Sitzungen, alte Ratenereignisse
# und die Archivierung des Aenderungsprotokolls.
#
# Vorher lief das nur, wenn jemand "tesm-admin housekeeping" aufrief oder in
# der Diagnose darauf klickte -- also praktisch nie. Gemessen auf einer
# Installation nach vier Wochen: 747 Sitzungszeilen, 682 davon abgelaufen.
OnCalendar=*-*-* 03:30:00
# Streuung, damit nicht jede Installation im Netz zur selben Sekunde loslegt.
RandomizedDelaySec=15m
# Nachgeholt, wenn die Maschine nachts aus war.
Persistent=true
[Install]
WantedBy=timers.target
@@ -0,0 +1,34 @@
[Unit]
Description=TESM-Lizenzserver -- Aufraeumen: Sitzungen, Ratenereignisse, Protokollarchiv
After=tesm-license.service
# Ohne die laufende Anwendung ist nichts kaputt -- der Befehl arbeitet direkt
# auf der Datenbank. "After" sorgt nur fuer eine sinnvolle Reihenfolge beim
# Hochfahren.
[Service]
Type=oneshot
User=tesm-license
Group=tesm-license
WorkingDirectory=/srv/tesm-license
Environment=TESM_LICENSE_BASE_DIR=/srv/tesm-license
Environment=TESM_LICENSE_DATA_DIR=/srv/tesm-license/data
Environment=TESM_LICENSE_LOG_DIR=/var/log/tesm-license
Environment=PYTHONUNBUFFERED=1
ExecStart=/srv/tesm-license/venv/bin/tesm-license-admin housekeeping
# Hier darf voll gehaertet werden: dieser Aufruf fasst nur die Datenbank und
# das Protokollverzeichnis an, keine Systemdateien und keinen Helfer per sudo.
NoNewPrivileges=yes
PrivateTmp=yes
ProtectSystem=strict
ProtectHome=yes
ProtectKernelTunables=yes
ProtectKernelModules=yes
ProtectControlGroups=yes
RestrictSUIDSGID=yes
RestrictNamespaces=yes
RestrictAddressFamilies=AF_UNIX
LockPersonality=yes
SystemCallArchitectures=native
UMask=0027
ReadWritePaths=/srv/tesm-license/data /var/log/tesm-license
+18
View File
@@ -0,0 +1,18 @@
[Unit]
Description=TESM-Lizenzserver -- taegliches Aufraeumen
[Timer]
# Nachts, wenn niemand arbeitet: abgelaufene Sitzungen, alte Ratenereignisse
# und die Archivierung des Aenderungsprotokolls.
#
# Vorher lief das nur, wenn jemand "tesm-license-admin housekeeping" aufrief oder in
# der Diagnose darauf klickte -- also praktisch nie. Gemessen auf einer
# Installation nach vier Wochen: 747 Sitzungszeilen, 682 davon abgelaufen.
OnCalendar=*-*-* 03:30:00
# Streuung, damit nicht jede Installation im Netz zur selben Sekunde loslegt.
RandomizedDelaySec=15m
# Nachgeholt, wenn die Maschine nachts aus war.
Persistent=true
[Install]
WantedBy=timers.target
+1 -1
View File
@@ -47,7 +47,7 @@ check_in_list() {
} }
# Name einer Installation auf diesem Host: entweder die Hauptinstallation # 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 # beiden Formen gibt es nicht -- so kann keine erfundene Zeichenkette einen
# Pfad ausserhalb des vorgesehenen Namensraums erzeugen. # Pfad ausserhalb des vorgesehenen Namensraums erzeugen.
check_app_key() { check_app_key() {
+5 -8
View File
@@ -34,15 +34,12 @@ RESET = "\033[0m"
#: Dieselbe Datei liest die Anwendung (``services/kea.py``: ``LEASE_FILE``). #: Dieselbe Datei liest die Anwendung (``services/kea.py``: ``LEASE_FILE``).
LEASE_FILE = "/var/lib/kea/kea-leases4.csv" LEASE_FILE = "/var/lib/kea/kea-leases4.csv"
#: Zeile unter der Wortmarke. Wortlaut wie in der Anwendung #: Zeile unter der Wortmarke. Wortlaut wie in der jeweiligen Anwendung
#: (``apps/tesm/src/tesm/__init__.py``: ``AUTH_TAGLINE``) -- ein Hygienetest #: (``AUTH_TAGLINE`` in deren ``__init__.py``) -- ein Hygienetest haelt beides
#: haelt beides zusammen. #: zusammen.
#:
#: Der Lizenzserver hat bewusst keine: die Funktionsliste von TESM waere dort
#: falsch, und eine erfundene waere schlimmer als keine.
TAGLINE = { TAGLINE = {
"tesm": "UEBERWACHEN · BOOTEN · ANBINDEN (DHCP) · BETRIEBSSYSTEM GEBEN (PXE)", "tesm": "ÜBERWACHEN · BOOTEN · ANBINDEN",
"tesm-license": "", "tesm-license": "LIZENZIEREN",
} }
TITEL = {"tesm": "TimEShepManager", "tesm-license": "Lizenzserver"} TITEL = {"tesm": "TimEShepManager", "tesm-license": "Lizenzserver"}
+171 -61
View File
@@ -1,89 +1,199 @@
#!/usr/bin/env bash #!/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. # Enthaelt selbst keinerlei Installationslogik.
# #
# Zwei Fallen des Vorgaengers sind hier bewusst vermieden: # Eine einzelne Datei genuegt -- sie holt sich alles Weitere:
# * Der Tag "latest" wird ueber die API aufgeloest, nie direkt in die #
# Download-URL geschrieben. Gitea liefert sonst mit HTTP 200 ein # curl -fsSLO https://gitea.int.eertmoed.net/alientim/tesm/raw/branch/main/deploy/update.sh
# automatisch erzeugtes Quell-Snapshot statt des echten Release-Assets. # sudo bash update.sh tesm
# * Nach dem Entpacken wird geprueft, dass ein install.sh existiert -- faengt #
# denselben Fehler ein zweites Mal ab. # 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 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:-}" APP="${1:-}"
TAG="${2:-latest}" TAG="${2:-latest}"
# Alles nach dem Tag geht unveraendert an install.sh weiter. Ohne das liesse # Alles nach dem Tag geht unveraendert an install.sh weiter. Ohne das liesse
# sich eine benannte Instanz nicht aktualisieren: install.sh leitet den Namen # 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 # zweite Installation namens "tesm" -- mit eigener Datenbank, eigener
# nginx-Site und demselben Port. # nginx-Site und demselben Port.
shift $(( $# > 2 ? 2 : $# )) shift $(( $# > 2 ? 2 : $# ))
EXTRA=("$@") 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" ]] || { [[ "$APP" == "tesm" || "$APP" == "tesm-license" ]] || {
echo "Verwendung: update.sh <tesm|tesm-license> [tag] [install.sh-Argumente ...]" >&2 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
exit 2 exit 2
} }
REPO="${TESM_RELEASE_REPO:-$APP}" # Repositorium: die Master-Ausgabe des Lizenzservers liegt in einem eigenen,
# privaten Repositorium, heisst als Installation aber weiter "tesm-license".
WORK="$(mktemp -d /tmp/tesm-update-XXXXXX)" # Erkannt wird sie an der Installation und nicht an einem Schalter -- wer
trap 'rm -rf "$WORK"' EXIT # anders als im Vorgaenger wird aufgeraeumt # aktualisiert, soll nicht erst wissen muessen, welche Ausgabe hier laeuft.
# Sonst holte der Aufruf die Ausgabe fuer Sub-Lizenzserver, und der Installer
if [[ "$TAG" == "latest" ]]; then # muesste sie abweisen.
echo "==> Ermittle aktuellen Release-Tag ..." REPO="$APP"
TAG="$(curl -fsS "$BASE/api/v1/repos/$OWNER/$REPO/releases/latest" \ if [[ "$APP" == "tesm-license" ]]; then
| python3 -c 'import json,sys; print(json.load(sys.stdin)["tag_name"])')" NAME="$APP"
echo " $TAG" 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 fi
ASSET="$REPO-$TAG.tar.gz" # --8<-- Bootstrap: Release holen --------------------------------------------
echo "==> Lade $ASSET ..." # Wortgleich in update.sh und upgrade.sh; ein Hygienetest vergleicht die beiden
curl -fsSL -o "$WORK/$ASSET" "$BASE/$OWNER/$REPO/releases/download/$TAG/$ASSET" # 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 ..." # Ein privates Repositorium -- tesm-license-master -- gibt sein Release nur mit
tar -xzf "$WORK/$ASSET" -C "$WORK" # Zugriffstoken heraus. Reihenfolge: Umgebungsvariable, dann eine Datei, die
# Drei Ebenen, nicht zwei: release.sh packt mit dem Praefix "<name>/", also # der Betreiber selbst anlegt. Der Installer legt sie nie an: ob ein Token auf
# liegt das Skript unter <name>/deploy/install.sh. Mit "-maxdepth 2" fand die # dem Server liegt, ist keine Entscheidung eines Skripts.
# Suche nie etwas -- und die Fehlermeldung schob es auf den Download. TOKEN="${TESM_RELEASE_TOKEN:-}"
SRC="$(find "$WORK" -maxdepth 3 -name install.sh -path '*/deploy/*' -printf '%h/..\n' | head -1)" if [[ -z "$TOKEN" && -r /etc/tesm-release.token ]]; then
[[ -n "$SRC" ]] || { echo "Im Paket fehlt deploy/install.sh -- Download unbrauchbar." >&2; exit 1; } TOKEN="$(tr -d '[:space:]' < /etc/tesm-release.token)"
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
fi fi
read -r -p "Version $TAG fuer $APP${EXTRA[0]+ (${EXTRA[*]})} installieren? [j/N] " answer # Ein Wagenruecklauf im Paket macht jedes Shell-Skript auf dem Ziel unbrauchbar
[[ "$answer" =~ ^[jJyY]$ ]] || exit 0 # ("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
# --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
View File
@@ -2,10 +2,17 @@
# ============================================================================= # =============================================================================
# Upgrade vom Stand SONNET5 (Version 1.x) auf diese Fassung -- mit Datenuebernahme. # 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
# sudo bash deploy/upgrade.sh --app tesm-license # sudo bash deploy/upgrade.sh --app tesm --instance test --source /srv/tesm
# sudo bash deploy/upgrade.sh --app tesm --instance opus --source /srv/tesm
# #
# Der Ablauf ist die Vorgabe und braucht keine Schalter: # Der Ablauf ist die Vorgabe und braucht keine Schalter:
# #
@@ -44,6 +51,25 @@ DRY_RUN=0
WITH_DATA=1 WITH_DATA=1
STOP_OLD=1 STOP_OLD=1
ADOPT_KEY=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=() EXTRA=()
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" 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 erzeugen, statt den alten zu uebernehmen. Dann muessen
alle Kunden den Ausstellerwechsel bestaetigen -- die alle Kunden den Ausstellerwechsel bestaetigen -- die
Vorgabe ist deshalb die Uebernahme. 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 --dry-run Nur zeigen, was geschehen wuerde
--yes Keine Rueckfrage --yes Keine Rueckfrage
-h, --help Diese Hilfe -h, --help Diese Hilfe
@@ -85,7 +117,7 @@ Beispiele:
sudo bash deploy/upgrade.sh --app tesm sudo bash deploy/upgrade.sh --app tesm
# Daneben, zum Vergleich, alte Installation bleibt unangetastet: # 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 USAGE
} }
@@ -99,6 +131,12 @@ while [[ $# -gt 0 ]]; do
--keep-old-running) STOP_OLD=0; shift ;; --keep-old-running) STOP_OLD=0; shift ;;
--new-key) ADOPT_KEY=0; shift ;; --new-key) ADOPT_KEY=0; shift ;;
--adopt-key) ADOPT_KEY=1; shift ;; # Vorgabe, hier nur zur Deutlichkeit --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 ;; --dry-run) DRY_RUN=1; shift ;;
--yes|-y) ASSUME_YES=1; shift ;; --yes|-y) ASSUME_YES=1; shift ;;
-h|--help) usage; exit 0 ;; -h|--help) usage; exit 0 ;;
@@ -108,37 +146,364 @@ done
[[ "$APP" == "tesm" || "$APP" == "tesm-license" ]] || { usage; fail "--app fehlt oder ist unbekannt"; } [[ "$APP" == "tesm" || "$APP" == "tesm-license" ]] || { usage; fail "--app fehlt oder ist unbekannt"; }
[[ "$(id -u)" -eq 0 ]] || fail "bitte mit sudo ausfuehren" [[ "$(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" NAME="$APP"
[[ -n "$INSTANCE" ]] && NAME="$APP-$INSTANCE" [[ -n "$INSTANCE" ]] && NAME="$APP-$INSTANCE"
ADMIN="$NAME-admin" ADMIN="$NAME-admin"
NEW_ROOT="/srv/$NAME" NEW_ROOT="/srv/$NAME"
IN_PLACE=0
[[ "$(cd "$SOURCE" && pwd)" == "$NEW_ROOT" ]] && IN_PLACE=1
STAMP="$(date +%Y%m%d-%H%M%S)" STAMP="$(date +%Y%m%d-%H%M%S)"
BACKUP="/srv/$NAME-upgrade-$STAMP" BACKUP="/srv/$NAME-upgrade-$STAMP"
ALT_KOPIE="$BACKUP/alt" ALT_KOPIE="$BACKUP/alt"
SYSTEM_KOPIE="$BACKUP/system"
EXPORT_ALT="$BACKUP/export-alt.json" EXPORT_ALT="$BACKUP/export-alt.json"
IMPORT_NEU="$BACKUP/import-neu.json" IMPORT_NEU="$BACKUP/import-neu.json"
# Alte Einheiten der Vorgaengerfassung. Der Timer gehoert dazu: nur den Dienst # Alte Einheiten der Vorgaengerfassung. Der Timer gehoert dazu: nur den Dienst
# zu stoppen liesse ihn beim naechsten Ablauf wieder anlaufen. # zu stoppen liesse ihn beim naechsten Ablauf wieder anlaufen.
if [[ "$APP" == "tesm" ]]; then # -- Rueckweg ---------------------------------------------------------------- #
ALTE_UNITS=(tesm.service tesm-check.timer tesm-check.service # Alles, was install.sh unter demselben Namen ueberschreiben wuerde. Bei einem
tesm-check-restart.timer tesm-check-restart.service) # Upgrade in dasselbe Verzeichnis sind das die Dateien der alten Fassung.
else SYSTEMDATEIEN=(
ALTE_UNITS=(tesm-license.service) "/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 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" step "Vorhaben"
info "Anwendung : $APP${INSTANCE:+ (Instanz $INSTANCE)}" 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 "Neu : $NEW_ROOT$([[ $IN_PLACE -eq 1 ]] && echo ' << dasselbe Verzeichnis')"
info "Sicherung : $ALT_KOPIE" info "Sicherung : $ALT_KOPIE"
info "Datenuebernahme: $([[ $WITH_DATA -eq 1 ]] && echo ja || echo nein)" info "Datenuebernahme: $([[ $WITH_DATA -eq 1 ]] && echo ja || echo nein)"
@@ -160,15 +525,45 @@ fi
# -- 1. Sichern -------------------------------------------------------------- # # -- 1. Sichern -------------------------------------------------------------- #
step "Sichern" step "Sichern"
mkdir -p "$ALT_KOPIE" mkdir -p "$ALT_KOPIE" "$SYSTEM_KOPIE"
chmod 700 "$BACKUP" 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, # Das venv kommt nur mit, wenn in dasselbe Verzeichnis installiert wird -- dort
# gross, und seine Shebangs zeigen ohnehin auf den alten Pfad. # ersetzt install.sh es, und ohne das alte startet der alte Dienst nach einem
tar -C "$SOURCE" --exclude=venv --exclude=__pycache__ -cf - . | tar -C "$ALT_KOPIE" -xf - # Rueckweg nicht mehr. Bei einer eigenen Instanz bleibt die alte Installation
info "$(du -sh "$ALT_KOPIE" | cut -f1) kopiert (ohne venv)" # 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" [[ -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 ---------------------------------------------- # # -- 2. Altes System abschalten ---------------------------------------------- #
if [[ $STOP_OLD -eq 1 ]]; then if [[ $STOP_OLD -eq 1 ]]; then
step "Altes System abschalten" step "Altes System abschalten"
@@ -186,9 +581,12 @@ fi
step "Neu installieren" step "Neu installieren"
INSTALL_ARGS=(--app "$APP" --yes) INSTALL_ARGS=(--app "$APP" --yes)
[[ -n "$INSTANCE" ]] && INSTALL_ARGS+=(--instance "$INSTANCE") [[ -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 if [[ $WITH_DATA -eq 0 ]]; then
UPGRADE_FERTIG=1
step "Fertig -- ohne Datenuebernahme" step "Fertig -- ohne Datenuebernahme"
info "Sicherung der alten Installation: $ALT_KOPIE" info "Sicherung der alten Installation: $ALT_KOPIE"
exit 0 exit 0
@@ -240,6 +638,7 @@ else
fi fi
# -- Abschluss --------------------------------------------------------------- # # -- Abschluss --------------------------------------------------------------- #
UPGRADE_FERTIG=1
step "Fertig" step "Fertig"
info "Sicherung der alten Installation : $ALT_KOPIE" info "Sicherung der alten Installation : $ALT_KOPIE"
if [[ "$APP" == "tesm" ]]; then if [[ "$APP" == "tesm" ]]; then
+57 -10
View File
@@ -105,19 +105,26 @@ Eine bereits eingerichtete HTTPS-Konfiguration bleibt erhalten, auch ohne
### Ohne Quellbaum: `update.sh` ### Ohne Quellbaum: `update.sh`
Liegt kein entpacktes Release vor, holt `update.sh` es selbst -- es lädt das `update.sh` genügt als **einzelne Datei**. Auf einem System, auf dem noch nichts
Release-Asset, entpackt es und ruft dessen `install.sh` auf: von diesem Projekt liegt:
```bash ```bash
sudo bash /opt/tesm/deploy/update.sh tesm curl -fsSLO https://gitea.int.eertmoed.net/alientim/tesm/raw/branch/main/deploy/update.sh
sudo bash /opt/tesm-license/deploy/update.sh tesm-license 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
``` ```
Alles nach dem Tag geht unverändert an `install.sh` weiter -- das ist der Weg, Alles nach dem Tag geht unverändert an `install.sh` weiter -- das ist der Weg,
eine **benannte Instanz** zu aktualisieren: eine **benannte Instanz** zu aktualisieren:
```bash ```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 Ohne `--instance` entstünde daneben eine zweite Installation namens `tesm`, mit
@@ -147,11 +154,51 @@ Ein eigenes Skript, weil dabei mehr passiert als ein Dateiabgleich: die alte
Fassung hat ein anderes Schema, andere Rechteschlüssel und ein anderes Fassung hat ein anderes Schema, andere Rechteschlüssel und ein anderes
Exportformat. 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 ```bash
sudo bash deploy/upgrade.sh --app tesm sudo bash deploy/upgrade.sh --app tesm
sudo bash deploy/upgrade.sh --app tesm-license 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: Der Ablauf ist die Vorgabe und braucht keine Schalter:
| | | | | |
@@ -177,7 +224,7 @@ Export des Vorgängers und taugt deshalb auch als Rückweg.
und die alte bleibt vollständig liegen: und die alte bleibt vollständig liegen:
```bash ```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: **Was nicht mitkommt** -- und warum:
@@ -273,7 +320,7 @@ die niemand mehr füllte.
Von Hand prüfen: Von Hand prüfen:
```bash ```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 Der Aufruf im Banner ist mit `timeout 3` und `|| true` abgesichert: ein
@@ -288,8 +335,8 @@ entfernt -- nur dieses, nichts anderes in dem Verzeichnis.
Aus einer Instanz die Hauptinstallation machen (oder umgekehrt): Aus einer Instanz die Hauptinstallation machen (oder umgekehrt):
```bash ```bash
sudo bash deploy/rename.sh --app tesm --from tesm-opus --to tesm sudo bash deploy/rename.sh --app tesm --from tesm-test --to tesm
sudo bash deploy/rename.sh --app tesm-license --from tesm-license-opus --to tesm-license 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 Der Name steckt an mehr Stellen als im Verzeichnisnamen, und drei davon sind
@@ -399,7 +446,7 @@ Unverzichtbar:
**Die Anwendung antwortet nicht.** **Die Anwendung antwortet nicht.**
```bash ```bash
journalctl -u tesm -n 200 --no-pager journalctl -u tesm -n 200 --no-pager
curl -s http://127.0.0.1:5000/gesundheit curl -s http://127.0.0.1:5000/health
``` ```
Antwortet gunicorn, aber der Browser nicht, liegt es an nginx. Antwortet gunicorn, aber der Browser nicht, liegt es an nginx.
+181
View File
@@ -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[@]}"}
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project] [project]
name = "tesm-core" name = "tesm-core"
version = "2.0.6" version = "2.7.3"
description = "Gemeinsames Anwendungsgeruest (Auth, RBAC, Navigation, Design-System, Migrationen) fuer TESM und TESM-Lizenzserver" description = "Gemeinsames Anwendungsgeruest (Auth, RBAC, Navigation, Design-System, Migrationen) fuer TESM und TESM-Lizenzserver"
requires-python = ">=3.11" requires-python = ">=3.11"
dependencies = [ dependencies = [
+14 -1
View File
@@ -8,7 +8,20 @@ Design-System.
from __future__ import annotations from __future__ import annotations
__version__ = "2.0.2" #: Version aus den Metadaten des installierten Pakets -- die einzige Quelle
#: ist ``pyproject.toml``. Vorher stand hier eine feste Zahl, und die
#: Fusszeile zeigte vier Releases lang 2.0.2.
#:
#: Im Quellbaum ohne ``pip install`` gibt es keine Metadaten; dann steht dort
#: "unbekannt" statt einer erfundenen Nummer. In einem editierbaren Install
#: zeigt sie den Stand des letzten ``pip install -e`` -- also die Version, die
#: tatsaechlich installiert ist.
try: # pragma: no cover - haengt an der Installationsart
from importlib.metadata import PackageNotFoundError, version as _paketversion
__version__ = _paketversion("tesm-core")
except (ImportError, PackageNotFoundError): # pragma: no cover
__version__ = "unbekannt"
from .app_factory import create_base_app from .app_factory import create_base_app
from .config import CoreConfig, SecurityConfig, load_core_config from .config import CoreConfig, SecurityConfig, load_core_config
+67 -13
View File
@@ -13,10 +13,12 @@ import secrets
from datetime import datetime, timezone from datetime import datetime, timezone
from typing import Any, Sequence from typing import Any, Sequence
from flask import Blueprint, Flask, g, render_template, request from urllib.parse import urlencode
from flask import Blueprint, Flask, g, redirect, render_template, request
from werkzeug.exceptions import HTTPException from werkzeug.exceptions import HTTPException
from . import audit, logging_setup, ui from . import audit, legacy_urls, logging_setup, ui
from .config import CoreConfig from .config import CoreConfig
from .core_schema import CORE_MIGRATIONS from .core_schema import CORE_MIGRATIONS
from .db import Database from .db import Database
@@ -31,6 +33,7 @@ from .security import csrf
from .security import sessions as session_store from .security import sessions as session_store
from .security.sessions import SqliteSessionInterface from .security.sessions import SqliteSessionInterface
from .settings_store import SettingSpec, SettingsStore from .settings_store import SettingSpec, SettingsStore
from . import uploads
from .sysops import SysOps from .sysops import SysOps
from .auth import directory from .auth import directory
from .auth import routes as auth_routes from .auth import routes as auth_routes
@@ -56,18 +59,29 @@ CORE_SETTINGS: tuple[SettingSpec, ...] = (
30, 30,
"int", "int",
"Aufbewahrung im Papierkorb (Tage)", "Aufbewahrung im Papierkorb (Tage)",
hint="Nach dieser Frist werden Eintraege beim naechsten Aufruf endgueltig entfernt.", hint="Nach dieser Frist werden Einträge beim nächsten Aufruf endgültig entfernt.",
section="system", section="system",
minimum=1, minimum=1,
maximum=3650, maximum=3650,
), ),
SettingSpec(
"session_absolute_hours",
12,
"int",
"Längstmögliche Sitzungsdauer (Stunden)",
hint="Danach ist Schluss, auch bei ständiger Arbeit – eine Sitzung soll nicht "
"ewig leben. Die eingebundenen Dateifreigaben enden mit ihr.",
section="system",
minimum=1,
maximum=168,
),
SettingSpec( SettingSpec(
"session_idle_minutes", "session_idle_minutes",
30, 30,
"int", "int",
"Automatische Abmeldung nach Leerlauf (Minuten)", "Automatische Abmeldung nach Leerlauf (Minuten)",
hint="Jede Anfrage setzt den Zaehler zurueck -- gezaehlt wird nur Untaetigkeit. " hint="Jede Anfrage setzt den Zähler zurück – gezählt wird nur Untätigkeit. "
"Gilt auch fuer die eingebundenen Dateifreigaben: mit der Sitzung endet die " "Gilt auch für die eingebundenen Dateifreigaben: mit der Sitzung endet die "
"Einbindung.", "Einbindung.",
section="system", section="system",
minimum=5, minimum=5,
@@ -100,8 +114,8 @@ CORE_SETTINGS: tuple[SettingSpec, ...] = (
SettingSpec("web_hsts_enabled", False, "bool", "HSTS senden", section="web"), SettingSpec("web_hsts_enabled", False, "bool", "HSTS senden", section="web"),
SettingSpec("web_redirect_http", True, "bool", "HTTP auf HTTPS umleiten", section="web"), SettingSpec("web_redirect_http", True, "bool", "HTTP auf HTTPS umleiten", section="web"),
SettingSpec("web_cert_path", "", "str", "Zertifikat (Pfad)", section="web"), SettingSpec("web_cert_path", "", "str", "Zertifikat (Pfad)", section="web"),
SettingSpec("web_key_path", "", "str", "Privater Schluessel (Pfad)", section="web"), SettingSpec("web_key_path", "", "str", "Privater Schlüssel (Pfad)", section="web"),
SettingSpec("web_acme_email", "", "str", "E-Mail fuer Let's Encrypt", section="web"), SettingSpec("web_acme_email", "", "str", "E-Mail für Let's Encrypt", section="web"),
SettingSpec("web_acme_webroot", "", "str", "ACME-Webroot", section="web"), SettingSpec("web_acme_webroot", "", "str", "ACME-Webroot", section="web"),
# Abweichende Ports braucht nur eine Testinstanz neben der Produktion -- # Abweichende Ports braucht nur eine Testinstanz neben der Produktion --
# im Regelbetrieb bleibt es bei 80 und 443. # im Regelbetrieb bleibt es bei 80 und 443.
@@ -112,7 +126,7 @@ CORE_SETTINGS: tuple[SettingSpec, ...] = (
"web_https_port", 443, "int", "HTTPS-Port", section="web", minimum=1, maximum=65535 "web_https_port", 443, "int", "HTTPS-Port", section="web", minimum=1, maximum=65535
), ),
SettingSpec( SettingSpec(
"web_max_body_mb", 128, "int", "Maximale Anfragegroesse (MB)", section="web", "web_max_body_mb", 128, "int", "Maximale Anfragegröße (MB)", section="web",
minimum=1, maximum=8192, minimum=1, maximum=8192,
), ),
*directory.SETTINGS, *directory.SETTINGS,
@@ -130,11 +144,11 @@ def create_base_app(
import_name: str = "tesm_core", import_name: str = "tesm_core",
app_short: str = "TESM", app_short: str = "TESM",
app_edition: str = "", app_edition: str = "",
#: Zeile unter der Wortmarke auf den Anmeldeseiten. Leer = kein #: Zeile unter der Wortmarke auf den Anmeldeseiten. Leer = nur die
#: Seitenhintergrund. Der Lizenzserver setzt sie bewusst nicht: die #: Wortmarke, ohne Zeile darunter; die Marke selbst zeigen beide
#: Funktionsliste von TESM waere dort schlicht falsch. #: Anwendungen.
auth_tagline: str = "", auth_tagline: str = "",
app_version: str = "2.0.6", app_version: str = "2.7.3",
built_in_trusted_keys: tuple[str, ...] = (), built_in_trusted_keys: tuple[str, ...] = (),
enable_license: bool = True, enable_license: bool = True,
template_folder: str | None = None, template_folder: str | None = None,
@@ -146,6 +160,10 @@ def create_base_app(
static_folder=static_folder or "static", static_folder=static_folder or "static",
static_url_path="/assets", static_url_path="/assets",
) )
# Umlaute bleiben Umlaute -- auch in JSON. Flask wuerde sonst nach ASCII
# ausweichen ("ü"), und genau darin stehen die Flash-Meldungen in der
# Seite: eine Meldung mit "ue" war lesbar, eine mit "ü" wurde zu Zahlen.
app.json.ensure_ascii = False
# Templates und Design-System des Kerns liegen im Paket ``tesm_core`` und # Templates und Design-System des Kerns liegen im Paket ``tesm_core`` und
# werden ueber einen eigenen Blueprint bereitgestellt. Dadurch kann jede # werden ueber einen eigenen Blueprint bereitgestellt. Dadurch kann jede
@@ -163,6 +181,10 @@ def create_base_app(
config.data_dir.mkdir(parents=True, exist_ok=True) config.data_dir.mkdir(parents=True, exist_ok=True)
config.log_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( app.config.update(
SECRET_KEY=load_or_create_secret_key(config.secret_key_file), SECRET_KEY=load_or_create_secret_key(config.secret_key_file),
# site_key statt app_key: Cookies unterscheiden keine Ports. Zwei # site_key statt app_key: Cookies unterscheiden keine Ports. Zwei
@@ -172,6 +194,9 @@ def create_base_app(
SESSION_COOKIE_HTTPONLY=True, SESSION_COOKIE_HTTPONLY=True,
SESSION_COOKIE_SECURE=config.security.cookie_secure, SESSION_COOKIE_SECURE=config.security.cookie_secure,
SESSION_COOKIE_SAMESITE=config.security.cookie_samesite, 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, MAX_CONTENT_LENGTH=config.security.max_content_length,
JSON_SORT_KEYS=False, JSON_SORT_KEYS=False,
TRAP_HTTP_EXCEPTIONS=False, TRAP_HTTP_EXCEPTIONS=False,
@@ -222,12 +247,21 @@ def create_base_app(
wert = 0 wert = 0
return wert if wert > 0 else config.security.session_idle_minutes return wert if wert > 0 else config.security.session_idle_minutes
def _gesamtdauer(conn: Any) -> int:
"""Gesamtdauer aus den Einstellungen -- ueber dieselbe offene Verbindung."""
try:
wert = int(settings.get(conn, "session_absolute_hours") or 0)
except (TypeError, ValueError):
wert = 0
return wert if wert > 0 else config.security.session_absolute_hours
app.session_interface = SqliteSessionInterface( app.session_interface = SqliteSessionInterface(
database, database,
cookie_name=app.config["SESSION_COOKIE_NAME"], cookie_name=app.config["SESSION_COOKIE_NAME"],
idle_minutes=config.security.session_idle_minutes, idle_minutes=config.security.session_idle_minutes,
absolute_hours=config.security.session_absolute_hours, absolute_hours=config.security.session_absolute_hours,
idle_lookup=_leerlauf, idle_lookup=_leerlauf,
absolute_lookup=_gesamtdauer,
) )
# -- Schema und Startzustand ------------------------------------------ # # -- Schema und Startzustand ------------------------------------------ #
@@ -328,6 +362,26 @@ def create_base_app(
# -- Fehlerseiten ------------------------------------------------------ # # -- Fehlerseiten ------------------------------------------------------ #
@app.errorhandler(HTTPException) @app.errorhandler(HTTPException)
def _http_error(error: HTTPException): # type: ignore[no-untyped-def] def _http_error(error: HTTPException): # type: ignore[no-untyped-def]
if error.code == 404:
# Vor der Fehlerseite: war das eine Adresse aus der Zeit vor 2.5.0?
# Erst hier gefragt, damit die Tabelle nie eine bestehende Route
# verdeckt -- auf dem Lizenzserver ist /lizenz/<ticket> echt.
ziel = legacy_urls.neue_adresse(request.path)
if ziel is not None:
parameter = legacy_urls.neue_parameter(request.args.to_dict(flat=True))
if parameter:
ziel = ziel + "?" + urlencode(parameter)
return redirect(ziel)
if error.code == 413:
# "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(): if _wants_json():
return { return {
"error": error.name, "error": error.name,
@@ -381,7 +435,7 @@ def create_base_app(
with app.app_context(): with app.app_context():
trash_views.register_core_providers() trash_views.register_core_providers()
app.add_url_rule("/gesundheit", "health", _health, methods=["GET"]) app.add_url_rule("/health", "health", _health, methods=["GET"])
return app return app
+2 -2
View File
@@ -133,14 +133,14 @@ def verify_chain(conn: sqlite3.Connection, *, limit: int | None = None) -> dict[
"ok": False, "ok": False,
"checked": checked, "checked": checked,
"broken_at": data["id"], "broken_at": data["id"],
"reason": "Vorgaenger-Hash passt nicht -- eine Zeile wurde entfernt oder eingefuegt.", "reason": "Vorgänger-Hash passt nicht – eine Zeile wurde entfernt oder eingefügt.",
} }
if compute_hash(str(data["prev_hash"]), data) != data["hash"]: if compute_hash(str(data["prev_hash"]), data) != data["hash"]:
return { return {
"ok": False, "ok": False,
"checked": checked, "checked": checked,
"broken_at": data["id"], "broken_at": data["id"],
"reason": "Zeileninhalt passt nicht zum gespeicherten Hash -- Eintrag geaendert.", "reason": "Zeileninhalt passt nicht zum gespeicherten Hash | Eintrag geändert.",
} }
expected_prev = str(data["hash"]) expected_prev = str(data["hash"])
checked += 1 checked += 1
@@ -59,20 +59,20 @@ SETTINGS: tuple[SettingSpec, ...] = (
section="ldap", section="ldap",
choices=(("ldaps", "LDAPS (Port 636)"), ("starttls", "StartTLS (Port 389)"), ("none", "Unverschluesselt")), choices=(("ldaps", "LDAPS (Port 636)"), ("starttls", "StartTLS (Port 389)"), ("none", "Unverschluesselt")),
), ),
SettingSpec("ldap_verify_cert", True, "bool", "Serverzertifikat pruefen", section="ldap"), SettingSpec("ldap_verify_cert", True, "bool", "Serverzertifikat prüfen", section="ldap"),
SettingSpec("ldap_ca_file", "", "str", "CA-Zertifikat (Pfad)", section="ldap"), SettingSpec("ldap_ca_file", "", "str", "CA-Zertifikat (Pfad)", section="ldap"),
SettingSpec( SettingSpec(
"ldap_allow_insecure", "ldap_allow_insecure",
False, False,
"bool", "bool",
"Unverschluesselte Verbindung ausdruecklich erlauben", "Unverschlüsselte Verbindung ausdrücklich erlauben",
hint="Nur fuer Testumgebungen. Das Passwort geht dabei im Klartext ueber das Netz.", hint="Nur für Testumgebungen. Das Passwort geht dabei im Klartext über das Netz.",
section="ldap", section="ldap",
), ),
SettingSpec("ldap_base_dn", "", "str", "Basis-DN (leer = automatisch)", section="ldap"), SettingSpec("ldap_base_dn", "", "str", "Basis-DN (leer = automatisch)", section="ldap"),
SettingSpec("ldap_user_attribute", "sAMAccountName", "str", "Anmeldeattribut", section="ldap"), SettingSpec("ldap_user_attribute", "sAMAccountName", "str", "Anmeldeattribut", section="ldap"),
SettingSpec("ldap_email_attribute", "userPrincipalName", "str", "E-Mail-Attribut", section="ldap"), SettingSpec("ldap_email_attribute", "userPrincipalName", "str", "E-Mail-Attribut", section="ldap"),
SettingSpec("ldap_group_sync", True, "bool", "Gruppen aus dem Verzeichnis uebernehmen", section="ldap"), SettingSpec("ldap_group_sync", True, "bool", "Gruppen aus dem Verzeichnis übernehmen", section="ldap"),
SettingSpec("ldap_timeout", 8, "int", "Zeitlimit (Sekunden)", section="ldap", minimum=1, maximum=60), SettingSpec("ldap_timeout", 8, "int", "Zeitlimit (Sekunden)", section="ldap", minimum=1, maximum=60),
) )
@@ -123,7 +123,7 @@ def bind_credentials(conn: sqlite3.Connection) -> tuple[str, str]:
try: try:
secret = core().keystore.decrypt(encrypted, aad=AAD_LDAP_BIND) secret = core().keystore.decrypt(encrypted, aad=AAD_LDAP_BIND)
except Exception: # noqa: BLE001 - Schluessel gewechselt/Datei kaputt except Exception: # noqa: BLE001 - Schluessel gewechselt/Datei kaputt
logger.error("Bind-Passwort des Verzeichnisdienstes konnte nicht entschluesselt werden") logger.error("Bind-Passwort des Verzeichnisdienstes konnte nicht entschlüsselt werden")
return str(row.get("username") or ""), "" return str(row.get("username") or ""), ""
return str(row.get("username") or ""), secret return str(row.get("username") or ""), secret
@@ -182,7 +182,7 @@ def _connect(config: dict[str, Any], user: str, password: str):
security = str(config["ldap_security"]) security = str(config["ldap_security"])
if security == "none" and not config["ldap_allow_insecure"]: if security == "none" and not config["ldap_allow_insecure"]:
raise PermissionError( raise PermissionError(
"Unverschluesselte Verzeichnisverbindungen sind deaktiviert. " "Unverschlüsselte Verzeichnisverbindungen sind deaktiviert. "
"Nutzen Sie LDAPS oder StartTLS." "Nutzen Sie LDAPS oder StartTLS."
) )
connection = Connection( connection = Connection(
@@ -249,9 +249,9 @@ def authenticate(conn: sqlite3.Connection, username: str, password: str) -> Dire
bind_user, bind_password = bind_credentials(conn) bind_user, bind_password = bind_credentials(conn)
warnings: list[str] = [] warnings: list[str] = []
if str(config["ldap_security"]) == "none": if str(config["ldap_security"]) == "none":
warnings.append("Unverschluesselte Verzeichnisverbindung in Benutzung") warnings.append("Unverschlüsselte Verzeichnisverbindung in Benutzung")
if not config["ldap_verify_cert"]: if not config["ldap_verify_cert"]:
warnings.append("Serverzertifikat des Verzeichnisdienstes wird nicht geprueft") warnings.append("Serverzertifikat des Verzeichnisdienstes wird nicht geprüft")
try: try:
service_conn, bound = _connect(config, bind_user, bind_password) service_conn, bound = _connect(config, bind_user, bind_password)
@@ -359,7 +359,7 @@ def _resolve_groups(connection: Any, base: str, user_dn: str) -> tuple[str, ...]
) )
return tuple(str(entry.entry_dn) for entry in connection.entries) return tuple(str(entry.entry_dn) for entry in connection.entries)
except Exception as exc: # noqa: BLE001 except Exception as exc: # noqa: BLE001
logger.warning("Gruppenaufloesung fehlgeschlagen: %s", exc) logger.warning("Gruppenauflösung fehlgeschlagen: %s", exc)
return () return ()
@@ -200,7 +200,12 @@ def _deny(message: str) -> Any:
abort(401, description="Sitzung abgelaufen.") abort(401, description="Sitzung abgelaufen.")
if request.accept_mimetypes.accept_json and not request.accept_mimetypes.accept_html: if request.accept_mimetypes.accept_json and not request.accept_mimetypes.accept_html:
abort(401, description="Anmeldung erforderlich.") abort(401, description="Anmeldung erforderlich.")
flash("Bitte melden Sie sich an.", "info") # "Bitte melden Sie sich an." stand auf einer Seite, die aus einem
# Anmeldefeld, einem Passwortfeld und einem Knopf "Anmelden" besteht --
# eine Meldung, die nichts hinzufuegt. Wer dagegen gerade noch
# angemeldet war, soll erfahren, warum er es nicht mehr ist.
if getattr(g, "session_expired", False):
flash("Ihre Sitzung ist abgelaufen. Bitte melden Sie sich erneut an.", "info")
return redirect(url_for("auth.login")) return redirect(url_for("auth.login"))
abort(403, description=message) abort(403, description=message)
@@ -215,6 +220,37 @@ def login_required(view: Callable[..., Any]) -> Callable[..., Any]:
return wrapper return wrapper
def _verlangt_wiederanmeldung(permission: str) -> bool:
"""Fragt das Rechtemodell -- ausserhalb einer Anwendung nie."""
try:
from ..extension import core
return bool(core().permissions.verlangt_wiederanmeldung(permission))
except Exception: # noqa: BLE001 - ohne Modell keine zusaetzliche Huerde
return False
def _wiederanmeldung_frisch() -> bool:
from .service import reauth_is_fresh # zyklischen Import vermeiden
return reauth_is_fresh()
def _bitte_bestaetigen(permission: str) -> Any:
"""Zur Passwortbestaetigung schicken -- oder es einem Skript sagen.
Ein Aufruf per fetch bekaeme sonst die Anmeldeseite als Antwort und wuesste
nichts damit anzufangen.
"""
if is_automatic(request) or (
request.accept_mimetypes.accept_json and not request.accept_mimetypes.accept_html
):
abort(403, description="Bitte bestätigen Sie zur Sicherheit Ihr Passwort.")
session["_after_reauth"] = request.full_path if request.query_string else request.path
flash("Bitte bestätigen Sie zur Sicherheit Ihr Passwort.", "info")
return redirect(url_for("auth.reauth"))
def permission_required(*permissions: str, mode: str = "all") -> Callable[..., Any]: def permission_required(*permissions: str, mode: str = "all") -> Callable[..., Any]:
"""Erzwingt Rechte serverseitig. """Erzwingt Rechte serverseitig.
@@ -232,7 +268,15 @@ def permission_required(*permissions: str, mode: str = "all") -> Callable[..., A
else current_user.has(*permissions) else current_user.has(*permissions)
) )
if not ok: if not ok:
return _deny("Fuer diese Seite fehlt Ihnen die Berechtigung.") return _deny("Für diese Seite fehlt Ihnen die Berechtigung.")
# Geheimnisse und Exporte verlangen eine frische Bestaetigung --
# das steht im Rechtemodell und wird deshalb hier abgefragt, nicht
# in jeder Route einzeln. Vorher hing es daran, dass jemand an den
# Dekorator dachte: dieselbe Lizenz herunterzuladen fragte nach,
# sie per Mail zu verschicken nicht.
heikel = [p for p in permissions if _verlangt_wiederanmeldung(p)]
if heikel and not _wiederanmeldung_frisch():
return _bitte_bestaetigen(heikel[0])
return view(*args, **kwargs) return view(*args, **kwargs)
wrapper._required_permissions = permissions # type: ignore[attr-defined] wrapper._required_permissions = permissions # type: ignore[attr-defined]
@@ -274,7 +318,7 @@ def reauth_required(view: Callable[..., Any]) -> Callable[..., Any]:
return _deny("Anmeldung erforderlich.") return _deny("Anmeldung erforderlich.")
if not reauth_is_fresh(): if not reauth_is_fresh():
session["_after_reauth"] = request.full_path if request.query_string else request.path session["_after_reauth"] = request.full_path if request.query_string else request.path
flash("Bitte bestaetigen Sie zur Sicherheit Ihr Passwort.", "info") flash("Bitte bestätigen Sie zur Sicherheit Ihr Passwort.", "info")
return redirect(url_for("auth.reauth")) return redirect(url_for("auth.reauth"))
return view(*args, **kwargs) return view(*args, **kwargs)
@@ -66,7 +66,7 @@ def login():
) )
@bp.route("/login/zweiter-faktor", methods=["GET", "POST"]) @bp.route("/login/second-factor", methods=["GET", "POST"])
def second_factor(): def second_factor():
conn = db() conn = db()
user_row = service.pending_totp_user(conn) user_row = service.pending_totp_user(conn)
@@ -116,7 +116,7 @@ def logout_get():
return redirect(url_for("auth.login")) return redirect(url_for("auth.login"))
@bp.route("/bestaetigen", methods=["GET", "POST"]) @bp.route("/confirm", methods=["GET", "POST"])
@login_required @login_required
def reauth(): def reauth():
target = _safe_next(session.get("_after_reauth")) target = _safe_next(session.get("_after_reauth"))
@@ -151,7 +151,7 @@ def authenticate(
if ip_verdict.blocked: if ip_verdict.blocked:
return AuthOutcome( return AuthOutcome(
"rate_limited", "rate_limited",
"Zu viele Anmeldeversuche von dieser Adresse. Bitte spaeter erneut versuchen.", "Zu viele Anmeldeversuche von dieser Adresse. Bitte später erneut versuchen.",
retry_after=ip_verdict.retry_after_seconds, retry_after=ip_verdict.retry_after_seconds,
) )
@@ -165,7 +165,7 @@ def authenticate(
if user_verdict.blocked: if user_verdict.blocked:
return AuthOutcome( return AuthOutcome(
"rate_limited", "rate_limited",
"Dieses Konto ist nach mehreren Fehlversuchen voruebergehend gesperrt.", "Dieses Konto ist nach mehreren Fehlversuchen vorübergehend gesperrt.",
retry_after=user_verdict.retry_after_seconds, retry_after=user_verdict.retry_after_seconds,
) )
@@ -248,7 +248,7 @@ def _verify_credentials(
return AuthOutcome( return AuthOutcome(
"directory_unavailable", "directory_unavailable",
"Der Verzeichnisdienst ist derzeit nicht erreichbar. " "Der Verzeichnisdienst ist derzeit nicht erreichbar. "
"Bitte versuchen Sie es spaeter erneut.", "Bitte versuchen Sie es später erneut.",
) )
if result.status != "ok" or result.profile is None: if result.status != "ok" or result.profile is None:
return AuthOutcome("invalid", GENERIC_FAILURE) return AuthOutcome("invalid", GENERIC_FAILURE)
@@ -257,6 +257,24 @@ def _verify_credentials(
return AuthOutcome("ok", user_row=user_row, group_dns=result.profile.group_dns) return AuthOutcome("ok", user_row=user_row, group_dns=result.profile.group_dns)
def _ist_erstanmeldung(user_row: dict[str, Any]) -> bool:
"""Meldet sich dieses Konto zum ersten Mal ueberhaupt an?
Das Aenderungsprotokoll soll Aenderungen zeigen. Eine taegliche Anmeldung
ist keine -- sie erzeugte aber jedes Mal eine Zeile und schob damit echte
Eintraege aus dem Blickfeld. Interessant ist genau die erste: mit ihr
entsteht das Konto aus dem Verzeichnisdienst und betritt jemand die
Anwendung, der vorher nicht darin war.
Nicht betroffen: fehlgeschlagene Anmeldungen und Sperren. Die stehen
weiterhin **jedes Mal** im Protokoll -- dort liegt der sicherheitsrelevante
Teil, und Haeufung ist dort das Signal. Wer wann zuletzt angemeldet war,
steht ausserdem unveraendert am Konto und in der Sitzungsuebersicht.
"""
return not str(user_row.get("last_login_at") or "").strip()
def complete_login( def complete_login(
conn: sqlite3.Connection, conn: sqlite3.Connection,
user_row: dict[str, Any], user_row: dict[str, Any],
@@ -275,21 +293,26 @@ def complete_login(
""" """
from ..security import csrf from ..security import csrf
# Vor dem UPDATE lesen -- danach steht ueberall ein Zeitstempel.
erstanmeldung = _ist_erstanmeldung(user_row)
with core().database.transaction(conn): with core().database.transaction(conn):
session_store.rotate(conn, session, reason="login") # type: ignore[arg-type] session_store.rotate(conn, session, reason="login") # type: ignore[arg-type]
conn.execute( conn.execute(
"UPDATE users SET last_login_at=? WHERE id=?", (_iso(_now()), user_row["id"]) "UPDATE users SET last_login_at=? WHERE id=?", (_iso(_now()), user_row["id"])
) )
audit.log( if erstanmeldung:
conn, audit.log(
actor=str(user_row["username"]), conn,
actor_id=int(user_row["id"]), actor=str(user_row["username"]),
action="auth.login", actor_id=int(user_row["id"]),
target=str(user_row["username"]), action="auth.first_login",
detail=f"Quelle: {user_row.get('auth_source') or 'local'}", target=str(user_row["username"]),
ip=ip, detail=f"Erste Anmeldung | Quelle: {user_row.get('auth_source') or 'local'}",
request_id=getattr(g, "request_id", ""), ip=ip,
) request_id=getattr(g, "request_id", ""),
severity="notice",
)
session.clear() session.clear()
session[SESSION_USER_KEY] = int(user_row["id"]) session[SESSION_USER_KEY] = int(user_row["id"])
+12 -12
View File
@@ -83,7 +83,7 @@ def run_web_setup(app: Any, extension: Any, args: argparse.Namespace) -> int:
port = getattr(args, flag, None) port = getattr(args, flag, None)
if port: if port:
if not 1 <= int(port) <= 65535: if not 1 <= int(port) <= 65535:
print(f"Ungueltiger Port: {port}", file=sys.stderr) print(f"Ungültiger Port: {port}", file=sys.stderr)
return 2 return 2
values[key] = int(port) values[key] = int(port)
@@ -93,11 +93,11 @@ def run_web_setup(app: Any, extension: Any, args: argparse.Namespace) -> int:
# -- Zertifikat --------------------------------------------------- # # -- Zertifikat --------------------------------------------------- #
config = _config_from_settings(extension, conn) config = _config_from_settings(extension, conn)
cert: dict[str, Any] = {"ok": True, "message": "Zertifikat unveraendert."} cert: dict[str, Any] = {"ok": True, "message": "Zertifikat unverändert."}
if args.acme: if args.acme:
if sysops is None or not sysops.available: if sysops is None or not sysops.available:
print("Der Systemhelfer ist nicht verfuegbar.", file=sys.stderr) print("Der Systemhelfer ist nicht verfügbar.", file=sys.stderr)
return 2 return 2
cert = webserver.request_certificate( cert = webserver.request_certificate(
sysops, sysops,
@@ -118,7 +118,7 @@ def run_web_setup(app: Any, extension: Any, args: argparse.Namespace) -> int:
if args.self_signed and not args.acme: if args.self_signed and not args.acme:
if sysops is None or not sysops.available: if sysops is None or not sysops.available:
print("Der Systemhelfer ist nicht verfuegbar.", file=sys.stderr) print("Der Systemhelfer ist nicht verfügbar.", file=sys.stderr)
return 2 return 2
suggested = webserver.local_names() suggested = webserver.local_names()
cert = webserver.create_self_signed( cert = webserver.create_self_signed(
@@ -144,7 +144,7 @@ def run_web_setup(app: Any, extension: Any, args: argparse.Namespace) -> int:
if args.https: if args.https:
if not (config.cert_path and config.key_path): if not (config.cert_path and config.key_path):
print( print(
"HTTPS verlangt Zertifikat und Schluessel -- bitte --self-signed " "HTTPS verlangt Zertifikat und Schlüssel – bitte --self-signed "
"oder --acme angeben.", "oder --acme angeben.",
file=sys.stderr, file=sys.stderr,
) )
@@ -172,7 +172,7 @@ def run_web_setup(app: Any, extension: Any, args: argparse.Namespace) -> int:
return 0 return 0
if sysops is None or not sysops.available: if sysops is None or not sysops.available:
print("Der Systemhelfer ist nicht verfuegbar.", file=sys.stderr) print("Der Systemhelfer ist nicht verfügbar.", file=sys.stderr)
return 2 return 2
result = webserver.apply_config(sysops, rendered) result = webserver.apply_config(sysops, rendered)
print(result["message"]) print(result["message"])
@@ -194,7 +194,7 @@ def add_web_setup(sub: Any, runner: Any) -> None:
help="nginx und TLS einrichten (Port 80/443)", help="nginx und TLS einrichten (Port 80/443)",
description=( description=(
"Setzt die Webserver-Einstellungen und erzeugt daraus die nginx-Site. " "Setzt die Webserver-Einstellungen und erzeugt daraus die nginx-Site. "
"Die Datei wird immer vollstaendig neu geschrieben -- eine von Hand " "Die Datei wird immer vollständig neu geschrieben – eine von Hand "
"bearbeitete Site-Datei gibt es in diesem Projekt bewusst nicht." "bearbeitete Site-Datei gibt es in diesem Projekt bewusst nicht."
), ),
) )
@@ -207,20 +207,20 @@ def add_web_setup(sub: Any, runner: Any) -> None:
parser.add_argument( parser.add_argument(
"--acme", "--acme",
action="store_true", action="store_true",
help="Zertifikat per Let's Encrypt anfordern (Domain muss oeffentlich zeigen)", help="Zertifikat per Let's Encrypt anfordern (Domain muss öffentlich zeigen)",
) )
parser.add_argument("--domain", help="Oeffentlicher Name dieser Installation") parser.add_argument("--domain", help="Öffentlicher Name dieser Installation")
parser.add_argument("--acme-email", help="Kontaktadresse fuer Let's Encrypt") parser.add_argument("--acme-email", help="Kontaktadresse für Let's Encrypt")
parser.add_argument("--hsts", action="store_true", help="HSTS-Header setzen") parser.add_argument("--hsts", action="store_true", help="HSTS-Header setzen")
parser.add_argument( parser.add_argument(
"--no-redirect", "--no-redirect",
action="store_true", action="store_true",
help="Port 80 nicht auf HTTPS umleiten (Sonderfall)", help="Port 80 nicht auf HTTPS umleiten (Sonderfall)",
) )
parser.add_argument("--max-body-mb", type=int, help="Obergrenze fuer Uploads") parser.add_argument("--max-body-mb", type=int, help="Obergrenze für Uploads")
parser.add_argument("--http-port", type=int, help="Abweichender HTTP-Port (Standard 80)") parser.add_argument("--http-port", type=int, help="Abweichender HTTP-Port (Standard 80)")
parser.add_argument("--https-port", type=int, help="Abweichender HTTPS-Port (Standard 443)") parser.add_argument("--https-port", type=int, help="Abweichender HTTPS-Port (Standard 443)")
parser.add_argument("--apply", action="store_true", help="nginx schreiben, pruefen, neu laden") parser.add_argument("--apply", action="store_true", help="nginx schreiben, prüfen, neu laden")
parser.add_argument( parser.add_argument(
"--print-config", action="store_true", help="Nur die erzeugte Datei ausgeben" "--print-config", action="store_true", help="Nur die erzeugte Datei ausgeben"
) )
+3 -3
View File
@@ -76,7 +76,7 @@ class CoreConfig:
"""Technischer Kurzname, z. B. ``tesm`` -- Namensraum fuer Pfade und Units.""" """Technischer Kurzname, z. B. ``tesm`` -- Namensraum fuer Pfade und Units."""
site_key: str 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 Weicht vom ``app_key`` ab, sobald neben der Hauptinstallation eine zweite
Instanz laeuft. Alles, was ausserhalb von ``/srv/<name>`` liegt und damit 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() bind = (_env(env_prefix, "BIND") or default_bind).strip()
if not re.fullmatch(r"[0-9A-Za-z_.\[\]:-]{1,80}:\d{1,5}", bind): if not re.fullmatch(r"[0-9A-Za-z_.\[\]:-]{1,80}:\d{1,5}", bind):
raise ValueError(f"Ungueltige Bind-Adresse: {bind!r}") raise ValueError(f"Ungültige Bind-Adresse: {bind!r}")
site_key = (_env(env_prefix, "SITE_KEY") or app_key).strip().lower() site_key = (_env(env_prefix, "SITE_KEY") or app_key).strip().lower()
if not re.fullmatch(r"[a-z0-9][a-z0-9-]{0,39}", site_key): if not re.fullmatch(r"[a-z0-9][a-z0-9-]{0,39}", site_key):
raise ValueError(f"Ungueltiger SITE_KEY: {site_key!r}") raise ValueError(f"Ungültiger SITE_KEY: {site_key!r}")
return CoreConfig( return CoreConfig(
app_key=app_key, app_key=app_key,
+25 -2
View File
@@ -80,8 +80,8 @@ class Database:
"""Oeffnet eine neue Verbindung. Nicht aufrufen, solange eine Transaktion laeuft.""" """Oeffnet eine neue Verbindung. Nicht aufrufen, solange eine Transaktion laeuft."""
if self._depth() > 0: if self._depth() > 0:
raise NestedConnectionError( raise NestedConnectionError(
"Es laeuft bereits eine Schreibtransaktion auf dieser Datenbank. " "Es läuft bereits eine Schreibtransaktion auf dieser Datenbank. "
"Reiche die bestehende Verbindung durch, statt eine neue zu oeffnen " "Reiche die bestehende Verbindung durch, statt eine neue zu öffnen "
"(sonst blockiert SQLite sich selbst)." "(sonst blockiert SQLite sich selbst)."
) )
return self._raw_connect() return self._raw_connect()
@@ -119,6 +119,29 @@ class Database:
finally: finally:
conn.close() conn.close()
@contextmanager
def dauerhaft(self, conn: sqlite3.Connection) -> Iterator[sqlite3.Connection]:
"""Fuer Schreibvorgaenge, die einen Stromausfall ueberstehen muessen.
Im Alltag laeuft die Datenbank mit ``synchronous=NORMAL``: schnell, und
bei einem Stromausfall gehen hoechstens die letzten Sekunden verloren --
die Datei bleibt heil. Fuer einen Messwert ist das genau richtig.
Fuer eine Unterschrift nicht. Dort liegt die Datei anschliessend auf der
Freigabe, und fehlte der Eintrag dazu, waere sie "unbekannt" statt
nachweisbar. Ein fsync kostet ein paar Millisekunden -- bei ein paar
Unterschriften am Tag ist das keine Diskussion wert.
Umschliesst die Transaktion, nicht umgekehrt: ``PRAGMA synchronous``
wirkt auf den naechsten Commit, und gesetzt werden kann es nur
ausserhalb einer laufenden Transaktion.
"""
conn.execute("PRAGMA synchronous=FULL")
try:
yield conn
finally:
conn.execute("PRAGMA synchronous=NORMAL")
# ------------------------------------------------------------------ # # ------------------------------------------------------------------ #
# Transaktionen # Transaktionen
# ------------------------------------------------------------------ # # ------------------------------------------------------------------ #
@@ -79,7 +79,7 @@ def core(app: Flask | None = None) -> TesmCore:
try: try:
return target.extensions["tesm"] # type: ignore[return-value] return target.extensions["tesm"] # type: ignore[return-value]
except KeyError as exc: # pragma: no cover - Programmierfehler except KeyError as exc: # pragma: no cover - Programmierfehler
raise RuntimeError("tesm_core ist fuer diese Anwendung nicht initialisiert") from exc raise RuntimeError("tesm_core ist für diese Anwendung nicht initialisiert") from exc
def db() -> sqlite3.Connection: def db() -> sqlite3.Connection:
+6 -6
View File
@@ -78,7 +78,7 @@ def check_private_file(path: Path) -> list[str]:
except OSError: except OSError:
return problems return problems
if mode & (stat.S_IRGRP | stat.S_IROTH | stat.S_IWGRP | stat.S_IWOTH): if mode & (stat.S_IRGRP | stat.S_IROTH | stat.S_IWGRP | stat.S_IWOTH):
problems.append(f"{path} ist fuer Gruppe/Andere lesbar oder schreibbar (erwartet 0600)") problems.append(f"{path} ist für Gruppe/Andere lesbar oder schreibbar (erwartet 0600)")
return problems return problems
@@ -110,11 +110,11 @@ class Keystore:
created_at=str(entry.get("created_at", "")), created_at=str(entry.get("created_at", "")),
) )
if len(key.material) != _KEY_BYTES: if len(key.material) != _KEY_BYTES:
raise ValueError(f"Schluessel {key.key_id} hat die falsche Laenge") raise ValueError(f"Schlüssel {key.key_id} hat die falsche Länge")
self._keys[key.key_id] = key self._keys[key.key_id] = key
self._active = str(raw.get("active") or "") self._active = str(raw.get("active") or "")
if self._active not in self._keys: if self._active not in self._keys:
raise ValueError("Aktiver Schluessel fehlt im Schluesselbund") raise ValueError("Aktiver Schlüssel fehlt im Schlüsselbund")
return return
self._active = "1" self._active = "1"
self._keys = { self._keys = {
@@ -160,7 +160,7 @@ class Keystore:
def drop_key(self, key_id: str) -> None: def drop_key(self, key_id: str) -> None:
"""Entfernt einen alten Schluessel -- erst nach vollstaendiger Neuverschluesselung.""" """Entfernt einen alten Schluessel -- erst nach vollstaendiger Neuverschluesselung."""
if key_id == self._active: if key_id == self._active:
raise ValueError("Der aktive Schluessel kann nicht entfernt werden") raise ValueError("Der aktive Schlüssel kann nicht entfernt werden")
self._keys.pop(key_id, None) self._keys.pop(key_id, None)
self._persist() self._persist()
@@ -183,14 +183,14 @@ class Keystore:
raise DecryptionError(f"Unbekannte Chiffrat-Version: {prefix!r}") raise DecryptionError(f"Unbekannte Chiffrat-Version: {prefix!r}")
key = self._keys.get(key_id) key = self._keys.get(key_id)
if key is None: if key is None:
raise DecryptionError(f"Schluessel {key_id} ist nicht im Schluesselbund") raise DecryptionError(f"Schlüssel {key_id} ist nicht im Schlüsselbund")
try: try:
plain = AESGCM(key.material).decrypt( plain = AESGCM(key.material).decrypt(
_b64d(nonce_b64), _b64d(ct_b64), aad.encode("utf-8") _b64d(nonce_b64), _b64d(ct_b64), aad.encode("utf-8")
) )
except (InvalidTag, ValueError) as exc: except (InvalidTag, ValueError) as exc:
raise DecryptionError( raise DecryptionError(
"Chiffrat konnte nicht entschluesselt werden (falscher Schluessel, " "Chiffrat konnte nicht entschlüsselt werden (falscher Schlüssel, "
"manipulierte Daten oder falscher Verwendungszweck)" "manipulierte Daten oder falscher Verwendungszweck)"
) from exc ) from exc
return plain.decode("utf-8") return plain.decode("utf-8")
@@ -0,0 +1,242 @@
"""Alte, deutsche Adressen auf die neuen englischen umlenken.
Mit 2.5.0 folgen die Adressen der Navigation und sind englisch:
``/monitoring/host-keys`` statt ``/hostschluessel``. Das ist eine gute
Aenderung und eine unangenehme zugleich -- Lesezeichen, offene Browsertabs und
ein Link in einer Mail zeigen weiter auf die alte Adresse. Ein offener Tab ist
dabei der unangenehmste Fall: die Statusuebersicht fragt alle 20 Sekunden ihre
Kacheln ab und bekam nach dem Update jedes Mal einen 404.
Deshalb diese Tabelle. Sie greift **nur, wenn sonst ein 404 herauskaeme** --
also nie vor einer bestehenden Route. Das ist keine Feinheit: auf dem
Lizenzserver ist ``/lizenz/<ticket>`` eine echte, weiterhin gueltige Adresse
(sie steht in verschickten Mails), waehrend dieselbe Wurzel in TESM zur
Lizenzseite fuehrte. Wer zuerst das Routing fragt, muss sich nicht zwischen
beidem entscheiden.
Die Tabelle ist bewusst vollstaendig und nicht nur auf Wurzeln gekuerzt: eine
Umleitung von ``/dateifreigaben/x/vorschau`` auf eine Uebersichtsseite waere
zwar kein 404 mehr, aber auch nicht das, was jemand angeklickt hat.
"""
from __future__ import annotations
from typing import Mapping
#: Alte Wurzel -> (neue Wurzel, Uebersetzung der Segmente darunter).
WURZELN: dict[str, tuple[str, Mapping[str, str]]] = {
"/dateifreigaben": (
"/fileshares",
{
"herunterladen": "download",
"vorschau": "preview",
"hochladen": "upload",
"formular": "form",
"ordner": "folder",
"umbenennen": "rename",
"loeschen": "delete",
"freigeben": "release",
"zuordnungen": "mappings",
},
),
"/hostschluessel": (
"/monitoring/host-keys",
{"pruefen": "probe", "freigeben": "trust", "verwerfen": "forget", "verwaist": "orphaned"},
),
"/zugangsdaten": (
"/monitoring/credentials",
{"neu": "new", "loeschen": "delete", "anzeigen": "reveal"},
),
"/switche": (
"/monitoring/switches",
{
"neu": "new",
"loeschen": "delete",
"hostkey": "host-key",
"pruefen": "probe",
"freigeben": "trust",
"verwerfen": "forget",
"kommando": "command",
"konsole": "console",
},
),
"/clients": (
"/monitoring/clients",
{
"neu": "new",
"aktiv": "active",
"loeschen": "delete",
"neustart": "restart",
"hostkey": "host-key",
"pruefen": "probe",
"freigeben": "trust",
"verwerfen": "forget",
"konsole": "console",
},
),
"/wartung": (
"/maintenance",
{
"paket-nachinstallieren": "install-package",
"auftrag": "job",
"ausgabe": "output",
"aktualisieren": "update",
"neustarten": "restart",
},
),
"/protokolle": (
"/logs",
{
"kea": "dhcp",
"neustarts": "restarts",
"laufend": "live",
"inhalt": "content",
"verlauf": "history",
"datei": "file",
"archiv": "archive",
"archiv-export": "archive-export",
"archiv-loeschen": "archive-delete",
"aenderungen": "audit",
},
),
"/verwaltung": (
"/administration",
{
"benutzer": "users",
"neu": "new",
"loeschen": "delete",
"sitzungen-beenden": "end-sessions",
"verzeichnis-suche": "directory-search",
"verzeichnis-uebernehmen": "directory-import",
"gruppen": "groups",
"vorlage-anwenden": "apply-preset",
"sitzungen": "sessions",
"beenden": "end",
},
),
"/einstellungen": (
"/administration",
{
"zeitzone": "timezone",
"pruefung-jetzt": "check-now",
"netzwerk": "network",
"bestaetigen": "confirm",
"webserver": "web",
"uebernehmen": "apply",
"zertifikat": "certificate",
"selbstsigniert": "self-signed",
"verzeichnisdienst": "directory",
"gruppensuche": "group-search",
"zuordnung": "mapping",
"loeschen": "delete",
"anbieter": "vendor",
"logo-entfernen": "remove-logo",
},
),
"/sicherung": (
"/administration/backup",
{"vorschau": "preview", "uebernehmen": "apply", "verwerfen": "discard"},
),
"/papierkorb": (
"/administration/trash",
{"wiederherstellen": "restore", "endgueltig-loeschen": "purge", "leeren": "empty"},
),
"/diagnose": (
"/administration/diagnostics",
{"aufraeumen": "cleanup", "schluessel-rotieren": "rotate-keys"},
),
"/eigene-lizenz": (
"/own-license",
{
"einspielen": "install",
"aktivieren": "activate",
"deaktivieren": "deactivate",
"offline-antwort": "offline-response",
"signaturschluessel": "signing-key",
},
),
"/lizenzen": (
"/licenses",
{
"aus-ticket": "from-ticket",
"datei": "file",
"neu-signieren": "resign",
"widerrufen": "revoke",
"loeschen": "delete",
"versenden": "send",
"sub-lizenzserver": "sub-servers",
},
),
"/lizenz": (
"/administration/license",
{
"einspielen": "install",
"aktivieren": "activate",
"deaktivieren": "deactivate",
"offline-antwort": "offline-response",
"serverzertifikat": "server-certificate",
"aussteller": "issuer",
"entfernen": "remove",
},
),
"/kunden": ("/customers", {"neu": "new", "loeschen": "delete"}),
"/konto": (
"/account",
{
"stammdaten": "profile",
"profilbild": "avatar",
"sicherheit": "security",
"passwort": "password",
"zwei-faktor": "two-factor",
"starten": "start",
"bestaetigen": "confirm",
"deaktivieren": "disable",
"codes-erneuern": "recovery-codes",
"sitzungen": "sessions",
"beenden": "end",
"alle-beenden": "end-all",
},
),
"/uebersicht": ("/overview", {"kacheln": "tiles"}),
"/gesundheit": ("/health", {}),
"/bestaetigen": ("/confirm", {}),
}
#: Abfrageparameter, die mit umbenannt wurden.
PARAMETER = {
"freigabe": "share",
"pfad": "path",
"aktion": "action",
"benutzer": "actor",
"stufe": "severity",
"seite": "page",
"methode": "method",
"kunde": "customer",
"freischalten": "unlocked",
}
#: Wurzeln, unter denen eine unbekannte Unterseite **nicht** umgelenkt wird.
#: ``/lizenz/<ticket>`` ist auf dem Lizenzserver eine echte Adresse; ein
#: abgelaufenes oder falsches Ticket gehoert dort auf die Fehlerseite und nicht
#: in die Verwaltung.
STRENG = {"/lizenz"}
def neue_adresse(pfad: str) -> str | None:
"""Uebersetzt eine alte Adresse -- oder ``None``, wenn es keine ist."""
for alt, (neu, segmente) in WURZELN.items():
if pfad != alt and not pfad.startswith(alt + "/"):
continue
rest = pfad[len(alt) :]
teile = rest.split("/")
if alt in STRENG:
erstes = next((teil for teil in teile if teil), "")
if erstes and erstes not in segmente:
return None
return neu + "/".join(segmente.get(teil, teil) for teil in teile)
return None
def neue_parameter(args: Mapping[str, str]) -> dict[str, str]:
return {PARAMETER.get(name, name): wert for name, wert in args.items()}
@@ -68,10 +68,10 @@ API_PATHS = {
#: die einzige Wahrheit -- Templates lesen sie, Routen erzwingen sie. #: die einzige Wahrheit -- Templates lesen sie, Routen erzwingen sie.
LICENSED_FEATURES: dict[str, str] = { LICENSED_FEATURES: dict[str, str] = {
"directory": "Verzeichnisanmeldung (LDAP/AD) konfigurieren", "directory": "Verzeichnisanmeldung (LDAP/AD) konfigurieren",
"nginx": "Webserver-Einstellungen aendern", "nginx": "Webserver-Einstellungen ändern",
"export": "Daten exportieren", "export": "Daten exportieren",
"trash_restore": "Papierkorb wiederherstellen und endgueltig loeschen", "trash_restore": "Papierkorb wiederherstellen und endgültig löschen",
"monitor_interval": "Pruefintervall der Ueberwachung aendern", "monitor_interval": "Prüfintervall der Überwachung ändern",
"auto_poe_restart": "Automatischer PoE-Neustart bei Ausfall", "auto_poe_restart": "Automatischer PoE-Neustart bei Ausfall",
} }
@@ -85,7 +85,7 @@ def describe_transport_error(exc: BaseException, endpoint: str) -> str:
""" """
text = str(exc) text = str(exc)
if "CERTIFICATE_VERIFY_FAILED" in text or "certificate verify failed" in text: if "CERTIFICATE_VERIFY_FAILED" in text or "certificate verify failed" in text:
detail = "Das Zertifikat konnte nicht geprueft werden" detail = "Das Zertifikat konnte nicht geprüft werden"
if "self-signed" in text or "self signed" in text: if "self-signed" in text or "self signed" in text:
detail = "Der Lizenzserver benutzt ein selbstsigniertes Zertifikat" detail = "Der Lizenzserver benutzt ein selbstsigniertes Zertifikat"
return ( return (
@@ -94,7 +94,7 @@ def describe_transport_error(exc: BaseException, endpoint: str) -> str:
"einer anerkannten Stelle." "einer anerkannten Stelle."
) )
if "Name or service not known" in text or "nodename nor servname" in text: if "Name or service not known" in text or "nodename nor servname" in text:
return f"Der Name im Endpunkt ({endpoint}) laesst sich nicht aufloesen." return f"Der Name im Endpunkt ({endpoint}) lässt sich nicht auflösen."
if "Connection refused" in text: if "Connection refused" in text:
return f"{endpoint} nimmt keine Verbindung an." return f"{endpoint} nimmt keine Verbindung an."
if "timed out" in text or "timeout" in text.lower(): if "timed out" in text or "timeout" in text.lower():
@@ -257,8 +257,8 @@ class LicenseManager:
def require(self, feature: str) -> None: def require(self, feature: str) -> None:
if not self.feature_allowed(feature): if not self.feature_allowed(feature):
raise LicenseError( raise LicenseError(
f"{LICENSED_FEATURES.get(feature, feature)} ist ohne gueltige, " f"{LICENSED_FEATURES.get(feature, feature)} ist ohne gültige, "
"aktivierte Lizenz nicht moeglich." "aktivierte Lizenz nicht möglich."
) )
# ------------------------------------------------------------------ # # ------------------------------------------------------------------ #
@@ -284,7 +284,7 @@ class LicenseManager:
try: try:
payload = json.loads(text) payload = json.loads(text)
except ValueError as exc: except ValueError as exc:
raise LicenseError("Die Datei ist kein gueltiges JSON.") from exc raise LicenseError("Die Datei ist kein gültiges JSON.") from exc
try: try:
document, client_key = lic.split_bundle(payload) document, client_key = lic.split_bundle(payload)
@@ -294,20 +294,20 @@ class LicenseManager:
try: try:
lic.verify_license(document) # innere Konsistenz lic.verify_license(document) # innere Konsistenz
except lic.LicensingError as exc: except lic.LicensingError as exc:
raise LicenseError(f"Die Lizenzdatei ist ungueltig: {exc}") from exc raise LicenseError(f"Die Lizenzdatei ist ungültig: {exc}") from exc
master_pubkey = str(document.get("master_pubkey") or "") master_pubkey = str(document.get("master_pubkey") or "")
known = self.trusted_keys(conn) known = self.trusted_keys(conn)
if known and master_pubkey not in set(known) and not allow_new_issuer: if known and master_pubkey not in set(known) and not allow_new_issuer:
raise LicenseError( raise LicenseError(
"Diese Lizenz wurde von einem anderen Lizenzserver ausgestellt " "Diese Lizenz wurde von einem anderen Lizenzserver ausgestellt "
f"(Schluessel {lic.key_id(master_pubkey)}). Ein Administrator muss den " f"(Schlüssel {lic.key_id(master_pubkey)}). Ein Administrator muss den "
"Wechsel des Ausstellers ausdruecklich bestaetigen." "Wechsel des Ausstellers ausdrücklich bestätigen."
) )
if not client_key and not self.key_path.is_file(): if not client_key and not self.key_path.is_file():
raise LicenseError( raise LicenseError(
"Der Datei fehlt der Client-Schluessel. Bitte das vollstaendige " "Der Datei fehlt der Client-Schlüssel. Bitte das vollständige "
"Lizenz-Bundle des Lizenzservers verwenden." "Lizenz-Bundle des Lizenzservers verwenden."
) )
@@ -332,7 +332,7 @@ class LicenseManager:
actor=actor, actor=actor,
action="license.issuer_pinned", action="license.issuer_pinned",
target=lic.key_id(master_pubkey), target=lic.key_id(master_pubkey),
detail="Trust on first use" if not known else "Aussteller-Wechsel bestaetigt", detail="Trust on first use" if not known else "Aussteller-Wechsel bestätigt",
severity="notice", severity="notice",
) )
@@ -397,11 +397,11 @@ class LicenseManager:
self, action: str, *, hostname: str = "", report: dict[str, Any] | None = None self, action: str, *, hostname: str = "", report: dict[str, Any] | None = None
) -> dict[str, Any]: ) -> dict[str, Any]:
if not self._state.valid: if not self._state.valid:
raise LicenseError("Es ist keine gueltige Lizenz installiert.") raise LicenseError("Es ist keine gültige Lizenz installiert.")
key = self.client_key() key = self.client_key()
if not key: if not key:
raise LicenseError( raise LicenseError(
"Der Client-Schluessel dieser Lizenz fehlt. Bitte die Lizenzdatei erneut importieren." "Der Client-Schlüssel dieser Lizenz fehlt. Bitte die Lizenzdatei erneut importieren."
) )
return lic.build_client_request( return lic.build_client_request(
action, self._state.document, key, hostname=hostname, report=report action, self._state.document, key, hostname=hostname, report=report
@@ -442,8 +442,8 @@ class LicenseManager:
) )
else: else:
reason = ( reason = (
"In dieser Lizenz ist kein Lizenzserver hinterlegt -- sie kann sich nur " "In dieser Lizenz ist kein Lizenzserver hinterlegt – sie kann sich nur "
"ueber den Offline-Weg aktivieren." "über den Offline-Weg aktivieren."
) )
# Offline-Weg: Anfrage merken -- **mit** dem Code. Zwischen Anfrage und # Offline-Weg: Anfrage merken -- **mit** dem Code. Zwischen Anfrage und
@@ -475,7 +475,7 @@ class LicenseManager:
"code": code, "code": code,
"message": ( "message": (
(reason + " " if reason else "") (reason + " " if reason else "")
+ "Der Offline-Weg steht offen: uebermitteln Sie den angezeigten Code an " + "Der Offline-Weg steht offen: übermitteln Sie den angezeigten Code an "
"Ihren Ansprechpartner und tragen Sie die Antwort hier ein." "Ihren Ansprechpartner und tragen Sie die Antwort hier ein."
), ),
} }
@@ -527,7 +527,7 @@ class LicenseManager:
current_seq=current_seq, current_seq=current_seq,
) )
except lic.LicensingError as exc: except lic.LicensingError as exc:
raise LicenseError(f"Die Antwort des Lizenzservers ist nicht gueltig: {exc}") from exc raise LicenseError(f"Die Antwort des Lizenzservers ist nicht gültig: {exc}") from exc
status = str(response.get("status") or "") status = str(response.get("status") or "")
detail = str(response.get("detail") or "") detail = str(response.get("detail") or "")
@@ -597,7 +597,7 @@ class LicenseManager:
actor=actor, actor=actor,
) )
self.settings.set(conn, SETTING_HEARTBEAT_ERROR, "", actor=actor) self.settings.set(conn, SETTING_HEARTBEAT_ERROR, "", actor=actor)
message = "Verbindung zum Lizenzserver bestaetigt." message = "Verbindung zum Lizenzserver bestätigt."
self.settings.set(conn, SETTING_PENDING, "", actor=actor) self.settings.set(conn, SETTING_PENDING, "", actor=actor)
# Ein erfolgreicher Heartbeat laeuft alle paar Minuten und ist kein # Ein erfolgreicher Heartbeat laeuft alle paar Minuten und ist kein
@@ -648,7 +648,7 @@ class LicenseManager:
url: str, payload: dict[str, Any], *, timeout: int, ca_pem: str = "" url: str, payload: dict[str, Any], *, timeout: int, ca_pem: str = ""
) -> dict[str, Any]: ) -> dict[str, Any]:
if not url.startswith(("https://", "http://")): if not url.startswith(("https://", "http://")):
raise ValueError("Ungueltiger Endpunkt") raise ValueError("Ungültiger Endpunkt")
data = json.dumps(payload).encode("utf-8") data = json.dumps(payload).encode("utf-8")
request = urllib.request.Request( # noqa: S310 - Schema oben geprueft request = urllib.request.Request( # noqa: S310 - Schema oben geprueft
url, url,
@@ -748,7 +748,7 @@ class LicenseManager:
} }
if not state.valid: if not state.valid:
return { return {
"label": "Lizenz ungueltig", "label": "Lizenz ungültig",
"css_class": "status-pill--danger", "css_class": "status-pill--danger",
"dot": "1", "dot": "1",
"dot_class": "status-pill__dot--pulse", "dot_class": "status-pill__dot--pulse",
@@ -773,29 +773,29 @@ class LicenseManager:
"dot": "1", "dot": "1",
"dot_class": "status-pill__dot--pulse", "dot_class": "status-pill__dot--pulse",
"title": ( "title": (
"Die Kulanzfrist laeuft noch, Funktionen bleiben vorerst verfuegbar." "Die Kulanzfrist läuft noch, Funktionen bleiben vorerst verfügbar."
if status.grace_active if status.grace_active
else "Lizenzpflichtige Funktionen sind abgeschaltet." else "Lizenzpflichtige Funktionen sind abgeschaltet."
), ),
} }
if status.expiring_soon: if status.expiring_soon:
return { return {
"label": f"Lizenz laeuft in {status.days_left} Tagen ab", "label": f"Lizenz läuft in {status.days_left} Tagen ab",
"css_class": "status-pill--warning", "css_class": "status-pill--warning",
"dot": "1", "dot": "1",
"dot_class": "status-pill__dot--pulse", "dot_class": "status-pill__dot--pulse",
"title": "Bitte rechtzeitig verlaengern.", "title": "Bitte rechtzeitig verlängern.",
} }
# Zwischen der Warnschwelle und 90 Tagen: gruen, rein als Hinweis. Wer # Zwischen der Warnschwelle und 90 Tagen: gruen, rein als Hinweis. Wer
# eine Verlaengerung im Voraus plant, will das Datum sehen, ohne dass es # eine Verlaengerung im Voraus plant, will das Datum sehen, ohne dass es
# nach einem Problem aussieht. # nach einem Problem aussieht.
if status.days_left is not None and 0 < status.days_left <= EXPIRY_HINT_DAYS: if status.days_left is not None and 0 < status.days_left <= EXPIRY_HINT_DAYS:
return { return {
"label": f"Lizenz laeuft in {status.days_left} Tagen ab", "label": f"Lizenz läuft in {status.days_left} Tagen ab",
"css_class": "", "css_class": "",
"dot": "1", "dot": "1",
"dot_class": "", "dot_class": "",
"title": "Nur ein Hinweis -- es ist nichts eingeschraenkt.", "title": "Nur ein Hinweis – es ist nichts eingeschränkt.",
} }
if state.last_heartbeat_at: if state.last_heartbeat_at:
try: try:
@@ -809,6 +809,6 @@ class LicenseManager:
"css_class": "status-pill--warning", "css_class": "status-pill--warning",
"dot": "1", "dot": "1",
"dot_class": "", "dot_class": "",
"title": "Rein informativ -- es wird nichts abgeschaltet.", "title": "Rein informativ – es wird nichts abgeschaltet.",
} }
return None return None
@@ -94,7 +94,7 @@ def init_app(app: Flask, *, log_dir: Path, debug: bool, json_logs: bool = False)
except OSError: except OSError:
pass pass
except OSError as exc: # pragma: no cover - z. B. fehlende Rechte except OSError as exc: # pragma: no cover - z. B. fehlende Rechte
root.warning("Konnte app.log nicht oeffnen: %s", exc) root.warning("Konnte app.log nicht öffnen: %s", exc)
# Zugriffsprotokoll separat: eine Zeile pro Anfrage, ohne Stacktraces. # Zugriffsprotokoll separat: eine Zeile pro Anfrage, ohne Stacktraces.
access = logging.getLogger(ACCESS_LOGGER) access = logging.getLogger(ACCESS_LOGGER)
@@ -176,9 +176,9 @@ class MigrationRunner:
if record is not None: if record is not None:
if record["checksum"] != migration.checksum: if record["checksum"] != migration.checksum:
logger.warning( logger.warning(
"Migration %s/%s (%s) wurde nachtraeglich geaendert " "Migration %s/%s (%s) wurde nachträglich geändert "
"(Pruefsumme %s != %s). Bereits angewendete Migrationen werden " "(Prüfsumme %s != %s). Bereits angewendete Migrationen werden "
"nicht erneut ausgefuehrt -- neue Aenderungen brauchen eine neue " "nicht erneut ausgeführt – neue Änderungen brauchen eine neue "
"Versionsnummer.", "Versionsnummer.",
component, component,
migration.version, migration.version,
@@ -210,7 +210,7 @@ class MigrationRunner:
raise raise
logger.error( logger.error(
"Migration %s/%s (%s) konnte wegen bestehender Altdaten nicht " "Migration %s/%s (%s) konnte wegen bestehender Altdaten nicht "
"angewendet werden. Der Start laeuft weiter; die Daten muessen " "angewendet werden. Der Start läuft weiter; die Daten müssen "
"manuell bereinigt werden.", "manuell bereinigt werden.",
component, component,
migration.version, migration.version,
+14 -14
View File
@@ -19,7 +19,7 @@ USERS = Resource(
actions=(A.VIEW, A.CREATE, A.EDIT, A.DELETE), actions=(A.VIEW, A.CREATE, A.EDIT, A.DELETE),
hint="Konten anlegen, sperren, Gruppen zuordnen", hint="Konten anlegen, sperren, Gruppen zuordnen",
action_hints={ action_hints={
A.EDIT: "Stammdaten, Sperre und Gruppenzuordnung aendern.", A.EDIT: "Stammdaten, Sperre und Gruppenzuordnung ändern.",
A.DELETE: "Konto in den Papierkorb legen (Verzeichniskonten sofort entfernen).", A.DELETE: "Konto in den Papierkorb legen (Verzeichniskonten sofort entfernen).",
}, },
) )
@@ -30,7 +30,7 @@ GROUPS = Resource(
actions=(A.VIEW, A.CREATE, A.EDIT, A.DELETE), actions=(A.VIEW, A.CREATE, A.EDIT, A.DELETE),
hint="Rechtematrix pflegen", hint="Rechtematrix pflegen",
action_hints={ action_hints={
A.EDIT: "Rechte vergeben -- nur Rechte, die man selbst besitzt.", A.EDIT: "Rechte vergeben – nur Rechte, die man selbst besitzt.",
}, },
) )
@@ -39,7 +39,7 @@ SETTINGS_SYSTEM = Resource(
label="Systemeinstellungen", label="Systemeinstellungen",
actions=(A.VIEW, A.EDIT, A.EXECUTE), actions=(A.VIEW, A.EDIT, A.EXECUTE),
hint="Hostname, Zeitzone, Protokollrotation, Navigation", hint="Hostname, Zeitzone, Protokollrotation, Navigation",
action_hints={A.EXECUTE: "Systemaktionen ausloesen (Dienste, Netzwerk uebernehmen)."}, action_hints={A.EXECUTE: "Systemaktionen auslösen (Dienste, Netzwerk übernehmen)."},
) )
SETTINGS_LICENSE = Resource( SETTINGS_LICENSE = Resource(
@@ -55,7 +55,7 @@ SETTINGS_DIRECTORY = Resource(
actions=(A.VIEW, A.EDIT, A.EXECUTE, A.SECRETS), actions=(A.VIEW, A.EDIT, A.EXECUTE, A.SECRETS),
hint="Anbindung und Gruppen-Zuordnung", hint="Anbindung und Gruppen-Zuordnung",
action_hints={ action_hints={
A.EXECUTE: "Verbindungstest ausfuehren.", A.EXECUTE: "Verbindungstest ausführen.",
A.SECRETS: "Bind-Passwort des Dienstkontos setzen.", A.SECRETS: "Bind-Passwort des Dienstkontos setzen.",
}, },
) )
@@ -65,7 +65,7 @@ SETTINGS_WEB = Resource(
label="Webserver und TLS", label="Webserver und TLS",
actions=(A.VIEW, A.EDIT, A.EXECUTE), actions=(A.VIEW, A.EDIT, A.EXECUTE),
hint="Domain, Zertifikat, HSTS", hint="Domain, Zertifikat, HSTS",
action_hints={A.EXECUTE: "Konfiguration uebernehmen, Zertifikat anfordern."}, action_hints={A.EXECUTE: "Konfiguration übernehmen, Zertifikat anfordern."},
) )
SETTINGS_NETWORK = Resource( SETTINGS_NETWORK = Resource(
@@ -77,13 +77,13 @@ SETTINGS_NETWORK = Resource(
BACKUP = Resource( BACKUP = Resource(
key="backup", key="backup",
label="Sicherung und Uebertragung", label="Sicherung und Übertragung",
actions=(A.VIEW, A.EXECUTE, A.EXPORT, A.SECRETS), actions=(A.VIEW, A.EXECUTE, A.EXPORT, A.SECRETS),
hint="Verschluesselter Export und Import", hint="Verschlüsselter Export und Import",
action_hints={ action_hints={
A.EXECUTE: "Eine Sicherung einspielen.", A.EXECUTE: "Eine Sicherung einspielen.",
A.EXPORT: "Sicherung erzeugen und herunterladen.", A.EXPORT: "Sicherung erzeugen und herunterladen.",
A.SECRETS: "Zugangsdaten in die Sicherung einschliessen.", A.SECRETS: "Zugangsdaten in die Sicherung einschließen.",
}, },
) )
@@ -91,10 +91,10 @@ TRASH = Resource(
key="trash", key="trash",
label="Papierkorb", label="Papierkorb",
actions=(A.VIEW, A.EDIT, A.DELETE), actions=(A.VIEW, A.EDIT, A.DELETE),
hint="Geloeschtes wiederherstellen oder endgueltig entfernen", hint="Gelöschtes wiederherstellen oder endgültig entfernen",
action_hints={ action_hints={
A.EDIT: "Eintraege wiederherstellen.", A.EDIT: "Einträge wiederherstellen.",
A.DELETE: "Eintraege endgueltig entfernen.", A.DELETE: "Einträge endgültig entfernen.",
}, },
) )
@@ -102,7 +102,7 @@ DIAGNOSTICS = Resource(
key="diagnostics", key="diagnostics",
label="Diagnose", label="Diagnose",
actions=(A.VIEW,), actions=(A.VIEW,),
hint="Schemastand, Protokollintegritaet, Schluesselrechte", hint="Schemastand, Protokollintegrität, Schlüsselrechte",
) )
LOGS_LIVE = Resource( LOGS_LIVE = Resource(
@@ -121,9 +121,9 @@ LOGS_HISTORY = Resource(
LOGS_AUDIT = Resource( LOGS_AUDIT = Resource(
key="logs_audit", key="logs_audit",
label="Aenderungsprotokoll", label="Änderungsprotokoll",
actions=(A.VIEW, A.EXPORT), actions=(A.VIEW, A.EXPORT),
hint="Wer hat wann was geaendert", hint="Wer hat wann was geändert",
) )
+34 -10
View File
@@ -42,16 +42,16 @@ class Action(str, Enum):
#: Kuerzel und Erklaerung je Aktion -- Kopfzeile der Rechtematrix. #: Kuerzel und Erklaerung je Aktion -- Kopfzeile der Rechtematrix.
ACTION_META: dict[Action, tuple[str, str, str]] = { ACTION_META: dict[Action, tuple[str, str, str]] = {
Action.VIEW: ("R", "Ansehen", "Datensaetze und Seiten dieses Bereichs lesen."), Action.VIEW: ("R", "Ansehen", "Datensätze und Seiten dieses Bereichs lesen."),
Action.CREATE: ("C", "Anlegen", "Neue Datensaetze erstellen."), Action.CREATE: ("C", "Anlegen", "Neue Datensätze erstellen."),
Action.EDIT: ("U", "Bearbeiten", "Bestehende Datensaetze aendern -- ohne Loeschen."), Action.EDIT: ("U", "Bearbeiten", "Bestehende Datensätze ändern – ohne Löschen."),
Action.DELETE: ("D", "Loeschen", "Datensaetze loeschen bzw. endgueltig entfernen."), Action.DELETE: ("D", "Loeschen", "Datensätze löschen bzw. endgültig entfernen."),
Action.EXECUTE: ("X", "Ausfuehren", "Aktionen ausloesen (Neustart, Dienste, Jobs, Import)."), Action.EXECUTE: ("X", "Ausfuehren", "Aktionen auslösen (Neustart, Dienste, Jobs, Import)."),
Action.EXPORT: ("E", "Exportieren", "Daten aus dem System heraustragen (Datei-Download)."), Action.EXPORT: ("E", "Exportieren", "Daten aus dem System heraustragen (Datei-Download)."),
Action.SECRETS: ( Action.SECRETS: (
"S", "S",
"Geheimnisse", "Geheimnisse",
"Passwoerter und Schluessel im Klartext sehen oder mitnehmen.", "Passwörter und Schlüssel im Klartext sehen oder mitnehmen.",
), ),
} }
@@ -74,6 +74,11 @@ class Resource:
hint: str = "" hint: str = ""
#: Abweichende Erklaerung einzelner Aktionen (z. B. was "Ausfuehren" hier meint). #: Abweichende Erklaerung einzelner Aktionen (z. B. was "Ausfuehren" hier meint).
action_hints: Mapping[Action, str] = field(default_factory=dict) action_hints: Mapping[Action, str] = field(default_factory=dict)
#: Aktionen, die hier **keine** frische Passwortbestaetigung verlangen,
#: obwohl sie in SENSITIVE_ACTIONS stehen. Bewusst hier und nicht als
#: stilles Weglassen in der Route: eine Ausnahme von einer
#: Sicherheitsregel gehoert dorthin, wo man sie sieht und begruenden muss.
ohne_wiederanmeldung: tuple[Action, ...] = ()
def permission(self, action: Action) -> str: def permission(self, action: Action) -> str:
return f"{self.key}.{action.value}" return f"{self.key}.{action.value}"
@@ -127,18 +132,18 @@ class PermissionTree:
seen_keys: set[str] = set() seen_keys: set[str] = set()
for area in self.areas: for area in self.areas:
if area.key in seen_keys: if area.key in seen_keys:
raise ValueError(f"Doppelter Bereichsschluessel: {area.key}") raise ValueError(f"Doppelter Bereichsschlüssel: {area.key}")
seen_keys.add(area.key) seen_keys.add(area.key)
self._by_key[area.key] = area self._by_key[area.key] = area
self._all.append(area.view_key) self._all.append(area.view_key)
for resource in area.resources: for resource in area.resources:
if resource.key in seen_keys: if resource.key in seen_keys:
raise ValueError(f"Doppelter Ressourcenschluessel: {resource.key}") raise ValueError(f"Doppelter Ressourcenschlüssel: {resource.key}")
seen_keys.add(resource.key) seen_keys.add(resource.key)
if Action.VIEW not in resource.actions: if Action.VIEW not in resource.actions:
raise ValueError( raise ValueError(
f"Ressource {resource.key} muss die Aktion 'view' anbieten -- " f"Ressource {resource.key} muss die Aktion 'view' anbieten – "
"sie ist Voraussetzung fuer jede andere Aktion." "sie ist Voraussetzung für jede andere Aktion."
) )
self._resources[resource.key] = (area, resource) self._resources[resource.key] = (area, resource)
for permission in resource.permissions(): for permission in resource.permissions():
@@ -159,6 +164,25 @@ class PermissionTree:
def resource(self, key: str) -> tuple[Area, Resource] | None: def resource(self, key: str) -> tuple[Area, Resource] | None:
return self._resources.get(key) return self._resources.get(key)
def verlangt_wiederanmeldung(self, permission: str) -> bool:
"""Braucht dieses Recht eine frische Passwortbestaetigung?
Die Antwort steht im Modell (``SENSITIVE_ACTIONS``) und nicht in der
Route -- sonst vergisst sie die naechste, die dazukommt.
"""
resource_key, _, action_value = permission.rpartition(".")
entry = self._resources.get(resource_key)
if entry is None:
return False
_, resource = entry
try:
action = Action(action_value)
except ValueError:
return False
if action in resource.ohne_wiederanmeldung:
return False
return action in SENSITIVE_ACTIONS
def label_for(self, permission: str) -> str: def label_for(self, permission: str) -> str:
"""Menschenlesbare Beschreibung eines Rechteschluessels -- fuer Audit-Logs.""" """Menschenlesbare Beschreibung eines Rechteschluessels -- fuer Audit-Logs."""
if permission.endswith(".view") and permission[:-5] in self._by_key: if permission.endswith(".view") and permission[:-5] in self._by_key:
@@ -132,7 +132,7 @@ def guard_no_amplification(
labels = ", ".join(tree.label_for(p) for p in missing[:6]) labels = ", ".join(tree.label_for(p) for p in missing[:6])
more = "" if len(missing) <= 6 else f" (und {len(missing) - 6} weitere)" more = "" if len(missing) <= 6 else f" (und {len(missing) - 6} weitere)"
raise PermissionDenied( raise PermissionDenied(
f"Sie koennen kein {what} vergeben, das Sie selbst nicht besitzen: {labels}{more}." f"Sie können kein {what} vergeben, das Sie selbst nicht besitzen: {labels}{more}."
) )
@@ -160,7 +160,7 @@ def create_group(
if existing["deleted_at"]: if existing["deleted_at"]:
raise ValidationError( raise ValidationError(
f"Eine Gruppe namens {clean_name!r} liegt im Papierkorb. " f"Eine Gruppe namens {clean_name!r} liegt im Papierkorb. "
"Stellen Sie sie wieder her oder loeschen Sie sie endgueltig." "Stellen Sie sie wieder her oder löschen Sie sie endgültig."
) )
raise ValidationError(f"Eine Gruppe namens {clean_name!r} existiert bereits.") raise ValidationError(f"Eine Gruppe namens {clean_name!r} existiert bereits.")
@@ -205,7 +205,7 @@ def update_group(
if name is not None and name.strip() != group.name: if name is not None and name.strip() != group.name:
if group.is_system: if group.is_system:
raise PermissionDenied("Systemgruppen koennen nicht umbenannt werden.") raise PermissionDenied("Systemgruppen können nicht umbenannt werden.")
clean = name.strip() clean = name.strip()
if not clean: if not clean:
raise ValidationError("Der Gruppenname darf nicht leer sein.") raise ValidationError("Der Gruppenname darf nicht leer sein.")
@@ -224,13 +224,13 @@ def update_group(
"UPDATE groups SET description=?, updated_at=? WHERE id=?", "UPDATE groups SET description=?, updated_at=? WHERE id=?",
(description.strip(), _now(), group_id), (description.strip(), _now(), group_id),
) )
changes.append("Beschreibung geaendert") changes.append("Beschreibung geändert")
if permissions is not None: if permissions is not None:
if group.is_system and not unlock_system: if group.is_system and not unlock_system:
raise PermissionDenied( raise PermissionDenied(
"Die Rechte dieser Systemgruppe sind gesperrt. " "Die Rechte dieser Systemgruppe sind gesperrt. "
"Sie muessen sie zuerst ausdruecklich freischalten." "Sie müssen sie zuerst ausdrücklich freischalten."
) )
wanted = tree.sanitize(permissions) wanted = tree.sanitize(permissions)
added = sorted(set(wanted) - set(group.permissions)) added = sorted(set(wanted) - set(group.permissions))
@@ -240,7 +240,7 @@ def update_group(
_write_permissions(conn, group_id, wanted) _write_permissions(conn, group_id, wanted)
conn.execute("UPDATE groups SET updated_at=? WHERE id=?", (_now(), group_id)) conn.execute("UPDATE groups SET updated_at=? WHERE id=?", (_now(), group_id))
if added: if added:
changes.append("Rechte hinzugefuegt: " + ", ".join(tree.label_for(p) for p in added)) changes.append("Rechte hinzugefügt: " + ", ".join(tree.label_for(p) for p in added))
if removed: if removed:
changes.append("Rechte entfernt: " + ", ".join(tree.label_for(p) for p in removed)) changes.append("Rechte entfernt: " + ", ".join(tree.label_for(p) for p in removed))
@@ -252,7 +252,7 @@ def soft_delete_group(conn: sqlite3.Connection, group_id: int) -> None:
if group is None: if group is None:
raise ValidationError("Diese Gruppe existiert nicht.") raise ValidationError("Diese Gruppe existiert nicht.")
if group.is_system: if group.is_system:
raise PermissionDenied("Systemgruppen koennen nicht geloescht werden.") raise PermissionDenied("Systemgruppen können nicht gelöscht werden.")
conn.execute("UPDATE groups SET deleted_at=? WHERE id=?", (_now(), group_id)) conn.execute("UPDATE groups SET deleted_at=? WHERE id=?", (_now(), group_id))
@@ -410,7 +410,7 @@ def guard_target_account(actor: Any, target: dict[str, Any]) -> None:
"""Ein Administratorkonto darf nur ein echter Administrator anfassen.""" """Ein Administratorkonto darf nur ein echter Administrator anfassen."""
if bool(target.get("is_admin")) and not getattr(actor, "is_admin", False): if bool(target.get("is_admin")) and not getattr(actor, "is_admin", False):
raise PermissionDenied( raise PermissionDenied(
"Administratorkonten koennen nur von Administratoren bearbeitet werden." "Administratorkonten können nur von Administratoren bearbeitet werden."
) )
@@ -420,8 +420,8 @@ def guard_last_admin(conn: sqlite3.Connection, user_id: int, *, action: str) ->
return return
if count_active_admins(conn, exclude_id=user_id) == 0: if count_active_admins(conn, exclude_id=user_id) == 0:
raise PermissionDenied( raise PermissionDenied(
f"{action} nicht moeglich: es muss mindestens ein entsperrtes " f"{action} nicht möglich: es muss mindestens ein entsperrtes "
"Administratorkonto uebrig bleiben." "Administratorkonto übrig bleiben."
) )
@@ -482,12 +482,12 @@ def create_user(
if existing["deleted_at"]: if existing["deleted_at"]:
raise ValidationError( raise ValidationError(
f"Der Benutzername {clean!r} liegt im Papierkorb. Stellen Sie das Konto " f"Der Benutzername {clean!r} liegt im Papierkorb. Stellen Sie das Konto "
"wieder her oder loeschen Sie es endgueltig." "wieder her oder löschen Sie es endgültig."
) )
raise ValidationError(f"Der Benutzername {clean!r} ist bereits vergeben.") raise ValidationError(f"Der Benutzername {clean!r} ist bereits vergeben.")
if is_admin and not getattr(actor, "is_admin", False): if is_admin and not getattr(actor, "is_admin", False):
raise PermissionDenied("Nur Administratoren koennen weitere Administratoren anlegen.") raise PermissionDenied("Nur Administratoren können weitere Administratoren anlegen.")
if auth_source == "local": if auth_source == "local":
problems = policy.check(password, username=clean) problems = policy.check(password, username=clean)
@@ -552,7 +552,7 @@ def update_user(
# ueberschrieben -- die Bearbeitung waere eine Illusion. # ueberschrieben -- die Bearbeitung waere eine Illusion.
raise ValidationError( raise ValidationError(
"Name und E-Mail eines Verzeichniskontos werden bei jeder Anmeldung " "Name und E-Mail eines Verzeichniskontos werden bei jeder Anmeldung "
"aus dem Verzeichnis uebernommen und koennen hier nicht geaendert werden." "aus dem Verzeichnis übernommen und können hier nicht geändert werden."
) )
fields: dict[str, Any] = {} fields: dict[str, Any] = {}
@@ -563,13 +563,13 @@ def update_user(
): ):
if value is not None and value.strip() != str(target.get(column) or ""): if value is not None and value.strip() != str(target.get(column) or ""):
fields[column] = value.strip() fields[column] = value.strip()
changes.append(f"{column} geaendert") changes.append(f"{column} geändert")
if is_admin is not None and bool(is_admin) != bool(target["is_admin"]): if is_admin is not None and bool(is_admin) != bool(target["is_admin"]):
if not getattr(actor, "is_admin", False): if not getattr(actor, "is_admin", False):
raise PermissionDenied("Das Admin-Kennzeichen kann nur ein Administrator aendern.") raise PermissionDenied("Das Admin-Kennzeichen kann nur ein Administrator ändern.")
if int(target["id"]) == int(getattr(actor, "id", 0)): if int(target["id"]) == int(getattr(actor, "id", 0)):
raise PermissionDenied("Sie koennen Ihr eigenes Admin-Kennzeichen nicht aendern.") raise PermissionDenied("Sie können Ihr eigenes Admin-Kennzeichen nicht ändern.")
if not is_admin: if not is_admin:
guard_last_admin(conn, user_id, action="Herabstufen") guard_last_admin(conn, user_id, action="Herabstufen")
fields["is_admin"] = int(bool(is_admin)) fields["is_admin"] = int(bool(is_admin))
@@ -577,7 +577,7 @@ def update_user(
if is_locked is not None and bool(is_locked) != bool(target["is_locked"]): if is_locked is not None and bool(is_locked) != bool(target["is_locked"]):
if int(target["id"]) == int(getattr(actor, "id", 0)): if int(target["id"]) == int(getattr(actor, "id", 0)):
raise PermissionDenied("Sie koennen Ihr eigenes Konto nicht sperren.") raise PermissionDenied("Sie können Ihr eigenes Konto nicht sperren.")
if is_locked: if is_locked:
guard_last_admin(conn, user_id, action="Sperren") guard_last_admin(conn, user_id, action="Sperren")
fields["is_locked"] = int(bool(is_locked)) fields["is_locked"] = int(bool(is_locked))
@@ -595,7 +595,7 @@ def update_user(
after = {int(gid) for gid in group_ids} after = {int(gid) for gid in group_ids}
if before != after: if before != after:
set_user_groups(conn, user_id, sorted(after), actor=actor, tree=tree) set_user_groups(conn, user_id, sorted(after), actor=actor, tree=tree)
changes.append("Gruppenzuordnung geaendert") changes.append("Gruppenzuordnung geändert")
return changes return changes
@@ -613,7 +613,7 @@ def set_password(
raise ValidationError("Dieses Konto existiert nicht.") raise ValidationError("Dieses Konto existiert nicht.")
if str(row.get("auth_source")) == "ldap": if str(row.get("auth_source")) == "ldap":
raise ValidationError( raise ValidationError(
"Verzeichniskonten haben hier kein Passwort -- es wird im Verzeichnisdienst gepflegt." "Verzeichniskonten haben hier kein Passwort – es wird im Verzeichnisdienst gepflegt."
) )
problems = policy.check(new_password, username=str(row["username"])) problems = policy.check(new_password, username=str(row["username"]))
if problems: if problems:
@@ -631,7 +631,7 @@ def soft_delete_user(conn: sqlite3.Connection, user_id: int, *, actor: Any) -> s
raise ValidationError("Dieses Konto existiert nicht (mehr).") raise ValidationError("Dieses Konto existiert nicht (mehr).")
guard_target_account(actor, target) guard_target_account(actor, target)
if int(target["id"]) == int(getattr(actor, "id", 0)): if int(target["id"]) == int(getattr(actor, "id", 0)):
raise PermissionDenied("Sie koennen Ihr eigenes Konto nicht loeschen.") raise PermissionDenied("Sie können Ihr eigenes Konto nicht löschen.")
guard_last_admin(conn, user_id, action="Loeschen") guard_last_admin(conn, user_id, action="Loeschen")
if str(target.get("auth_source")) == "ldap": if str(target.get("auth_source")) == "ldap":
@@ -112,7 +112,7 @@ def protect() -> None:
if not expected or not provided: if not expected or not provided:
abort(403, description="Das Sicherheitstoken des Formulars fehlt. Bitte Seite neu laden.") abort(403, description="Das Sicherheitstoken des Formulars fehlt. Bitte Seite neu laden.")
if not hmac.compare_digest(str(expected), str(provided)): if not hmac.compare_digest(str(expected), str(provided)):
abort(403, description="Das Sicherheitstoken des Formulars ist ungueltig. Bitte Seite neu laden.") abort(403, description="Das Sicherheitstoken des Formulars ist ungültig. Bitte Seite neu laden.")
def exempt(view): def exempt(view):
@@ -65,10 +65,10 @@ class PasswordPolicy:
if classes < self.require_classes: if classes < self.require_classes:
problems.append( problems.append(
f"Mindestens {self.require_classes} verschiedene Zeichenarten " f"Mindestens {self.require_classes} verschiedene Zeichenarten "
"(Klein-/Grossbuchstaben, Ziffern, Sonderzeichen)." "(Klein-/Großbuchstaben, Ziffern, Sonderzeichen)."
) )
if normalized.lower() in _FORBIDDEN: if normalized.lower() in _FORBIDDEN:
problems.append("Dieses Passwort ist zu haeufig und damit unsicher.") problems.append("Dieses Passwort ist zu häufig und damit unsicher.")
if username and len(username) >= 3 and username.lower() in normalized.lower(): if username and len(username) >= 3 and username.lower() in normalized.lower():
problems.append("Das Passwort darf den Benutzernamen nicht enthalten.") problems.append("Das Passwort darf den Benutzernamen nicht enthalten.")
if len(set(normalized)) <= 3 and normalized: if len(set(normalized)) <= 3 and normalized:
@@ -26,7 +26,7 @@ import sqlite3
from datetime import datetime, timedelta, timezone from datetime import datetime, timedelta, timezone
from typing import Any from typing import Any
from flask import Flask, Request, Response from flask import Flask, Request, Response, g
from flask import request as aktuelle_anfrage from flask import request as aktuelle_anfrage
from flask.sessions import SessionInterface, SessionMixin from flask.sessions import SessionInterface, SessionMixin
from werkzeug.datastructures import CallbackDict from werkzeug.datastructures import CallbackDict
@@ -163,6 +163,7 @@ class SqliteSessionInterface(SessionInterface):
idle_minutes: int, idle_minutes: int,
absolute_hours: int, absolute_hours: int,
idle_lookup: Any = None, idle_lookup: Any = None,
absolute_lookup: Any = None,
) -> None: ) -> None:
self.database = database self.database = database
self.cookie_name = cookie_name self.cookie_name = cookie_name
@@ -172,6 +173,16 @@ class SqliteSessionInterface(SessionInterface):
#: Zeit zur Laufzeit einstellbar, ohne den Dienst neu zu starten -- und #: Zeit zur Laufzeit einstellbar, ohne den Dienst neu zu starten -- und
#: ohne eine zweite Datenbankverbindung, denn sie bekommt die offene. #: ohne eine zweite Datenbankverbindung, denn sie bekommt die offene.
self._idle_lookup = idle_lookup self._idle_lookup = idle_lookup
self._absolute_lookup = absolute_lookup
def _absolute(self, conn: Any) -> int:
"""Gesamtdauer in Stunden -- einstellbar wie die Leerlaufgrenze."""
if self._absolute_lookup is None:
return self.absolute_hours
try:
return int(self._absolute_lookup(conn)) or self.absolute_hours
except Exception: # pragma: no cover - Einstellung unlesbar
return self.absolute_hours
def _idle(self, conn: Any) -> int: def _idle(self, conn: Any) -> int:
if self._idle_lookup is None: if self._idle_lookup is None:
@@ -209,6 +220,11 @@ class SqliteSessionInterface(SessionInterface):
"WHERE id=?", "WHERE id=?",
(_iso(now), sid), (_iso(now), sid),
) )
# Fuer die Anmeldeseite vermerken, **warum** sie erscheint. Ohne
# das steht dort entweder nichts -- dann wundert sich, wer
# gerade noch angemeldet war -- oder eine Meldung, die jeder
# Besucher zu sehen bekommt und die deshalb nichts sagt.
g.session_expired = True
return self.session_class(new=True) return self.session_class(new=True)
try: try:
@@ -275,7 +291,10 @@ class SqliteSessionInterface(SessionInterface):
payload = json.dumps(dict(session), separators=(",", ":"), sort_keys=True) payload = json.dumps(dict(session), separators=(",", ":"), sort_keys=True)
with self.database.session() as conn, self.database.transaction(conn): with self.database.session() as conn, self.database.transaction(conn):
if session.new or not session.sid: if session.new or not session.sid:
session.sid = secrets.token_urlsafe(SESSION_ID_BYTES) # Eine vorhandene ID nicht wegwerfen: nach einem Wechsel beim
# Anmelden steht sie schon, und andere haben sie sich gemerkt.
if not session.sid:
session.sid = secrets.token_urlsafe(SESSION_ID_BYTES)
conn.execute( conn.execute(
"INSERT INTO user_sessions " "INSERT INTO user_sessions "
"(id, user_id, created_at, last_seen_at, idle_expires_at, " "(id, user_id, created_at, last_seen_at, idle_expires_at, "
@@ -287,7 +306,7 @@ class SqliteSessionInterface(SessionInterface):
_iso(now), _iso(now),
_iso(now), _iso(now),
_iso(now + timedelta(minutes=self._idle(conn))), _iso(now + timedelta(minutes=self._idle(conn))),
_iso(now + timedelta(hours=self.absolute_hours)), _iso(now + timedelta(hours=self._absolute(conn))),
session.get("_ip", ""), session.get("_ip", ""),
session.get("_ua", ""), session.get("_ua", ""),
payload, payload,
@@ -366,7 +385,12 @@ def rotate(conn: sqlite3.Connection, session: ServerSession, *, reason: str = "p
"UPDATE user_sessions SET revoked_at=?, revoked_reason=? WHERE id=?", "UPDATE user_sessions SET revoked_at=?, revoked_reason=? WHERE id=?",
(_iso(_now()), reason, old_sid), (_iso(_now()), reason, old_sid),
) )
session.sid = "" # Die neue ID entsteht **jetzt** und nicht erst beim Speichern der Antwort.
# Zwischen beidem laufen die Anmeldehaken, und einer davon bindet die
# Dateifreigaben ein und schreibt sich die Sitzung dazu. Mit einer leeren
# Sitzung hielt der Aufraeumer diese Einbindungen fuer verwaist und loeste
# sie binnen Minuten wieder.
session.sid = secrets.token_urlsafe(SESSION_ID_BYTES)
session.new = True session.new = True
session.modified = True session.modified = True
return old_sid return old_sid
@@ -412,7 +412,10 @@
und beide sind rot. */ und beide sind rot. */
.pill--critical { .pill--critical {
background: var(--danger); background: var(--danger);
color: var(--surface-0); /* Weiss wie bei .btn--danger. Vorher stand hier ein Token, das es nicht
gibt -- die Deklaration war damit unwirksam, und die Schrift blieb in
der Textfarbe: dunkelgrau auf Rot. */
color: #fff;
font-weight: 700; font-weight: 700;
} }
.pill--critical::before { background: currentColor; } .pill--critical::before { background: currentColor; }
@@ -682,7 +685,10 @@
.modal__sub { font-size: var(--fs-xs); color: var(--text-faint); margin-top: 2px; } .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 /* Eine Datei-Vorschau soll die ganze Flaeche des Dialogs nutzen; der
uebliche Innenabstand des Dialogkoerpers wuerde sie einrahmen. */ 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 { .modal__caption {
padding: 0 var(--sp-5) var(--sp-2); padding: 0 var(--sp-5) var(--sp-2);
color: var(--text-muted); color: var(--text-muted);
@@ -695,7 +701,177 @@
width: 100%; width: 100%;
height: min(70dvh, 760px); height: min(70dvh, 760px);
border: 0; border: 0;
background: var(--surface-0); /* Die Vorschau zeigt fremde Dokumente; ein neutraler Grund gehoert
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 /* Der Dateiname ist ein Knopf (er oeffnet die Vorschau), soll aber wie ein
Link aussehen -- ein Knopf mit Rahmen mitten in einer Tabellenzeile waere Link aussehen -- ein Knopf mit Rahmen mitten in einer Tabellenzeile waere
@@ -849,7 +1025,10 @@
width: 17px; width: 17px;
height: 17px; height: 17px;
border-radius: 5px; border-radius: 5px;
background: var(--surface-0); /* Dunkler als die Karte -- genau das war die Absicht: "dunkler heisst,
es gibt das Recht nicht". Mit dem nicht existierenden --surface-0 war
das Feld durchsichtig und damit so hell wie die Karte. */
background: var(--canvas);
border: 1px solid var(--border-subtle); border: 1px solid var(--border-subtle);
box-shadow: inset 0 1px 2px rgb(0 0 0 / 28%); box-shadow: inset 0 1px 2px rgb(0 0 0 / 28%);
vertical-align: -3px; vertical-align: -3px;
@@ -943,9 +1122,15 @@
display: grid; display: grid;
place-items: center; place-items: center;
padding: var(--sp-6); padding: var(--sp-6);
/* Zwei warme Kreise, Geometrie und Staerke aus der Fassung 1.x
uebernommen: oben links deutlich, unten rechts angedeutet. Die vorige
Fassung setzte den zweiten Kreis auf --info und 10 % -- ein kaltes
Blau, das man auf dunklem Grund nicht sah und das nicht zur Marke
gehoert. Ueber color-mix statt festem rgba, damit es im hellen Thema
nicht schmutzig wird. */
background: background:
radial-gradient(1100px 520px at 12% -10%, color-mix(in srgb, var(--accent) 12%, transparent), transparent 70%), radial-gradient(circle at 15% 15%, color-mix(in srgb, var(--accent) 14%, transparent), transparent 45%),
radial-gradient(900px 480px at 100% 110%, color-mix(in srgb, var(--info) 10%, transparent), transparent 70%), radial-gradient(circle at 85% 85%, color-mix(in srgb, var(--accent) 8%, transparent), transparent 40%),
var(--canvas); var(--canvas);
} }
/* Die Wortmarke liegt hinter allem (z-index 0), die Anmeldebox davor. /* Die Wortmarke liegt hinter allem (z-index 0), die Anmeldebox davor.
@@ -1026,24 +1211,35 @@
color: var(--text-faint); color: var(--text-faint);
} }
.auth-page { position: relative; overflow: hidden; align-content: center; } /* 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
Stapel kann nicht kollidieren, und die Seite darf notfalls scrollen. */
.auth-stack {
display: flex;
flex-direction: column;
align-items: center;
gap: var(--sp-8);
width: 100%;
}
.auth-backdrop { .auth-backdrop {
position: absolute;
top: 4vh;
left: 50%;
transform: translateX(-50%);
width: min(72vw, 880px); width: min(72vw, 880px);
pointer-events: none; pointer-events: none;
z-index: 0; /* Volle Deckung: solange die Marke hinter der Karte lag, war sie ein
opacity: 0.5; Wasserzeichen und musste zurueckhaltend sein. Jetzt steht sie ueber der
Karte und ist das Erste, was man sieht -- halbtransparent sah sie dort
nur blass aus. Die Farben kommen ohnehin aus zurueckhaltenden Token. */
opacity: 1;
} }
.auth-backdrop svg { width: 100%; height: auto; display: block; } .auth-backdrop svg { width: 100%; height: auto; display: block; }
@media (max-width: 640px) { @media (max-width: 640px) {
.auth-backdrop { width: min(90vw, 460px); opacity: 0.32; } .auth-backdrop { width: min(90vw, 460px); }
} }
/* Wer wenig Bewegung/Reize moechte, bekommt das Motiv dezenter. */ /* Wer wenig Bewegung/Reize moechte, bekommt das Motiv dezenter. */
/* Wer weniger Reize moechte, bekommt die Marke ruhiger -- aber nicht
unsichtbar: sie ist die Ueberschrift der Seite. */
@media (prefers-contrast: more) { @media (prefers-contrast: more) {
.auth-backdrop { opacity: 0.18; } .auth-backdrop { opacity: 0.75; }
} }
.auth-card { .auth-card {
@@ -1138,3 +1334,250 @@
.hide-sm { display: none !important; } .hide-sm { display: none !important; }
} }
} }
/* -- Sortierbare Liste (Navigationsreihenfolge) --------------------------- */
/* Symbol und Beschriftung stehen in einer Zeile -- so wie im Menue selbst.
Vorher brach die Beschriftung unter das Symbol, und die Liste sah aus wie
etwas anderes als das, was sie sortiert. */
.reorder {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 4px;
}
.reorder__row {
display: flex;
align-items: center;
gap: 10px;
padding: 6px 10px;
border: 1px solid var(--border);
border-radius: var(--r-sm);
background: var(--surface-2);
cursor: grab;
}
.reorder__row > .flex-1 {
display: flex;
align-items: center;
gap: 8px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.reorder__row.is-dragging {
opacity: 0.5;
}
.reorder__grip {
color: var(--text-muted);
display: inline-flex;
}
.reorder__pos {
min-width: 1.6em;
text-align: right;
font-variant-numeric: tabular-nums;
color: var(--text-muted);
font-size: var(--fs-xs);
}
.reorder__moves {
display: inline-flex;
gap: 2px;
}
.reorder__group {
list-style: none;
}
.reorder--child {
margin: 4px 0 4px 28px;
}
.reorder--child .reorder__row {
background: transparent;
border-style: dashed;
}
/* -- Unterschrift --------------------------------------------------------- */
/* Die Zeichenflaeche braucht "touch-action: none": sonst scrollt ein iPad die
Seite, statt einen Strich zu zeichnen -- und es sieht aus, als reagierte das
Feld nicht. */
.viewer__sign {
display: flex;
flex-direction: column;
gap: 8px;
padding: 10px 12px;
border-top: 1px solid var(--border);
background: var(--surface-2);
}
.viewer__signfelder {
display: flex;
gap: 10px;
flex-wrap: wrap;
}
.viewer__signpad {
width: 100%;
height: 160px;
border: 1px dashed var(--border-strong, var(--border));
border-radius: var(--r-sm);
background: #fff;
touch-action: none;
cursor: crosshair;
}
.viewer__signaktionen {
display: flex;
align-items: center;
gap: 8px;
justify-content: flex-end;
flex-wrap: wrap;
}
.viewer__pdfscroll--setzen {
cursor: crosshair;
}
.viewer__pdfscroll--setzen .page {
outline: 2px dashed var(--accent, #c8791f);
outline-offset: -2px;
}
/* Die gesetzte Unterschrift liegt auf der Seite -- in Prozent, damit sie beim
Zoomen an ihrer Stelle bleibt. Sie nimmt Zeiger an: verschieben mit dem
Block, Groesse mit dem Griff unten rechts, entfernen mit dem Kreuz.
"touch-action: none" ist auf dem iPad die Bedingung dafuer, dass ein Finger
zieht statt die Seite zu scrollen. */
.viewer__unterschrift {
position: absolute;
z-index: 3;
cursor: grab;
touch-action: none;
container-type: inline-size;
border: 1px dashed transparent;
}
.viewer__unterschrift:hover,
.viewer__unterschrift.is-fassend {
border-color: var(--accent);
cursor: grabbing;
}
.viewer__unterschrift img {
display: block;
width: 100%;
pointer-events: none;
}
/* Dieselbe Rechnung wie im PDF (Breite durch 18): auf dem Schirm steht, was
spaeter in der Datei steht. */
/* "width: max-content": der Strich ist so lang wie der Text darunter -- so
wie im PDF. Waere er so breit wie der Block, zeigte die Vorschau etwas
anderes als die Datei. */
.viewer__unterschrifttext {
font-size: 5.6cqw;
line-height: 1.15;
color: #111;
border-top: 1px solid rgba(0, 0, 0, 0.35);
padding-top: 1px;
pointer-events: none;
white-space: nowrap;
width: max-content;
}
.viewer__unterschriftgriff,
.viewer__unterschriftweg {
position: absolute;
width: 16px;
height: 16px;
padding: 0;
border: 1px solid var(--accent);
border-radius: 3px;
background: var(--surface-1);
color: var(--accent);
font-size: 11px;
line-height: 1;
opacity: 0;
cursor: nwse-resize;
}
.viewer__unterschriftweg {
cursor: pointer;
top: -8px;
right: -8px;
}
.viewer__unterschriftgriff {
right: -8px;
bottom: -8px;
}
.viewer__unterschrift:hover .viewer__unterschriftgriff,
.viewer__unterschrift:hover .viewer__unterschriftweg,
.viewer__unterschrift.is-fassend .viewer__unterschriftgriff {
opacity: 1;
}
/* Auf einem Tastbildschirm gibt es kein "hover" -- dort stehen die Knoepfe
immer, sonst kaeme man nie an sie heran. */
@media (hover: none) {
.viewer__unterschriftgriff,
.viewer__unterschriftweg {
opacity: 1;
}
}
/* -- Ergebnis der Signaturpruefung ---------------------------------------- */
/* Der Schleier liegt ueber der Seite und nimmt keine Klicks an: ein
unterschriebenes Dokument ist flach, aber bei einem Irrtum soll die
Kennzeichnung nicht auch noch die Bedienung blockieren. */
.viewer__pdf--unveraendert {
box-shadow: inset 0 0 0 3px var(--success);
}
.viewer__pdf--unveraendert::after,
.viewer__pdf--veraendert::after {
content: "";
position: absolute;
inset: 0;
pointer-events: none;
z-index: 4;
}
.viewer__pdf--unveraendert::after {
background: rgba(47, 158, 68, 0.1);
}
.viewer__pdf--veraendert {
box-shadow: inset 0 0 0 3px var(--danger);
}
.viewer__pdf--veraendert::after {
background: rgba(201, 42, 42, 0.08);
}
.viewer__pruefung {
margin: 0;
padding: 6px 12px;
font-size: var(--fs-xs);
border-top: 1px solid var(--border);
}
.viewer__pruefung--gut {
color: var(--success);
background: rgba(47, 158, 68, 0.12);
}
.viewer__pruefung--schlecht {
color: var(--danger);
background: rgba(201, 42, 42, 0.12);
}
@@ -109,7 +109,7 @@ export function showToast(message, type = "info", { timeout = 6000 } = {}) {
const close = document.createElement("button"); const close = document.createElement("button");
close.type = "button"; close.type = "button";
close.className = "toast__close"; close.className = "toast__close";
close.setAttribute("aria-label", "Meldung schliessen"); close.setAttribute("aria-label", "Meldung schließen");
close.textContent = "×"; close.textContent = "×";
toast.append(body, close); toast.append(body, close);
toastStack.appendChild(toast); toastStack.appendChild(toast);
@@ -293,7 +293,7 @@ function initConfirmations() {
event.preventDefault(); event.preventDefault();
event.stopPropagation(); event.stopPropagation();
pending = trigger; pending = trigger;
titleNode.textContent = trigger.dataset.confirmTitle || "Bitte bestaetigen"; titleNode.textContent = trigger.dataset.confirmTitle || "Bitte bestätigen";
textNode.textContent = trigger.dataset.confirm; textNode.textContent = trigger.dataset.confirm;
okButton.textContent = trigger.dataset.confirmOk || "Fortfahren"; okButton.textContent = trigger.dataset.confirmOk || "Fortfahren";
okButton.className = `btn btn--${trigger.dataset.confirmStyle || "danger"}`; okButton.className = `btn btn--${trigger.dataset.confirmStyle || "danger"}`;
@@ -497,14 +497,14 @@ function initAutoRefresh() {
nameField.value = entry.name || ""; nameField.value = entry.name || "";
dnField.value = entry.dn || ""; dnField.value = entry.dn || "";
results.hidden = true; results.hidden = true;
show(`Uebernommen: ${entry.name || entry.dn}`); show(`Übernommen: ${entry.name || entry.dn}`);
}; };
const search = async () => { const search = async () => {
const term = query.value.trim(); const term = query.value.trim();
if (term.length < 2) { show("Bitte mindestens zwei Zeichen eingeben."); return; } if (term.length < 2) { show("Bitte mindestens zwei Zeichen eingeben."); return; }
button.disabled = true; button.disabled = true;
show("Suche laeuft ..."); show("Suche läuft ...");
results.innerHTML = ""; results.innerHTML = "";
try { try {
const url = `${picker.dataset.searchUrl}?q=${encodeURIComponent(term)}`; const url = `${picker.dataset.searchUrl}?q=${encodeURIComponent(term)}`;
@@ -532,10 +532,10 @@ function initAutoRefresh() {
results.appendChild(row); results.appendChild(row);
}); });
results.hidden = false; results.hidden = false;
show(`${entries.length} Treffer -- zum Uebernehmen anklicken.`); show(`${entries.length} Treffer – zum Übernehmen anklicken.`);
} catch (error) { } catch (error) {
results.hidden = true; results.hidden = true;
show("Die Suche ist fehlgeschlagen. Laeuft die Verbindung zum Verzeichnis?"); show("Die Suche ist fehlgeschlagen. Läuft die Verbindung zum Verzeichnis?");
} finally { } finally {
button.disabled = false; button.disabled = false;
} }
@@ -720,7 +720,7 @@ function initClipboard() {
timeout: 2500, timeout: 2500,
}); });
} catch { } catch {
showToast("Kopieren nicht moeglich -- bitte manuell markieren.", "warning"); showToast("Kopieren nicht möglich – bitte manuell markieren.", "warning");
} }
}); });
} }
@@ -856,6 +856,81 @@ function initTileFilters() {
}); });
} }
/* -- Reihenfolge aendern -------------------------------------------------- */
/**
* Eine Liste, deren Reihenfolge man aendern kann -- ohne Bibliothek.
*
* Die Reihenfolge steckt in der Reihenfolge der versteckten Felder: das
* Formular schickt sie als wiederholtes Feld, und der Server liest sie als
* Liste. Verschiebt man einen Eintrag, verschiebt sich das Feld mit ihm.
*
* Zwei Wege, weil es zwei Arten von Geraeten gibt: Ziehen mit der Maus und
* zwei Knoepfe je Zeile. Die Knoepfe sind nicht die Notloesung -- auf einem
* Tablet sind sie der einzige Weg, der zuverlaessig funktioniert, und mit der
* Tastatur ebenso.
*/
function initReorder() {
$$('[data-behavior="reorder"]').forEach((liste) => {
// Nur die eigenen Zeilen: Gruppen enthalten wiederum eine sortierbare
// Liste, und deren Zeilen gehoeren der inneren Liste, nicht dieser.
const zeilen = () => Array.from(liste.querySelectorAll(":scope > [data-reorder-item]"));
const nummerieren = () => {
zeilen().forEach((zeile, index) => {
const nummer = zeile.querySelector("[data-reorder-position-text]");
if (nummer) nummer.textContent = String(index + 1);
const hoch = zeile.querySelector('[data-reorder-move="up"]');
const runter = zeile.querySelector('[data-reorder-move="down"]');
if (hoch) hoch.disabled = index === 0;
if (runter) runter.disabled = index === zeilen().length - 1;
});
};
liste.addEventListener("click", (event) => {
const knopf = event.target.closest("[data-reorder-move]");
if (!knopf) return;
const zeile = knopf.closest("[data-reorder-item]");
if (!zeile || zeile.parentElement !== liste) return;
event.preventDefault();
if (knopf.dataset.reorderMove === "up") {
const davor = zeile.previousElementSibling;
if (davor) liste.insertBefore(zeile, davor);
} else {
const danach = zeile.nextElementSibling;
if (danach) liste.insertBefore(danach, zeile);
}
nummerieren();
zeile.querySelector(`[data-reorder-move="${knopf.dataset.reorderMove}"]`)?.focus();
});
let gezogen = null;
liste.addEventListener("dragstart", (event) => {
const zeile = event.target.closest("[data-reorder-item]");
if (!zeile || zeile.parentElement !== liste) return;
gezogen = zeile;
gezogen.classList.add("is-dragging");
if (event.dataTransfer) event.dataTransfer.effectAllowed = "move";
});
liste.addEventListener("dragend", () => {
if (gezogen) gezogen.classList.remove("is-dragging");
gezogen = null;
nummerieren();
});
liste.addEventListener("dragover", (event) => {
if (!gezogen) return;
event.preventDefault();
const ziel = event.target.closest("[data-reorder-item]");
if (!ziel || ziel === gezogen || ziel.parentElement !== liste) return;
const kasten = ziel.getBoundingClientRect();
const obereHaelfte = event.clientY < kasten.top + kasten.height / 2;
liste.insertBefore(gezogen, obereHaelfte ? ziel : ziel.nextElementSibling);
});
nummerieren();
});
}
/* -- Start ---------------------------------------------------------------- */ /* -- Start ---------------------------------------------------------------- */
/** /**
@@ -893,8 +968,23 @@ function boot() {
initPalette(); initPalette();
initSubmitGuards(); initSubmitGuards();
initTileFilters(); initTileFilters();
initReorder();
initLogTails(); initLogTails();
unsavedGuard.init(); 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") { if (document.readyState === "loading") {
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,11 @@
pdf.js 4.10.38 -- https://github.com/mozilla/pdf.js
Aus dem npm-Paket pdfjs-dist; nur build/pdf.min.mjs, build/pdf.worker.min.mjs,
web/pdf_viewer.mjs, web/pdf_viewer.css, web/images und standard_fonts.
Apache-2.0, siehe LICENSE.
Die drei Skriptdateien sind von .mjs auf .js umbenannt: die mime.types von
nginx kennen .mjs nicht, und mit application/octet-stream lehnt jeder Browser
ein Modul ab. Der Inhalt ist unveraendert.
Warum eingebettet und nicht von einem CDN: TESM laeuft in abgeschotteten
Netzen. Was die Oberflaeche braucht, muss im Paket liegen.
@@ -0,0 +1,177 @@
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
@@ -0,0 +1 @@
4.10.38
@@ -0,0 +1,3 @@
<svg width="12" height="13" viewBox="0 0 12 13" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M5.375 7.625V11.875C5.375 12.0408 5.44085 12.1997 5.55806 12.3169C5.67527 12.4342 5.83424 12.5 6 12.5C6.16576 12.5 6.32473 12.4342 6.44194 12.3169C6.55915 12.1997 6.625 12.0408 6.625 11.875V7.625L7.125 7.125H11.375C11.5408 7.125 11.6997 7.05915 11.8169 6.94194C11.9342 6.82473 12 6.66576 12 6.5C12 6.33424 11.9342 6.17527 11.8169 6.05806C11.6997 5.94085 11.5408 5.875 11.375 5.875H7.125L6.625 5.375V1.125C6.625 0.95924 6.55915 0.800269 6.44194 0.683058C6.32473 0.565848 6.16576 0.5 6 0.5C5.83424 0.5 5.67527 0.565848 5.55806 0.683058C5.44085 0.800269 5.375 0.95924 5.375 1.125V5.375L4.875 5.875H0.625C0.45924 5.875 0.300269 5.94085 0.183058 6.05806C0.065848 6.17527 0 6.33424 0 6.5C0 6.66576 0.065848 6.82473 0.183058 6.94194C0.300269 7.05915 0.45924 7.125 0.625 7.125H4.762L5.375 7.625Z" fill="black"/>
</svg>

After

Width:  |  Height:  |  Size: 920 B

@@ -0,0 +1,3 @@
<svg width="17" height="16" viewBox="0 0 17 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M3.49073 1.3015L3.30873 2.1505C3.29349 2.22246 3.25769 2.28844 3.20568 2.34045C3.15368 2.39246 3.08769 2.42826 3.01573 2.4435L2.16673 2.6255C1.76473 2.7125 1.76473 3.2865 2.16673 3.3725L3.01573 3.5555C3.08769 3.57074 3.15368 3.60654 3.20568 3.65855C3.25769 3.71056 3.29349 3.77654 3.30873 3.8485L3.49073 4.6975C3.57773 5.0995 4.15173 5.0995 4.23773 4.6975L4.42073 3.8485C4.43598 3.77654 4.47177 3.71056 4.52378 3.65855C4.57579 3.60654 4.64178 3.57074 4.71373 3.5555L5.56173 3.3725C5.96373 3.2855 5.96373 2.7115 5.56173 2.6255L4.71273 2.4435C4.64083 2.42814 4.57491 2.3923 4.52292 2.34031C4.47093 2.28832 4.43509 2.2224 4.41973 2.1505L4.23773 1.3015C4.15073 0.8995 3.57673 0.8995 3.49073 1.3015ZM10.8647 13.9995C10.4853 14.0056 10.1158 13.8782 9.82067 13.6397C9.52553 13.4013 9.32347 13.0667 9.24973 12.6945L8.89273 11.0275C8.83676 10.7687 8.70738 10.5316 8.52009 10.3445C8.3328 10.1574 8.09554 10.0282 7.83673 9.9725L6.16973 9.6155C5.38873 9.4465 4.86473 8.7975 4.86473 7.9995C4.86473 7.2015 5.38873 6.5525 6.16973 6.3845L7.83673 6.0275C8.09551 5.97135 8.33267 5.84193 8.51992 5.65468C8.70716 5.46744 8.83658 5.23028 8.89273 4.9715L9.25073 3.3045C9.41773 2.5235 10.0667 1.9995 10.8647 1.9995C11.6627 1.9995 12.3117 2.5235 12.4797 3.3045L12.8367 4.9715C12.9507 5.4995 13.3647 5.9135 13.8927 6.0265L15.5597 6.3835C16.3407 6.5525 16.8647 7.2015 16.8647 7.9995C16.8647 8.7975 16.3407 9.4465 15.5597 9.6145L13.8927 9.9715C13.6337 10.0275 13.3963 10.157 13.209 10.3445C13.0217 10.5319 12.8925 10.7694 12.8367 11.0285L12.4787 12.6945C12.4054 13.0667 12.2036 13.4014 11.9086 13.6399C11.6135 13.8784 11.2441 14.0057 10.8647 13.9995ZM10.8647 3.2495C10.7667 3.2495 10.5337 3.2795 10.4727 3.5655L10.1147 5.2335C10.0081 5.72777 9.76116 6.18082 9.40361 6.53837C9.04606 6.89593 8.59301 7.14283 8.09873 7.2495L6.43173 7.6065C6.14573 7.6685 6.11473 7.9015 6.11473 7.9995C6.11473 8.0975 6.14573 8.3305 6.43173 8.3925L8.09873 8.7495C8.59301 8.85617 9.04606 9.10307 9.40361 9.46062C9.76116 9.81817 10.0081 10.2712 10.1147 10.7655L10.4727 12.4335C10.5337 12.7195 10.7667 12.7495 10.8647 12.7495C10.9627 12.7495 11.1957 12.7195 11.2567 12.4335L11.6147 10.7665C11.7212 10.272 11.9681 9.81878 12.3256 9.46103C12.6832 9.10329 13.1363 8.85624 13.6307 8.7495L15.2977 8.3925C15.5837 8.3305 15.6147 8.0975 15.6147 7.9995C15.6147 7.9015 15.5837 7.6685 15.2977 7.6065L13.6307 7.2495C13.1365 7.14283 12.6834 6.89593 12.3259 6.53837C11.9683 6.18082 11.7214 5.72777 11.6147 5.2335L11.2567 3.5655C11.1957 3.2795 10.9627 3.2495 10.8647 3.2495ZM3.30873 12.1505L3.49073 11.3015C3.57673 10.8995 4.15073 10.8995 4.23773 11.3015L4.41973 12.1505C4.43509 12.2224 4.47093 12.2883 4.52292 12.3403C4.57491 12.3923 4.64083 12.4281 4.71273 12.4435L5.56173 12.6255C5.96373 12.7115 5.96373 13.2855 5.56173 13.3725L4.71273 13.5545C4.64083 13.5699 4.57491 13.6057 4.52292 13.6577C4.47093 13.7097 4.43509 13.7756 4.41973 13.8475L4.23773 14.6965C4.15173 15.0985 3.57773 15.0985 3.49073 14.6965L3.30873 13.8475C3.29337 13.7756 3.25754 13.7097 3.20555 13.6577C3.15356 13.6057 3.08764 13.5699 3.01573 13.5545L2.16673 13.3725C1.76473 13.2865 1.76473 12.7125 2.16673 12.6255L3.01573 12.4435C3.08769 12.4283 3.15368 12.3925 3.20568 12.3405C3.25769 12.2884 3.29349 12.2225 3.30873 12.1505Z" fill="black"/>
</svg>

After

Width:  |  Height:  |  Size: 3.3 KiB

@@ -0,0 +1,3 @@
<svg width="12" height="13" viewBox="0 0 12 13" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M6 0.5C5.21207 0.5 4.43185 0.655195 3.7039 0.956723C2.97595 1.25825 2.31451 1.70021 1.75736 2.25736C1.20021 2.81451 0.758251 3.47595 0.456723 4.2039C0.155195 4.93185 0 5.71207 0 6.5C0 7.28793 0.155195 8.06815 0.456723 8.7961C0.758251 9.52405 1.20021 10.1855 1.75736 10.7426C2.31451 11.2998 2.97595 11.7417 3.7039 12.0433C4.43185 12.3448 5.21207 12.5 6 12.5C7.5913 12.5 9.11742 11.8679 10.2426 10.7426C11.3679 9.61742 12 8.0913 12 6.5C12 4.9087 11.3679 3.38258 10.2426 2.25736C9.11742 1.13214 7.5913 0.5 6 0.5ZM5.06 8.9L2.9464 6.7856C2.85273 6.69171 2.80018 6.56446 2.80033 6.43183C2.80048 6.29921 2.85331 6.17207 2.9472 6.0784C3.04109 5.98473 3.16834 5.93218 3.30097 5.93233C3.43359 5.93248 3.56073 5.98531 3.6544 6.0792L5.3112 7.7368L8.3464 4.7008C8.44109 4.6109 8.56715 4.56153 8.69771 4.56322C8.82827 4.56492 8.95301 4.61754 9.04534 4.70986C9.13766 4.80219 9.19028 4.92693 9.19198 5.05749C9.19367 5.18805 9.1443 5.31411 9.0544 5.4088L5.5624 8.9H5.06Z" fill="#FBFBFE"/>
</svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

@@ -0,0 +1,30 @@
<!-- This Source Code Form is subject to the terms of the Mozilla Public
- License, v. 2.0. If a copy of the MPL was not distributed with this
- file, You can obtain one at http://mozilla.org/MPL/2.0/. -->
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" width="16" height="16">
<style>
@media not (prefers-reduced-motion) {
@keyframes loadingRotate {
from { rotate: 0; } to { rotate: 360deg }
}
#circle-arrows {
animation: loadingRotate 1.8s linear infinite;
transform-origin: 50% 50%;
}
#hourglass {
display: none;
}
}
@media (prefers-reduced-motion) {
#circle-arrows {
display: none;
}
}
</style>
<path id="circle-arrows" d="M9 5.528c0 .42.508.63.804.333l2.528-2.528a.47.47 0 0 0 0-.666L9.805.14A.471.471 0 0 0 9 .472v1.866A5.756 5.756 0 0 0 2.25 8c0 .942.232 1.83.635 2.615l1.143-1.143A4.208 4.208 0 0 1 3.75 8 4.254 4.254 0 0 1 8 3.75c.345 0 .68.042 1 .122v1.656zM7 10.472v1.656c.32.08.655.122 1 .122A4.254 4.254 0 0 0 12.25 8c0-.52-.107-1.013-.279-1.474l1.143-1.143c.404.786.636 1.674.636 2.617A5.756 5.756 0 0 1 7 13.662v1.866a.47.47 0 0 1-.804.333l-2.528-2.528a.47.47 0 0 1 0-.666l2.528-2.528a.47.47 0 0 1 .804.333z"/>
<g id="hourglass">
<path d="M13,1 C13.5522847,1 14,1.44771525 14,2 C14,2.55228475 13.5522847,3 13,3 L12.9854217,2.99990801 C12.9950817,3.16495885 13,3.33173274 13,3.5 C13,5.24679885 10.9877318,6.01090495 10.9877318,8.0017538 C10.9877318,9.99260264 13,10.7536922 13,12.5 C13,12.6686079 12.9950617,12.8357163 12.985363,13.0010943 L13,13 C13.5522847,13 14,13.4477153 14,14 C14,14.5522847 13.5522847,15 13,15 L3,15 C2.44771525,15 2,14.5522847 2,14 C2,13.4477153 2.44771525,13 3,13 L3.01463704,13.0010943 C3.00493827,12.8357163 3,12.6686079 3,12.5 C3,10.7536922 4.9877318,9.99260264 5,8.0017538 C5.0122682,6.01090495 3,5.24679885 3,3.5 C3,3.33173274 3.00491834,3.16495885 3.01457832,2.99990801 L3,3 C2.44771525,3 2,2.55228475 2,2 C2,1.44771525 2.44771525,1 3,1 L13,1 Z M10.987,3 L5.012,3 L5.00308914,3.24815712 C5.00103707,3.33163368 5,3.4155948 5,3.5 C5,5.36125069 6.99153646,6.01774089 6.99153646,8.0017538 C6.99153646,9.98576671 5,10.6393737 5,12.5 L5.00307746,12.7513676 L5.01222201,12.9998392 L5.60191711,12.9988344 L6.0425138,12.2959826 C7.02362731,10.7653275 7.67612271,10 8,10 C8.37014547,10 9.16950644,10.9996115 10.3980829,12.9988344 L10.987778,12.9998392 C10.9958674,12.8352104 11,12.66849 11,12.5 C11,10.6393737 8.98689779,10.0147381 8.98689779,8.0017538 C8.98689779,5.98876953 11,5.36125069 11,3.5 L10.9969109,3.24815712 L10.987,3 Z"/>
<path d="M6,4 L10,4 C8.95166016,6 8.28499349,7 8,7 C7.71500651,7 7.04833984,6 6,4 Z"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 2.6 KiB

@@ -0,0 +1,3 @@
<svg width="17" height="16" viewBox="0 0 17 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M8.78182 2.63903C8.58882 2.28803 8.25782 2.25003 8.12482 2.25003C7.99019 2.24847 7.85771 2.28393 7.74185 2.35253C7.62599 2.42113 7.5312 2.52023 7.46782 2.63903L1.97082 12.639C1.90673 12.7528 1.87406 12.8816 1.87617 13.0122C1.87828 13.1427 1.91509 13.2704 1.98282 13.382C2.04798 13.4951 2.14207 13.5888 2.25543 13.6535C2.36879 13.7182 2.49732 13.7515 2.62782 13.75H13.6218C13.7523 13.7515 13.8809 13.7182 13.9942 13.6535C14.1076 13.5888 14.2017 13.4951 14.2668 13.382C14.3346 13.2704 14.3714 13.1427 14.3735 13.0122C14.3756 12.8816 14.3429 12.7528 14.2788 12.639L8.78182 2.63903ZM6.37282 2.03703C6.75182 1.34603 7.43882 1.00003 8.12482 1.00003C8.48341 0.997985 8.83583 1.09326 9.14454 1.2757C9.45325 1.45814 9.70668 1.72092 9.87782 2.03603L15.3748 12.036C16.1078 13.369 15.1438 15 13.6228 15H2.62782C1.10682 15 0.141823 13.37 0.875823 12.037L6.37282 2.03703ZM8.74982 9.06203C8.74982 9.22779 8.68397 9.38676 8.56676 9.50397C8.44955 9.62118 8.29058 9.68703 8.12482 9.68703C7.95906 9.68703 7.80009 9.62118 7.68288 9.50397C7.56566 9.38676 7.49982 9.22779 7.49982 9.06203V5.62503C7.49982 5.45927 7.56566 5.3003 7.68288 5.18309C7.80009 5.06588 7.95906 5.00003 8.12482 5.00003C8.29058 5.00003 8.44955 5.06588 8.56676 5.18309C8.68397 5.3003 8.74982 5.45927 8.74982 5.62503V9.06203ZM7.74982 12L7.49982 11.75V11L7.74982 10.75H8.49982L8.74982 11V11.75L8.49982 12H7.74982Z" fill="black"/>
</svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

@@ -0,0 +1,11 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg
xmlns="http://www.w3.org/2000/svg"
width="40"
height="40"
viewBox="0 0 40 40">
<path
d="M 1.5006714,23.536225 6.8925879,18.994244 14.585721,26.037937 34.019683,4.5410479 38.499329,9.2235032 14.585721,35.458952 z"
id="path4"
style="fill:#ffff00;fill-opacity:1;stroke:#000000;stroke-width:1.25402856;stroke-opacity:1" />
</svg>

After

Width:  |  Height:  |  Size: 415 B

@@ -0,0 +1,16 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg
xmlns="http://www.w3.org/2000/svg"
height="40"
width="40"
viewBox="0 0 40 40">
<rect
style="fill:#ffff00;fill-opacity:1;fill-rule:evenodd;stroke:#000000;stroke-width:1;stroke-miterlimit:4;stroke-opacity:1;stroke-dasharray:none"
width="33.76017"
height="33.76017"
x="3.119915"
y="3.119915" />
<path
d="m 20.677967,8.54499 c -7.342801,0 -13.295293,4.954293 -13.295293,11.065751 0,2.088793 0.3647173,3.484376 1.575539,5.150563 L 6.0267418,31.45501 13.560595,29.011117 c 2.221262,1.387962 4.125932,1.665377 7.117372,1.665377 7.3428,0 13.295291,-4.954295 13.295291,-11.065753 0,-6.111458 -5.952491,-11.065751 -13.295291,-11.065751 z"
style="fill:#ffffff;fill-opacity:1;stroke:#000000;stroke-width:0.93031836;stroke-miterlimit:4;stroke-opacity:1;stroke-dasharray:none"/>
</svg>

After

Width:  |  Height:  |  Size: 883 B

@@ -0,0 +1,26 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg
xmlns="http://www.w3.org/2000/svg"
width="40"
height="40"
viewBox="0 0 40 40">
<g
transform="translate(0,-60)"
id="layer1">
<rect
width="36.460953"
height="34.805603"
x="1.7695236"
y="62.597198"
style="fill:#ffff00;fill-opacity:1;fill-rule:evenodd;stroke:#000000;stroke-width:1.30826771;stroke-opacity:1" />
<g
transform="matrix(0.88763677,0,0,0.88763677,2.2472646,8.9890584)">
<path
d="M 20,64.526342 C 11.454135,64.526342 4.5263421,71.454135 4.5263421,80 4.5263421,88.545865 11.454135,95.473658 20,95.473658 28.545865,95.473658 35.473658,88.545865 35.473658,80 35.473658,71.454135 28.545865,64.526342 20,64.526342 z m -0.408738,9.488564 c 3.527079,0 6.393832,2.84061 6.393832,6.335441 0,3.494831 -2.866753,6.335441 -6.393832,6.335441 -3.527079,0 -6.393832,-2.84061 -6.393832,-6.335441 0,-3.494831 2.866753,-6.335441 6.393832,-6.335441 z"
style="fill:#ffffff;fill-opacity:1;fill-rule:nonzero;stroke:#000000;stroke-width:1.02768445;stroke-miterlimit:4;stroke-opacity:1;stroke-dasharray:none" />
<path
d="m 7.2335209,71.819938 4.9702591,4.161823 c -1.679956,2.581606 -1.443939,6.069592 0.159325,8.677725 l -5.1263071,3.424463 c 0.67516,1.231452 3.0166401,3.547686 4.2331971,4.194757 l 3.907728,-4.567277 c 2.541952,1.45975 5.730694,1.392161 8.438683,-0.12614 l 3.469517,6.108336 c 1.129779,-0.44367 4.742234,-3.449633 5.416358,-5.003859 l -5.46204,-4.415541 c 1.44319,-2.424098 1.651175,-5.267515 0.557303,-7.748623 l 5.903195,-3.833951 C 33.14257,71.704996 30.616217,69.018606 29.02952,67.99296 l -4.118813,4.981678 C 22.411934,71.205099 18.900853,70.937534 16.041319,72.32916 l -3.595408,-5.322091 c -1.345962,0.579488 -4.1293881,2.921233 -5.2123901,4.812869 z m 8.1010311,3.426672 c 2.75284,-2.446266 6.769149,-2.144694 9.048998,0.420874 2.279848,2.56557 2.113919,6.596919 -0.638924,9.043185 -2.752841,2.446267 -6.775754,2.13726 -9.055604,-0.428308 -2.279851,-2.565568 -2.107313,-6.589485 0.64553,-9.035751 z"
style="fill:#000000;fill-opacity:1;stroke:none" />
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 2.1 KiB

@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg
xmlns="http://www.w3.org/2000/svg"
width="64"
height="64"
viewBox="0 0 64 64">
<path
d="M 32.003143,1.4044602 57.432701,62.632577 6.5672991,62.627924 z"
style="fill:#ffff00;fill-opacity:0.94117647;fill-rule:nonzero;stroke:#000000;stroke-width:1.00493038;stroke-miterlimit:4;stroke-opacity:1;stroke-dasharray:none" />
</svg>

After

Width:  |  Height:  |  Size: 408 B

@@ -0,0 +1,11 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg
xmlns="http://www.w3.org/2000/svg"
width="64"
height="64"
viewBox="0 0 64 64">
<path
d="M 25.470843,9.4933766 C 25.30219,12.141818 30.139101,14.445969 34.704831,13.529144 40.62635,12.541995 41.398833,7.3856498 35.97505,5.777863 31.400921,4.1549155 25.157674,6.5445892 25.470843,9.4933766 z M 4.5246282,17.652051 C 4.068249,11.832873 9.2742983,5.9270407 18.437379,3.0977088 29.751911,-0.87185184 45.495663,1.4008022 53.603953,7.1104009 c 9.275765,6.1889221 7.158128,16.2079421 -3.171076,21.5939521 -1.784316,1.635815 -6.380222,1.21421 -7.068351,3.186186 -1.04003,0.972427 -1.288046,2.050158 -1.232864,3.168203 1.015111,2.000108 -3.831548,1.633216 -3.270553,3.759574 0.589477,5.264544 -0.179276,10.53738 -0.362842,15.806257 -0.492006,2.184998 1.163456,4.574232 -0.734888,6.610642 -2.482919,2.325184 -7.30604,2.189143 -9.193497,-0.274767 -2.733688,-1.740626 -8.254447,-3.615254 -6.104247,-6.339626 3.468112,-1.708686 -2.116197,-3.449897 0.431242,-5.080274 5.058402,-1.39256 -2.393215,-2.304318 -0.146889,-4.334645 3.069198,-0.977415 2.056986,-2.518352 -0.219121,-3.540397 1.876567,-1.807151 1.484149,-4.868919 -2.565455,-5.942205 0.150866,-1.805474 2.905737,-4.136876 -1.679967,-5.20493 C 10.260902,27.882167 4.6872697,22.95045 4.5245945,17.652051 z"
id="path604"
style="fill:#ffff00;fill-opacity:1;stroke:#000000;stroke-width:1.72665179;stroke-opacity:1" />
</svg>

After

Width:  |  Height:  |  Size: 1.4 KiB

@@ -0,0 +1,11 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg
xmlns="http://www.w3.org/2000/svg"
width="64"
height="64"
viewBox="0 0 64 64">
<path
d="M 32.003143,10.913072 57.432701,53.086929 6.567299,53.083723 z"
id="path2985"
style="fill:#ffff00;fill-opacity:0.94117647;fill-rule:nonzero;stroke:#000000;stroke-width:0.83403099;stroke-miterlimit:4;stroke-opacity:1;stroke-dasharray:none" />
</svg>

After

Width:  |  Height:  |  Size: 426 B

@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg
xmlns="http://www.w3.org/2000/svg"
width="40"
height="40"
viewBox="0 0 40 40">
</svg>

After

Width:  |  Height:  |  Size: 158 B

Loaded 100 of 163 files, more files were not shown because too many files have changed in this diff. Show more