Compare commits

..
8 Commits
Author SHA1 Message Date
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
alientimandClaude Opus 5 635722089e rename.sh: aus einer Instanz die Hauptinstallation machen
Die laufenden Installationen hiessen "tesm-opus" und "tesm-license-opus" --
ein Instanzname aus der Zeit, in der sie neben der Fassung 1.x getestet wurden.
Jetzt heissen sie "tesm" und "tesm-license".

Umbenennen ist mehr als ein verschobenes Verzeichnis, und drei Stellen sind
leicht zu uebersehen:

* SITE_KEY in der Umgebungsdatei -- daraus entsteht der Name des
  Sitzungscookies. Bleibt er alt, heisst das Cookie weiter wie die alte
  Instanz: unauffaellig und falsch.
* web_cert_path/web_key_path/web_acme_webroot in der Datenbank enthalten den
  Namen. Ein Zertifikat unter dem alten Pfad findet nginx nicht mehr.
* server_name der nginx-Site. install.sh schreibt die Datei aus seiner Vorlage
  neu, sobald sich die alias-Pfade aendern -- und die aendern sich hier
  zwangslaeufig. Ohne den Namen aus der Datenbank stehen danach zwei Sites mit
  "server_name _" auf Port 80, und die erste gewinnt. web-setup braucht dafuer
  --apply; ohne ihn speichert es nur.

Zwei Fehler hat erst der Lauf am System gezeigt:

* Das venv wandert nicht mit. Seine Konsolenskripte tragen den absoluten Pfad
  im Shebang, und danach zeigt venv/bin/gunicorn auf einen Interpreter, den es
  nicht mehr gibt -- systemd meldet "No such file or directory" fuer eine
  Datei, die sichtbar da ist. Es wird jetzt verworfen und neu gebaut.
* Den Zielnamen belegt nicht nur /srv/<name>, sondern auch /var/log/<name>,
  der ACME-Pfad und die logrotate-Datei. Der Umzug brach mitten drin ab. Jetzt
  wandert alles, was den Namen belegt, in ein Archiv -- geloescht wird nichts,
  die Units und die Site der alten Fassung liegen darin unter systemd-alt/.

Ein Test vergleicht die Pfade, die install.sh unter dem Namen anlegt, mit
denen, die rename.sh kennt. Genau diese Luecke war der zweite Fehler; ohne den
Test faellt der naechste erst wieder auf einem Server auf.

Version 2.0.6.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-03 14:12:40 +02:00
alientimandClaude Opus 5 6dff1ed137 Banner: Einzahl, wo eines gemeint ist
"1 Clients online" liest man jeden Tag einmal zu oft.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-03 12:48:32 +02:00
alientimandClaude Opus 5 b4529a0e31 Anmeldebanner wieder in Betrieb -- ohne die Anwendung zu starten
Das Banner der Vorgaengerfassung hing an /srv/tesm/motd_banner.py und
importierte dafuer die ganze Flask-Anwendung samt deren venv. Nach dem
Umstieg zeigte es damit die Zahlen einer Datenbank, die niemand mehr fuellt --
eingefroren, aber unverdaechtig. Aufgefallen ist es erst, als der alte
Ueberwachungsdienst abgeschaltet wurde.

deploy/tesm-motd loest das anders: **nur Standardbibliothek**, kein venv, kein
Import der Anwendung, die Datenbank wird nur lesend geoeffnet (mode=ro). Ein
Lauf dauert Millisekunden statt eines Flask-Starts. install.sh legt das Skript
nach /usr/local/lib/tesm/ und schreibt /etc/update-motd.d/50-<name>, abgesichert
mit "timeout 3" und "|| true" -- ein Banner darf eine Anmeldung nie blockieren.
Ein Banner der Vorgaengerfassung wird entfernt, aber nur, wenn es genau auf
motd_banner.py zeigt.

Damit es nicht zwei Wahrheiten ueber "online" gibt, steht die Zaehlung der
Statusuebersicht jetzt als Konstante DASHBOARD_COUNT_SQL in monitor.py; das
Banner traegt sie wortgleich, und ein Hygienetest vergleicht beide. Dasselbe
fuer die Zeile unter der Wortmarke. Ein weiterer Test verbietet dem Banner
jeden Import ausserhalb der Standardbibliothek -- genau dieser Import war die
Ursache.

Der Lizenzserver bekommt sein eigenes Banner (Kunden und ausgestellte
Lizenzen). Eine Tagline hat er bewusst nicht: die Funktionsliste von TESM waere
dort falsch, und eine erfundene waere schlimmer als keine.

Version 2.0.5.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-03 12:44:21 +02:00
alientimandClaude Opus 5 313eb4bbf9 Leerlaufabmeldung: die zweite Schreibstelle und ein Tab, der das alte Skript faehrt
Die Abmeldung hat weiterhin nicht ausgeloest. Zwei Gruende, beide fuer sich
ausreichend:

* **save_session** schreibt ``idle_expires_at`` bei jeder Anfrage, die die
  Sitzung anfasst -- ein neu erzeugtes CSRF-Token genuegt. Die Pruefung in
  ``open_session`` lief damit ins Leere. Sitzungsdaten werden weiter
  gespeichert, nur die Fristen bleiben stehen. Dieselbe Stelle benutzte
  ausserdem ``idle_minutes`` statt der einstellbaren Zeit; bei der Vorgabe von
  dreissig Minuten fiel das nicht auf.
* **Der Kopf allein reicht nicht.** Er setzt voraus, dass der Browser das
  aktuelle Skript faehrt -- ein Tab, der seit gestern Abend offen ist, faehrt
  das alte und schickt ihn nicht. Genau dieser Tab ist der Fall, um den es
  geht. Der Server weiss es ohne Zutun des Browsers: die Endpunkte, die eine
  Kachel oder ein Protokoll nachlaedt, sind als Aktualisierungsausschnitt
  gekennzeichnet (``@fragment``) und gelten damit immer als automatisch.
  ``request.endpoint`` steht in ``open_session`` noch nicht -- Flask oeffnet
  die Sitzung vor dem Routing --, deshalb sieht die Erkennung dort selbst nach.

Dass es auf dem Lizenzserver zuverlaessig funktionierte, war der Hinweis: dort
pollt keine Kachel.

Dazu ein Kopf ``X-Session-Expired`` fuer Seiten, die auch oeffentlich
antworten. Die Statusuebersicht liefert nach dem Ablauf eine 200 mit den
oeffentlichen Kacheln -- am Status ist der Verlust der Sitzung also nicht zu
erkennen, und der offene Tab zeigte weiter eine Oberflaeche, als sei alles in
Ordnung. Jetzt laedt er neu und zeigt die Anmeldemaske.

Version 2.0.4.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-03 12:21:36 +02:00
alientimandClaude Opus 5 51ae39dd53 Upgrade von 1.x, Leerlaufabmeldung, Mitschrift im Audit
upgrade.sh -- ein Weg vom Stand SONNET5 auf diese Fassung, mit Daten. Der
Ablauf braucht keine Schalter: alte Installation sichern, abschalten, neu
installieren, exportieren, uebersetzen, einspielen. Die Sicherung steht
bewusst vor dem Export -- ohne --instance installiert install.sh in dasselbe
Verzeichnis, und wer erst exportiert und dann sichert, sichert im
schlechtesten Fall nichts mehr. Gelesen wird nur aus der Kopie.

Dafuer zwei neue Befehle: "legacy-export" liest eine alte Installation
unmittelbar (der Vorgaenger kann nur ueber die Weboberflaeche exportieren --
fuer ein Upgrade der falsche Weg, und unmoeglich, wenn die alte Anwendung nicht
mehr startet) und schreibt eine Datei im alten Format, die deshalb auch als
Rueckweg taugt. "legacy-import" liest eine Datei im neuen Format ein, ueber
transfer.apply_import -- dieselbe Funktion wie die Seite "Sicherung", kein
zweiter Schreibpfad.

Am Lizenzserver hat der Lauf gegen echte Altdaten zwei Fehler gezeigt, die
jede Uebernahme unmoeglich machten:

* Der alte Signaturschluessel ist URL-sicher kodiert (- und _). Das neue
  Protokoll prueft Base64 streng -- richtig so, aber beim Lesen einer alten
  Datei brach die Uebernahme mit einem Traceback ueber "kein gueltiges Base64"
  ab. Umkodiert wird jetzt an der Grenze zum Altformat; der kanonische Decoder
  bleibt streng.
* Der Anbieterblock wurde aus den Einstellungen der *neuen* Installation
  gebaut, und ohne Endpunkt brach die Uebernahme ab. Bei einem Upgrade ist die
  neue Installation frisch -- dort steht nichts. Endpunkt und Anbieterdaten
  kommen jetzt aus der alten Installation, aber nur dort, wo neu nichts steht.
  Der alte Signaturschluessel wird uebernommen: sonst muesste jeder Kunde
  einen Ausstellerwechsel bestaetigen.

Leerlaufabmeldung: sie hat nie ausgeloest. Ein offener Tab fragt seine
Kacheln alle 15 Sekunden neu ab, und jede Abfrage verlaengerte das
Leerlauffenster -- nachweisbar an einer Sitzung, deren last_seen_at eine
Minute alt war, obwohl seit zehn Stunden niemand am Rechner sass. Der Client
kennzeichnet automatische Anfragen jetzt mit einem Kopf, der Server
verlaengert daraufhin nichts, und last_seen_at bleibt, was der Name sagt.
Faellt die Sitzung waehrend des Pollens, antwortet der Server mit 401 statt
mit einer Umleitung: der Tab laedt neu und zeigt die Anmeldemaske, statt
stumm mit alten Zahlen stehenzubleiben.

Im Audit ist der Name der Mitschrift jetzt ein Verweis auf die Datei -- es ist
derselbe Weg wie zuvor, nur einen Klick weit. Verlinkt wird nur, wenn die
Datei existiert und der Betrachter Protokolle lesen darf; der Rest des
Eintrags wird maskiert, denn ein Auditeintrag enthaelt Fremdeingaben.

Ausserdem: Version 2.0.3.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-03 11:43:02 +02:00
31 changed files with 2149 additions and 143 deletions
+16
View File
@@ -152,3 +152,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.2" version = "2.0.9"
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 = [
+20 -5
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__)
@@ -15,6 +15,7 @@ from __future__ import annotations
from typing import Any from typing import Any
from flask import Blueprint, abort, render_template, request from flask import Blueprint, abort, render_template, request
from tesm_core.security.sessions import fragment
from tesm_core.extension import core, db from tesm_core.extension import core, db
from ..services import monitor from ..services import monitor
@@ -138,6 +139,7 @@ def index_view():
@bp.get("/uebersicht/kacheln") @bp.get("/uebersicht/kacheln")
@fragment
def tiles(): def tiles():
"""Teilbereich fuer die automatische Aktualisierung -- liefert nur HTML-Fragment.""" """Teilbereich fuer die automatische Aktualisierung -- liefert nur HTML-Fragment."""
from tesm_core.auth.models import current_user from tesm_core.auth.models import current_user
@@ -16,6 +16,7 @@ geratene Kategorie schickt ein ``reboot`` an das falsche Geraet).
from __future__ import annotations from __future__ import annotations
from flask import Blueprint, abort, flash, jsonify, redirect, render_template, request, url_for from flask import Blueprint, abort, flash, jsonify, redirect, render_template, request, url_for
from tesm_core.security.sessions import fragment
from tesm_core import audit from tesm_core 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
@@ -176,6 +177,7 @@ def job_detail(job_id: str):
@bp.get("/auftrag/<job_id>/ausgabe") @bp.get("/auftrag/<job_id>/ausgabe")
@permission_required("maintenance.view", "maintenance.execute", mode="any") @permission_required("maintenance.view", "maintenance.execute", mode="any")
@fragment
def job_output(job_id: str): def job_output(job_id: str):
conn = db() conn = db()
job = service.get_job(conn, job_id) job = service.get_job(conn, job_id)
+165
View File
@@ -243,6 +243,131 @@ def cmd_convert_legacy(args: argparse.Namespace) -> int:
return 0 return 0
def cmd_legacy_export(args: argparse.Namespace) -> int:
"""Liest eine alte Installation und schreibt deren Daten als Exportdatei.
Der Vorgaenger kann nur ueber die Weboberflaeche exportieren: anmelden,
Kategorien anhaken, Datei herunterladen. Fuer ein Upgrade ist das der
falsche Weg -- es soll ohne Browser laufen und auch dann noch, wenn die
alte Anwendung nicht mehr startet. Gelesen wird ausschliesslich; die alte
Installation bleibt unberuehrt.
Herauskommt eine Datei im **alten** Format. Damit ist sie genau das, was
der Vorgaenger selbst exportiert haette -- und taugt deshalb auch als
Rueckweg.
"""
from getpass import getpass
from pathlib import Path
from .services import legacy_transfer as legacy
quelle = Path(args.source)
ziel = Path(args.out)
if ziel.exists() and not args.force:
print(f"{ziel} existiert bereits. Mit --force ueberschreiben.")
return 2
passphrase = args.passphrase or getpass("Passphrase fuer die Exportdatei: ")
try:
nutzlast, bericht = legacy.read_installation(quelle)
inhalt = legacy.seal_legacy(nutzlast, passphrase)
except legacy.LegacyTransferError as exc:
print(f"Fehlgeschlagen: {exc}", file=sys.stderr)
return 1
print(bericht.as_text("Gelesen"))
if args.dry_run:
print()
print("--dry-run: keine Datei geschrieben.")
return 0
try:
ziel.write_text(inhalt, encoding="utf-8", newline="\n")
except OSError as exc:
print(f"Schreiben fehlgeschlagen: {exc}", file=sys.stderr)
return 1
print()
print(f"Geschrieben: {ziel}")
return 0
def cmd_legacy_import(args: argparse.Namespace) -> int:
"""Liest eine Datei im neuen Format ein -- derselbe Weg wie in der Oberflaeche.
Gebraucht wird das fuer das Upgrade: dort laeuft niemand durch einen
Browser. Geschrieben wird ueber ``transfer.apply_import`` -- dieselbe
Funktion, die die Seite "Sicherung" benutzt. Ein zweiter Schreibpfad fuer
Fremddaten waere die Stelle, die niemand pflegt.
"""
from getpass import getpass
from pathlib import Path
from .services import transfer
quelle = Path(args.datei)
if not quelle.is_file():
print(f"Datei nicht gefunden: {quelle}", file=sys.stderr)
return 2
app, extension = _app_and_core()
passphrase = args.passphrase or getpass("Passphrase der Datei: ")
try:
nutzlast, kopf = transfer.unseal(quelle.read_bytes(), passphrase)
except transfer.TransferError as exc:
print(f"Fehlgeschlagen: {exc}", file=sys.stderr)
return 1
vorhanden = [eintrag["key"] for eintrag in transfer.summarize(nutzlast)]
gewaehlt = (
[key for key in args.categories.split(",") if key.strip()]
if args.categories
else vorhanden
)
unbekannt = sorted(set(gewaehlt) - set(transfer.CATEGORY_BY_KEY))
if unbekannt:
print(f"Unbekannte Kategorie(n): {', '.join(unbekannt)}", file=sys.stderr)
return 2
print(f"Datei vom {kopf.get('created_at') or 'unbekannt'}:")
for eintrag in transfer.summarize(nutzlast):
marke = "x" if eintrag["key"] in gewaehlt else " "
print(f" [{marke}] {eintrag['label']}: {eintrag['count']}")
if args.dry_run:
print()
print("--dry-run: nichts geschrieben.")
return 0
with app.app_context(), extension.database.session() as conn:
with extension.database.transaction(conn):
bericht = transfer.apply_import(
conn,
extension.keystore,
nutzlast,
categories=gewaehlt,
set_setting=lambda key, value: extension.settings.set(
conn, key, value, actor=args.actor
),
)
audit.log(
conn,
actor=args.actor,
action="backup.imported",
detail="; ".join(
f"{key}: {zahlen['created']} neu, {zahlen['updated']} aktualisiert, "
f"{zahlen['skipped']} uebersprungen"
for key, zahlen in bericht.items()
)
+ " (Kommandozeile)",
severity="warning",
)
print()
for key, zahlen in sorted(bericht.items()):
print(
f" {key}: {zahlen['created']} neu, {zahlen['updated']} aktualisiert, "
f"{zahlen['skipped']} uebersprungen"
)
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 fuer TESM"
@@ -288,6 +413,46 @@ def build_parser() -> argparse.ArgumentParser:
legacy.add_argument("--force", action="store_true", help="Zieldatei ueberschreiben") legacy.add_argument("--force", action="store_true", help="Zieldatei ueberschreiben")
legacy.set_defaults(func=cmd_convert_legacy) legacy.set_defaults(func=cmd_convert_legacy)
export_alt = sub.add_parser(
"legacy-export",
help="Daten einer alten Installation als Exportdatei sichern",
description=(
"Liest die Datenbank einer Installation der Vorgaengerfassung und schreibt "
"Clients, Switche, Zugangsdaten und DHCP in eine Datei im alten Format. "
"Die alte Installation wird nur gelesen. Ohne Browser und auch dann noch "
"moeglich, wenn die alte Anwendung nicht mehr startet."
),
)
export_alt.add_argument(
"--source", default="/srv/tesm", help="Verzeichnis der alten Installation"
)
export_alt.add_argument("--out", required=True, help="Zieldatei")
export_alt.add_argument("--passphrase", help="Passphrase der Zieldatei (sonst wird gefragt)")
export_alt.add_argument("--dry-run", action="store_true", help="Nur den Bericht zeigen")
export_alt.add_argument("--force", action="store_true", help="Zieldatei ueberschreiben")
export_alt.set_defaults(func=cmd_legacy_export)
import_neu = sub.add_parser(
"legacy-import",
help="Datei im Format dieser Anwendung einlesen",
description=(
"Liest eine Sicherungs- oder uebersetzte Datei ein -- derselbe Weg wie unter "
"Verwaltung -> Sicherung, nur ohne Browser. Ohne --categories werden alle "
"Kategorien uebernommen, die in der Datei stehen."
),
)
import_neu.add_argument("datei", help="Datei im Format dieser Anwendung")
import_neu.add_argument("--passphrase", help="Passphrase der Datei (sonst wird gefragt)")
import_neu.add_argument(
"--categories", help="Kommaliste, z.B. devices,switches,credentials,dhcp"
)
import_neu.add_argument(
"--actor", default="upgrade", help="Name, der im Auditlog erscheint"
)
import_neu.add_argument("--dry-run", action="store_true", help="Nur zeigen, was drin steht")
import_neu.set_defaults(func=cmd_legacy_import)
add_web_setup(sub, cmd_web_setup) add_web_setup(sub, cmd_web_setup)
return parser return parser
+271 -2
View File
@@ -27,6 +27,7 @@ from __future__ import annotations
import base64 import base64
import json import json
from dataclasses import dataclass, field from dataclasses import dataclass, field
from pathlib import Path
from typing import Any from typing import Any
#: Runden der alten Schluesselableitung. Steht so im Vorgaenger und darf sich #: Runden der alten Schluesselableitung. Steht so im Vorgaenger und darf sich
@@ -84,8 +85,13 @@ class Report:
if text not in self.notes: if text not in self.notes:
self.notes.append(text) self.notes.append(text)
def as_text(self) -> str: def as_text(self, titel: str = "Uebersetzt") -> str:
zeilen = ["Uebersetzt:"] """Bericht als Text. Der Titel gehoert dem Aufrufer.
Beim Lesen einer alten Installation ist noch nichts uebersetzt --
"Uebersetzt: devices 4" waere an dieser Stelle einfach falsch.
"""
zeilen = [f"{titel}:"]
zeilen += [f" {key}: {value}" for key, value in sorted(self.counts.items())] or [ zeilen += [f" {key}: {value}" for key, value in sorted(self.counts.items())] or [
" nichts" " nichts"
] ]
@@ -415,3 +421,266 @@ def convert(legacy: dict[str, Any]) -> tuple[dict[str, Any], Report]:
"Beim Export im alten TESM diese Kategorien mitwaehlen." "Beim Export im alten TESM diese Kategorien mitwaehlen."
) )
return payload, report return payload, report
def _eine(conn: Any, sql: str, args: tuple[Any, ...] = ()) -> Any:
"""Eine Zeile oder ``None`` -- die alte Datenbank kennt kein Database."""
return conn.execute(sql, args).fetchone()
# --------------------------------------------------------------------------- #
# Alte Installation lesen
# --------------------------------------------------------------------------- #
#: Dateinamen der Vorgaengerfassung, relativ zu ihrem Verzeichnis.
LEGACY_DB_NAME = "sqlite.db"
LEGACY_FERNET_NAME = "fernet.key"
#: Genau die Abfragen des alten Exports. Sie stehen hier als Text und nicht als
#: Nachbau: was der Vorgaenger exportiert hat, ist das, was uebersetzbar ist.
_ABFRAGEN: dict[str, str] = {
"devices": """
SELECT devices.mac, devices.ip, devices.port, devices.name,
devices.switch_hostname, devices.is_active, devices.ssh_port,
credentials.name AS credential_name
FROM devices LEFT JOIN credentials ON credentials.id = devices.credential_id
WHERE devices.deleted_at IS NULL
""",
"switches": """
SELECT switches.hostname, switches.ip, switches.ssh_port,
credentials.name AS credential_name
FROM switches LEFT JOIN credentials ON credentials.id = switches.credential_id
WHERE switches.deleted_at IS NULL
""",
}
def _oeffne_alt(pfad: Path) -> Any:
"""Oeffnet die alte Datenbank **nur lesend**.
Der alte Dienst laeuft womoeglich noch. Ein Schreibzugriff waere hier in
jedem Fall falsch, und ``mode=ro`` macht daraus einen Fehler statt einer
Moeglichkeit.
"""
import sqlite3
if not pfad.is_file():
raise LegacyTransferError(f"Keine Datenbank gefunden: {pfad}")
conn = sqlite3.connect(f"file:{pfad.as_posix()}?mode=ro", uri=True)
conn.row_factory = sqlite3.Row
return conn
def _tabellen(conn: Any) -> set[str]:
return {
str(row[0])
for row in conn.execute("SELECT name FROM sqlite_master WHERE type='table'").fetchall()
}
def _entschluessler(root: Path, report: Report) -> Any:
"""Gibt eine Funktion zurueck, die ein altes Passwort entschluesselt.
Fehlt der Schluessel, bleibt der Datensatz erhalten und das Passwort leer.
Ein Zugangsdatensatz ohne Passwort ist nachtragbar; ein abgebrochenes
Upgrade ist es nicht.
"""
schluesseldatei = root / LEGACY_FERNET_NAME
if not schluesseldatei.is_file():
report.note(
f"{LEGACY_FERNET_NAME} nicht gefunden -- die Passwoerter der Zugangsdaten "
"bleiben leer und sind nachzutragen."
)
return lambda _token: ""
from cryptography.fernet import Fernet, InvalidToken
try:
fernet = Fernet(schluesseldatei.read_bytes().strip())
except (ValueError, TypeError):
report.note(
f"{LEGACY_FERNET_NAME} ist unlesbar -- die Passwoerter der Zugangsdaten "
"bleiben leer und sind nachzutragen."
)
return lambda _token: ""
gescheitert: list[int] = []
def entschluesseln(token: Any) -> str:
if not token:
return ""
try:
return fernet.decrypt(str(token).encode("utf-8")).decode("utf-8")
except (InvalidToken, ValueError):
gescheitert.append(1)
if len(gescheitert) == 1:
report.note(
"Mindestens ein Passwort liess sich mit dem gefundenen Schluessel "
"nicht entschluesseln -- es bleibt leer."
)
return ""
return entschluesseln
def _alt_dhcp(conn: Any, vorhanden: set[str]) -> dict[str, Any] | None:
"""Baut den DHCP-Abschnitt so, wie der alte Export ihn geschrieben hat."""
if "dhcp_subnets" not in vorhanden:
return None
einstellungen: dict[str, Any] = {}
if "settings" in vorhanden:
# Nur die Schluessel holen, die der Uebersetzer kennt -- der alte
# Einstellungsbestand ist gross und heisst groesstenteils anders.
for key in DHCP_SETTING_KEYS:
row = _eine(conn, "SELECT value FROM settings WHERE key=?", (key,))
if row and str(row["value"] or ""):
einstellungen[key] = row["value"]
subnets = [
dict(row)
for row in conn.execute(
"SELECT interface, range_start, range_end, gateway, dns, sort_order, enabled "
"FROM dhcp_subnets"
).fetchall()
]
reservations = (
[
dict(row)
for row in conn.execute(
"SELECT mac, ip, name FROM dhcp_manual_reservations"
).fetchall()
]
if "dhcp_manual_reservations" in vorhanden
else []
)
options: list[dict[str, Any]] = []
if "dhcp_option_defs" in vorhanden:
for definition in conn.execute(
"SELECT id, code, name, type, description, is_standard FROM dhcp_option_defs"
).fetchall():
werte = (
conn.execute(
"SELECT device_mac, value FROM dhcp_option_values WHERE option_def_id=?",
(definition["id"],),
).fetchall()
if "dhcp_option_values" in vorhanden
else []
)
if not werte and definition["is_standard"]:
continue
options.append(
{
"code": definition["code"],
"name": definition["name"],
"type": definition["type"],
"description": definition["description"],
"is_standard": definition["is_standard"],
"values": [
{"device_mac": w["device_mac"], "value": w["value"]} for w in werte
],
}
)
if not (einstellungen or subnets or reservations or options):
return None
return {
"settings": einstellungen,
"subnets": subnets,
"reservations": reservations,
"options": options,
}
def read_installation(root: Path) -> tuple[dict[str, Any], Report]:
"""Liest eine alte Installation und gibt deren Nutzlast zurueck.
Ergebnis ist dieselbe Struktur, die ``open_legacy`` aus einer Exportdatei
liefert -- der Uebersetzer dahinter bleibt also derselbe. Das ist der
Unterschied zum Weg ueber die Oberflaeche: fuer ein Upgrade darf niemand
sich anmelden, eine Kategorie anhaken und eine Datei herunterladen muessen.
Gelesen wird ausschliesslich; die alte Installation bleibt unberuehrt.
"""
root = Path(root)
report = Report()
conn = _oeffne_alt(root / LEGACY_DB_NAME)
try:
vorhanden = _tabellen(conn)
payload: dict[str, Any] = {}
for abschnitt, sql in _ABFRAGEN.items():
tabelle = abschnitt
if tabelle not in vorhanden:
report.note(f"Tabelle {tabelle!r} fehlt in der alten Datenbank.")
continue
payload[abschnitt] = [dict(row) for row in conn.execute(sql).fetchall()]
if "credentials" in vorhanden:
entschluesseln = _entschluessler(root, report)
payload["credentials"] = [
{
"name": row["name"],
"username": row["username"],
"password": entschluesseln(row["password"]),
"category": row["category"],
}
for row in conn.execute(
"SELECT name, username, password, category FROM credentials "
"WHERE deleted_at IS NULL"
).fetchall()
]
else:
report.note("Tabelle 'credentials' fehlt in der alten Datenbank.")
dhcp = _alt_dhcp(conn, vorhanden)
if dhcp:
payload["dhcp"] = dhcp
finally:
conn.close()
payload = {key: value for key, value in payload.items() if value}
if not payload:
raise LegacyTransferError(
f"In {root} stehen keine uebernehmbaren Daten (Clients, Switche, "
"Zugangsdaten, DHCP)."
)
for abschnitt, inhalt in payload.items():
report.took(abschnitt, len(inhalt) if isinstance(inhalt, list) else 1)
return payload, report
def seal_legacy(payload: dict[str, Any], passphrase: str) -> str:
"""Schreibt eine Nutzlast im **alten** Umschlag.
Damit ist die Datei genau das, was der Vorgaenger selbst exportiert haette
-- lesbar fuer ``open_legacy`` und fuer den Import des alten TESM. Das ist
beim Upgrade der Unterschied zwischen einem Zwischenschritt und einem
Rueckweg: geht danach etwas schief, laesst sich diese Datei in die alte
Installation zurueckspielen.
"""
import secrets
from datetime import datetime
from cryptography.fernet import Fernet
from cryptography.hazmat.primitives import hashes
from cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2HMAC
if len(passphrase) < 8:
raise LegacyTransferError("Die Passphrase braucht mindestens 8 Zeichen.")
salt = secrets.token_bytes(16)
kdf = PBKDF2HMAC(
algorithm=hashes.SHA256(), length=32, salt=salt, iterations=LEGACY_KDF_ITERATIONS
)
key = base64.urlsafe_b64encode(kdf.derive(passphrase.encode("utf-8")))
token = Fernet(key).encrypt(json.dumps(payload).encode("utf-8"))
umschlag = {
"app": LEGACY_APP_TAG,
"version": 2,
"exported_at": datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
"salt": base64.urlsafe_b64encode(salt).decode("utf-8"),
"payload": token.decode("utf-8"),
}
return json.dumps(umschlag, indent=2, ensure_ascii=False) + "\n"
+11 -6
View File
@@ -636,10 +636,12 @@ def next_check_at(conn: sqlite3.Connection, interval_seconds: int) -> datetime |
return stamp + timedelta(seconds=max(1, int(interval_seconds))) return stamp + timedelta(seconds=max(1, int(interval_seconds)))
def dashboard_counts(conn: sqlite3.Connection) -> dict[str, int]: #: Zaehlung fuer die Statusuebersicht. Steht als Konstante, weil das
row = Database.one( #: Anmeldebanner (``deploy/tesm-motd``) dieselben Zahlen zeigt und ohne
conn, #: Anwendung laeuft -- es kann diese Datei nicht importieren, also vergleicht
""" #: ein Hygienetest die beiden Zeichenketten. Zwei Wahrheiten ueber "online"
#: waeren schlimmer als eine doppelte Zeichenkette.
DASHBOARD_COUNT_SQL = """
SELECT SELECT
COUNT(*) AS total, COUNT(*) AS total,
SUM(CASE WHEN d.is_active=0 THEN 1 ELSE 0 END) AS disabled, SUM(CASE WHEN d.is_active=0 THEN 1 ELSE 0 END) AS disabled,
@@ -649,8 +651,11 @@ def dashboard_counts(conn: sqlite3.Connection) -> dict[str, int]:
SUM(CASE WHEN d.is_active=1 AND st.state='restarting' THEN 1 ELSE 0 END) AS restarting SUM(CASE WHEN d.is_active=1 AND st.state='restarting' THEN 1 ELSE 0 END) AS restarting
FROM devices d LEFT JOIN device_status st ON st.device_id = d.id FROM devices d LEFT JOIN device_status st ON st.device_id = d.id
WHERE d.deleted_at IS NULL WHERE d.deleted_at IS NULL
""", """
)
def dashboard_counts(conn: sqlite3.Connection) -> dict[str, int]:
row = Database.one(conn, DASHBOARD_COUNT_SQL)
row = row or {} row = row or {}
return { return {
key: int(row.get(key) or 0) key: int(row.get(key) or 0)
@@ -115,7 +115,7 @@
{% else %}{{ pill('fehlgeschlagen', 'danger') }}{% endif %} {% else %}{{ pill('fehlgeschlagen', 'danger') }}{% endif %}
</td> </td>
<td class="numeric fs-sm nowrap">{{ event.duration_ms | millis }}</td> <td class="numeric fs-sm nowrap">{{ event.duration_ms | millis }}</td>
<td class="fs-xs muted break-all">{{ event.detail }}</td> <td class="fs-xs muted break-all">{{ event.detail | audit_detail }}</td>
</tr> </tr>
{% endfor %} {% endfor %}
</tbody> </tbody>
+36
View File
@@ -295,6 +295,42 @@ install -o root -g root -m 0440 "$TMP_SUDO" "/etc/sudoers.d/$APP"
rm -f "$TMP_SUDO" rm -f "$TMP_SUDO"
info "sudoers: $SERVICE_USER darf ausschliesslich tesm-helper als root aufrufen" info "sudoers: $SERVICE_USER darf ausschliesslich tesm-helper als root aufrufen"
# -- Anmeldebanner (MOTD) ----------------------------------------------------
# Das Banner liest die Datenbank dieser Installation und laeuft dabei mit dem
# System-Python, ohne das venv und ohne die Anwendung zu importieren. Der
# Vorgaenger importierte dafuer die ganze Flask-App -- bei jeder Anmeldung, und
# nach einem Wechsel des Quellverzeichnisses zeigte er stillschweigend die
# Zahlen einer Datenbank, die niemand mehr fuellte.
#
# "timeout" verhindert, dass ein haengender Dateizugriff die Anmeldung
# blockiert; "|| true" sorgt dafuer, dass ein Fehler hier den Login nie stoert.
step "Anmeldebanner"
if [[ -d /etc/update-motd.d ]]; then
install_if_changed "$ROOT/deploy/tesm-motd" /usr/local/lib/tesm/tesm-motd 0755 >/dev/null \
&& info "Bannerskript aktualisiert" || info "Bannerskript unveraendert"
TMP_MOTD="$(mktemp)"
{
echo "#!/bin/sh"
echo "# Von TESM erzeugt ($NAME). Aenderungen werden bei der naechsten"
echo "# Installation ueberschrieben."
echo "timeout 3 /usr/local/lib/tesm/tesm-motd --app $APP --root $ROOT 2>/dev/null || true"
} > "$TMP_MOTD"
install -o root -g root -m 0755 "$TMP_MOTD" "/etc/update-motd.d/50-$NAME"
rm -f "$TMP_MOTD"
info "Banner: /etc/update-motd.d/50-$NAME"
# Ein Banner der Vorgaengerfassung zeigt Zahlen aus einer Datenbank, die
# niemand mehr fuellt. Er wird nur entfernt, wenn er genau darauf zeigt --
# nichts anderes wird angefasst.
ALT_MOTD="/etc/update-motd.d/50-tesm"
if [[ "$ALT_MOTD" != "/etc/update-motd.d/50-$NAME" && -f "$ALT_MOTD" ]] \
&& grep -q "motd_banner.py" "$ALT_MOTD" 2>/dev/null; then
rm -f "$ALT_MOTD"
info "Banner der Vorgaengerfassung entfernt ($ALT_MOTD)"
fi
else
info "Kein /etc/update-motd.d -- Banner uebersprungen"
fi
# -- Rechte ------------------------------------------------------------------ # -- Rechte ------------------------------------------------------------------
step "Dateirechte" step "Dateirechte"
chown -R "$SERVICE_USER:$SERVICE_USER" "$ROOT/data" "$LOG_DIR" chown -R "$SERVICE_USER:$SERVICE_USER" "$ROOT/data" "$LOG_DIR"
+359
View File
@@ -0,0 +1,359 @@
#!/usr/bin/env bash
# =============================================================================
# Benennt eine Installation um -- aus einer Instanz wird die Hauptinstallation
# (oder umgekehrt).
#
# sudo bash deploy/rename.sh --app tesm --from tesm-test --to tesm
# sudo bash deploy/rename.sh --app tesm-license --from tesm-license-test \
# --to tesm-license
#
# Der Name steckt an mehr Stellen, als man beim Verschieben eines Verzeichnisses
# erwartet, und drei davon sind leicht zu vergessen:
#
# * ``SITE_KEY`` in der Umgebungsdatei -- daraus entsteht der Name des
# Sitzungscookies. Bleibt er alt, heisst das Cookie weiter wie die alte
# Instanz.
# * ``web_cert_path``/``web_key_path``/``web_acme_webroot`` in der Datenbank.
# Sie enthalten den Namen; ein Zertifikat unter dem alten Pfad findet nginx
# nach dem Umzug nicht mehr.
# * ``server_name`` der nginx-Site. install.sh schreibt die Datei neu, sobald
# sich die alias-Pfade aendern -- und die aendern sich hier zwangslaeufig.
# Ohne den Namen aus der Datenbank stuenden danach zwei Sites mit ``_`` auf
# Port 80 und die erste gewinnt.
#
# Belegt der Zielname noch eine Installation der Fassung 1.x, wird sie beiseite
# geschoben: Verzeichnis nach ``<ziel>-<suffix>``, ihre systemd-Units und ihre
# nginx-Site als Kopie hinein. Danach ist der Name frei und nichts auf dem
# System zeigt mehr ins Leere.
#
# Alle angemeldeten Benutzer werden abgemeldet: das Sitzungscookie heisst
# danach anders.
# =============================================================================
set -euo pipefail
APP=""
FROM=""
TO=""
SUFFIX="sonnet5"
ASSUME_YES=0
DRY_RUN=0
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
COLOR_OK=$'\033[32m'; COLOR_WARN=$'\033[33m'; COLOR_ERR=$'\033[31m'; COLOR_OFF=$'\033[0m'
step() { printf '\n%s==>%s %s\n' "$COLOR_OK" "$COLOR_OFF" "$*"; }
warn() { printf '%s !!%s %s\n' "$COLOR_WARN" "$COLOR_OFF" "$*"; }
fail() { printf '%s !!%s %s\n' "$COLOR_ERR" "$COLOR_OFF" "$*" >&2; exit 1; }
info() { printf ' %s\n' "$*"; }
usage() {
cat <<'USAGE'
Verwendung: rename.sh --app <tesm|tesm-license> --from <alt> --to <neu> [Optionen]
--app <name> Welche Anwendung (Pflicht)
--from <name> Bisheriger Installationsname, z.B. tesm-test (Pflicht)
--to <name> Neuer Installationsname, z.B. tesm (Pflicht)
--suffix <text> Namenszusatz fuer eine beiseite geschobene Installation
der Fassung 1.x (Vorgabe: sonnet5)
--dry-run Nur zeigen, was geschehen wuerde
--yes Keine Rueckfrage
USAGE
}
while [[ $# -gt 0 ]]; do
case "$1" in
--app) APP="${2:?}"; shift 2 ;;
--from) FROM="${2:?}"; shift 2 ;;
--to) TO="${2:?}"; shift 2 ;;
--suffix) SUFFIX="${2:?}"; shift 2 ;;
--dry-run) DRY_RUN=1; shift ;;
--yes|-y) ASSUME_YES=1; shift ;;
-h|--help) usage; exit 0 ;;
*) usage; fail "unbekannte Option: $1" ;;
esac
done
[[ "$APP" == "tesm" || "$APP" == "tesm-license" ]] || { usage; fail "--app fehlt oder ist unbekannt"; }
[[ -n "$FROM" && -n "$TO" ]] || { usage; fail "--from und --to sind Pflicht"; }
[[ "$FROM" != "$TO" ]] || fail "--from und --to sind gleich"
[[ "$TO" =~ ^[a-z0-9-]{1,32}$ ]] || fail "ungueltiger Zielname: $TO"
[[ "$TO" == "$APP" || "$TO" == "$APP-"* ]] || fail "der Zielname muss mit '$APP' beginnen"
[[ "$(id -u)" -eq 0 ]] || fail "bitte mit sudo ausfuehren"
[[ -f "$SCRIPT_DIR/install.sh" ]] || fail "install.sh fehlt neben diesem Skript"
[[ -d "/srv/$FROM" ]] || fail "/srv/$FROM gibt es nicht"
[[ -f "/srv/$FROM/data/app.db" ]] || fail "/srv/$FROM sieht nicht nach einer Installation dieser Fassung aus"
ENV_PREFIX="TESM_"
[[ "$APP" == "tesm-license" ]] && ENV_PREFIX="TESM_LICENSE_"
# Alles, was den Namen traegt. Reihenfolge: erst Daten, dann Konfiguration.
VERSCHIEBEN=(
"/srv/$FROM:/srv/$TO"
"/var/log/$FROM:/var/log/$TO"
"/etc/tesm/$FROM.env:/etc/tesm/$TO.env"
"/var/www/$FROM-acme:/var/www/$TO-acme"
"/etc/tesm/certs/$FROM:/etc/tesm/certs/$TO"
)
ENTFERNEN=(
"/etc/systemd/system/$FROM.service"
"/etc/systemd/system/$FROM-monitor.service"
"/etc/nginx/sites-enabled/$FROM"
"/etc/nginx/sites-available/$FROM"
"/usr/local/bin/$FROM-admin"
"/etc/logrotate.d/$FROM"
"/etc/update-motd.d/50-$FROM"
)
# -- Webeinstellungen auslesen, bevor irgendetwas umzieht -------------------- #
# Mit dem System-Python und der Standardbibliothek: das venv zieht gleich mit
# um, und ein zusaetzliches Werkzeug (sqlite3) ist auf einem schlanken Server
# nicht garantiert.
lese_einstellung() {
python3 - "$1" "/srv/$FROM/data/app.db" <<'PY'
import sqlite3, sys
schluessel, datenbank = sys.argv[1], sys.argv[2]
try:
conn = sqlite3.connect(f"file:{datenbank}?mode=ro", uri=True)
row = conn.execute("SELECT value FROM settings WHERE key=?", (schluessel,)).fetchone()
conn.close()
except sqlite3.Error:
row = None
print((row[0] if row and row[0] is not None else "").strip())
PY
}
DOMAIN="$(lese_einstellung web_domain || true)"
HTTPS_AN="$(lese_einstellung web_https_enabled || true)"
HSTS_AN="$(lese_einstellung web_hsts_enabled || true)"
CERT_ALT="$(lese_einstellung web_cert_path || true)"
step "Vorhaben"
info "Anwendung : $APP"
info "Alt : $FROM"
info "Neu : $TO"
info "Domain : ${DOMAIN:-(keine)}"
info "HTTPS : $([[ "$HTTPS_AN" == "1" ]] && echo "ja${CERT_ALT:+, Zertifikat $CERT_ALT}" || echo nein)"
for paar in "${VERSCHIEBEN[@]}"; do
quelle="${paar%%:*}"; ziel="${paar##*:}"
[[ -e "$quelle" ]] && info "verschieben: $quelle -> $ziel"
done
for pfad in "${ENTFERNEN[@]}"; do
[[ -e "$pfad" ]] && info "entfernen : $pfad (install.sh legt es unter dem neuen Namen neu an)"
done
# -- Was belegt den Zielnamen? ----------------------------------------------- #
# Nicht nur das Verzeichnis unter /srv: eine Installation der Fassung 1.x
# hinterlaesst auch /var/log/<name>, /etc/logrotate.d/<name> und den
# ACME-Pfad. Der Umzug bricht sonst mitten drin ab -- live genau so passiert.
BELEGT=()
for paar in "${VERSCHIEBEN[@]}"; do
ziel="${paar##*:}"
[[ -e "$ziel" ]] && BELEGT+=("$ziel")
done
for weiteres in "/etc/logrotate.d/$TO" "/etc/update-motd.d/50-$TO"; do
[[ -e "$weiteres" ]] && BELEGT+=("$weiteres")
done
BEISEITE=""
if [[ ${#BELEGT[@]} -gt 0 ]]; then
if [[ -f "/srv/$TO/data/app.db" ]]; then
fail "/srv/$TO ist bereits eine Installation dieser Fassung. Welche gewinnt, entscheidet niemand automatisch."
fi
BEISEITE="/srv/$TO-$SUFFIX"
if [[ -e "$BEISEITE" ]]; then
# Ein vorhandenes Archiv derselben Verdraengung wird weiterbenutzt -- die
# Reste einer Installation gehoeren zusammen und nicht in ein zweites
# Verzeichnis. Alles andere unter diesem Namen bleibt unangetastet.
if [[ -f "$BEISEITE/sqlite.db" || -d "$BEISEITE/systemd-alt" ]]; then
info "Archiv $BEISEITE ist vorhanden und wird weiterbenutzt."
else
fail "$BEISEITE existiert und sieht nicht nach einem solchen Archiv aus -- bitte --suffix waehlen"
fi
fi
info "Der Zielname ist belegt. Alles davon wandert nach $BEISEITE:"
for pfad in "${BELEGT[@]}"; do info " $pfad"; done
fi
if [[ $DRY_RUN -eq 1 ]]; then
step "--dry-run: hier ist Schluss."
exit 0
fi
if [[ $ASSUME_YES -eq 0 ]]; then
read -r -p "Fortfahren? Alle angemeldeten Benutzer werden abgemeldet. [j/N] " answer
[[ "$answer" =~ ^[jJyY]$ ]] || { echo "Abgebrochen."; exit 0; }
fi
# -- 1. Dienste anhalten ----------------------------------------------------- #
step "Dienste anhalten"
for unit in "$FROM.service" "$FROM-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 und deaktiviert"
fi
done
# -- 2. Zielnamen freimachen ------------------------------------------------- #
if [[ -n "$BEISEITE" ]]; then
step "Zielnamen freimachen"
# Alles wandert ins Archiv, nichts wird geloescht: unter /var/log/<name>
# liegen Protokolle, unter /srv/<name> im Zweifel eine ganze Installation.
# Die Units und die Site kommen mit -- sie zeigen auf das alte Verzeichnis
# und waeren auf dem System nur noch Fehlerquellen, im Archiv aber ein Beleg.
mkdir -p "$BEISEITE"
for pfad in "${BELEGT[@]}"; do
ziel="$BEISEITE/$(basename "$pfad")"
if [[ "$pfad" == "/srv/$TO" ]]; then
# Das Verzeichnis selbst wird das Archiv -- sein Inhalt bleibt, wie er ist.
for eintrag in "$pfad"/* "$pfad"/.[!.]*; do
[[ -e "$eintrag" ]] || continue
mv "$eintrag" "$BEISEITE/"
done
rmdir "$pfad"
info "/srv/$TO -> $BEISEITE/"
continue
fi
if [[ -e "$ziel" ]]; then
ziel="$ziel-$(date +%Y%m%d-%H%M%S)"
warn "Im Archiv lag schon etwas mit diesem Namen -- ablegen als $(basename "$ziel")"
fi
mv "$pfad" "$ziel"
info "$pfad -> $ziel"
done
mkdir -p "$BEISEITE/systemd-alt"
for unit in "/etc/systemd/system/$TO.service" "/etc/systemd/system/$TO-check.service" \
"/etc/systemd/system/$TO-check.timer" "/etc/systemd/system/$TO-check-restart.service" \
"/etc/systemd/system/$TO-check-restart.timer"; do
[[ -f "$unit" ]] || continue
systemctl disable --now "$(basename "$unit")" >/dev/null 2>&1 || true
mv "$unit" "$BEISEITE/systemd-alt/"
info "$(basename "$unit") archiviert"
done
for site in "/etc/nginx/sites-enabled/$TO" "/etc/nginx/sites-available/$TO"; do
[[ -e "$site" ]] || continue
[[ -L "$site" ]] && { rm -f "$site"; continue; }
mv "$site" "$BEISEITE/systemd-alt/nginx-site"
info "nginx-Site archiviert"
done
for motd in "/etc/update-motd.d/50-$TO"; do
[[ -f "$motd" ]] && grep -q "motd_banner.py" "$motd" 2>/dev/null && rm -f "$motd" \
&& info "Banner der Fassung 1.x entfernt"
done
info "Alles Verdraengte liegt unter $BEISEITE"
fi
# -- 3. Umziehen ------------------------------------------------------------- #
step "Umziehen"
for paar in "${VERSCHIEBEN[@]}"; do
quelle="${paar%%:*}"; ziel="${paar##*:}"
[[ -e "$quelle" ]] || continue
[[ -e "$ziel" ]] && fail "$ziel existiert bereits -- Abbruch, damit nichts vermischt wird"
mkdir -p "$(dirname "$ziel")"
mv "$quelle" "$ziel"
info "$quelle -> $ziel"
done
# -- 4. Namen in den Dateien nachziehen -------------------------------------- #
step "Namen nachziehen"
ENV_FILE="/etc/tesm/$TO.env"
if [[ -f "$ENV_FILE" ]]; then
# Der SITE_KEY bestimmt den Namen des Sitzungscookies. Bleibt er alt, heisst
# das Cookie weiter wie die alte Instanz -- unauffaellig und falsch.
sed -i "s#^${ENV_PREFIX}SITE_KEY=.*#${ENV_PREFIX}SITE_KEY=$TO#" "$ENV_FILE"
info "SITE_KEY=$TO"
fi
# Pfade in der Datenbank, die den Namen enthalten.
python3 - "$TO" "$FROM" "/srv/$TO/data/app.db" <<'PY'
import sqlite3, sys
neu, alt, datenbank = sys.argv[1], sys.argv[2], sys.argv[3]
schluessel = ("web_cert_path", "web_key_path", "web_acme_webroot")
try:
conn = sqlite3.connect(datenbank, timeout=5.0)
except sqlite3.Error as fehler:
print(f" Datenbank nicht schreibbar: {fehler}")
raise SystemExit(0)
try:
with conn:
for key in schluessel:
row = conn.execute("SELECT value FROM settings WHERE key=?", (key,)).fetchone()
if not row or not row[0]:
continue
wert = str(row[0])
ersetzt = wert.replace(alt, neu)
if ersetzt != wert:
conn.execute("UPDATE settings SET value=? WHERE key=?", (ersetzt, key))
print(f" {key}: {wert} -> {ersetzt}")
finally:
conn.close()
PY
# -- 5. Alte Konfiguration entfernen ----------------------------------------- #
step "Alte Konfiguration entfernen"
for pfad in "${ENTFERNEN[@]}"; do
[[ -e "$pfad" ]] || continue
rm -f "$pfad"
info "$pfad"
done
systemctl daemon-reload
# -- 6. Unter dem neuen Namen einrichten ------------------------------------- #
# Das venv wird verworfen und neu gebaut. Es ist abgeleiteter Zustand, und
# seine Konsolenskripte tragen den absoluten Pfad im Shebang: nach dem Umzug
# zeigt "venv/bin/gunicorn" auf einen Interpreter, den es nicht mehr gibt, und
# systemd meldet nur "No such file or directory" -- fuer eine Datei, die
# sichtbar da ist. Live auf POETEST genau so passiert.
if [[ -d "/srv/$TO/venv" ]]; then
step "venv verwerfen (Shebangs tragen den alten Pfad)"
rm -rf "/srv/$TO/venv"
info "wird von install.sh neu gebaut"
fi
step "Unter dem neuen Namen einrichten"
INSTALL_ARGS=(--app "$APP" --yes)
[[ "$TO" != "$APP" ]] && INSTALL_ARGS+=(--instance "${TO#"$APP"-}")
bash "$SCRIPT_DIR/install.sh" "${INSTALL_ARGS[@]}"
# -- 7. nginx-Site aus den Einstellungen neu schreiben ----------------------- #
# install.sh schreibt die Site aus seiner Vorlage, sobald sich die alias-Pfade
# aendern -- und die aendern sich hier zwangslaeufig. Der Name der Site steht
# aber in der Datenbank, nicht in der Vorlage. Ohne diesen Schritt stuenden
# zwei Sites mit "_" auf Port 80 und die erste gewinnt.
if [[ -n "$DOMAIN" ]]; then
step "Webserver-Konfiguration erneuern"
# --apply nicht vergessen: ohne ihn speichert web-setup nur die Einstellungen
# und laesst die Site-Datei stehen, die install.sh aus seiner Vorlage
# geschrieben hat -- mit "server_name _" und ohne TLS.
WEB_ARGS=(web-setup --apply --domain "$DOMAIN")
if [[ "$HTTPS_AN" == "1" ]]; then
WEB_ARGS+=(--https)
[[ "$HSTS_AN" == "1" ]] && WEB_ARGS+=(--hsts)
# Das Zertifikat ist mitgezogen und sein Pfad in der Datenbank nachgefuehrt
# -- "web-setup --https" laesst es dann unangetastet. Ein neues erzeugen
# wuerde nur eine weitere Browserwarnung produzieren. Nur wenn nach dem
# Umzug wirklich keines da ist, wird eines erzeugt.
CERT_NEU="${CERT_ALT//$FROM/$TO}"
if [[ -n "$CERT_NEU" && -f "$CERT_NEU" ]]; then
info "HTTPS mit dem vorhandenen Zertifikat: $CERT_NEU"
else
WEB_ARGS+=(--self-signed)
warn "Zertifikat nicht gefunden (${CERT_NEU:-keins hinterlegt}) -- es wird ein neues"
warn "selbstsigniertes erzeugt. Der Browser warnt dann einmal erneut."
fi
fi
if sudo -u "$APP" "/usr/local/bin/$TO-admin" "${WEB_ARGS[@]}"; then
info "Site neu geschrieben: server_name $DOMAIN"
else
warn "web-setup ist fehlgeschlagen -- bitte in der Anwendung unter"
warn "'Verwaltung -> Webserver und TLS' die Domain eintragen und uebernehmen."
fi
fi
# -- 8. Ergebnis ------------------------------------------------------------- #
step "Fertig"
info "Verzeichnis : /srv/$TO"
info "Dienst : systemctl status $TO.service"
info "Werkzeug : $TO-admin"
info "Aktualisieren: sudo bash /srv/$TO/deploy/update.sh $APP$([[ "$TO" != "$APP" ]] && echo " latest --instance ${TO#"$APP"-}")"
[[ -n "$BEISEITE" ]] && info "Fassung 1.x : $BEISEITE (Units und Site darin unter systemd-alt/)"
echo
warn "Alle Benutzer sind abgemeldet: das Sitzungscookie heisst jetzt anders."
+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() {
+185
View File
@@ -0,0 +1,185 @@
#!/usr/bin/env python3
"""Live-Banner fuer die SSH-Anmeldung (MOTD).
Eingebunden ueber ``/etc/update-motd.d/50-<name>``, das ``install.sh``
erzeugt. Aufruf mit ``--app`` und ``--root``; alles andere steht in der
Datenbank der Installation.
**Nur Standardbibliothek, kein Flask, kein venv.** Der Vorgaenger importierte
dafuer die ganze Anwendung -- bei jeder SSH-Anmeldung. Das ist teuer, und es
faellt aus, sobald das venv nicht mehr passt: nach dem Umstieg auf diese
Fassung zeigte das alte Banner monatelang eingefrorene Zahlen aus einer
Datenbank, die niemand mehr fuellte. Hier wird gelesen, was auf der Platte
liegt, und zwar **nur lesend** (``mode=ro``).
Ein Banner darf eine Anmeldung niemals stoeren: jede Auskunft, die nicht zu
holen ist, wird zu einem kurzen Hinweis, und am Ende faengt ``main`` alles ab.
Der Aufrufer setzt zusaetzlich ein Zeitlimit.
"""
from __future__ import annotations
import argparse
import sqlite3
import sys
import time
from pathlib import Path
AMBER = "\033[38;2;226;166;60m"
DIM = "\033[2m"
BOLD = "\033[1m"
RESET = "\033[0m"
#: Kea schreibt seine Leases als CSV; die letzte Zeile je Adresse gilt.
#: Dieselbe Datei liest die Anwendung (``services/kea.py``: ``LEASE_FILE``).
LEASE_FILE = "/var/lib/kea/kea-leases4.csv"
#: Zeile unter der Wortmarke. Wortlaut wie in der jeweiligen Anwendung
#: (``AUTH_TAGLINE`` in deren ``__init__.py``) -- ein Hygienetest haelt beides
#: zusammen.
TAGLINE = {
"tesm": "ÜBERWACHEN · BOOTEN · ANBINDEN",
"tesm-license": "LIZENZIEREN",
}
TITEL = {"tesm": "TimEShepManager", "tesm-license": "Lizenzserver"}
#: Wortgleich mit ``services/monitor.py``: ``DASHBOARD_COUNT_SQL``. Die
#: Statusuebersicht und dieses Banner sollen nicht zwei Wahrheiten haben; ein
#: Hygienetest vergleicht die beiden Zeichenketten.
DASHBOARD_COUNT_SQL = """
SELECT
COUNT(*) AS total,
SUM(CASE WHEN d.is_active=0 THEN 1 ELSE 0 END) AS disabled,
SUM(CASE WHEN d.is_active=1 AND COALESCE(st.state,'unknown')='online' THEN 1 ELSE 0 END) AS online,
SUM(CASE WHEN d.is_active=1 AND COALESCE(st.state,'unknown')='offline' THEN 1 ELSE 0 END) AS offline,
SUM(CASE WHEN d.is_active=1 AND COALESCE(st.state,'unknown')='unknown' THEN 1 ELSE 0 END) AS unknown,
SUM(CASE WHEN d.is_active=1 AND st.state='restarting' THEN 1 ELSE 0 END) AS restarting
FROM devices d LEFT JOIN device_status st ON st.device_id = d.id
WHERE d.deleted_at IS NULL
"""
def _zahl(anzahl: int, singular: str, plural: str) -> str:
"""Zahl mit passendem Wort. "1 Clients" liest man jeden Tag einmal zu oft."""
return f"{BOLD}{anzahl}{RESET} {singular if anzahl == 1 else plural}"
def _oeffnen(root: Path) -> sqlite3.Connection:
pfad = root / "data" / "app.db"
conn = sqlite3.connect(f"file:{pfad.as_posix()}?mode=ro", uri=True, timeout=1.0)
conn.row_factory = sqlite3.Row
return conn
def _flotte(conn: sqlite3.Connection) -> str:
row = conn.execute(DASHBOARD_COUNT_SQL).fetchone()
online = int(row["online"] or 0)
offline = int(row["offline"] or 0)
teile = [
_zahl(online, "Client online", "Clients online"),
f"{BOLD}{offline}{RESET} nicht erreichbar",
]
# Nur nennen, wenn es sie gibt: eine Null bei "startet neu" ist keine
# Auskunft, sondern Fuellmaterial.
restarting = int(row["restarting"] or 0)
if restarting:
teile.append(_zahl(restarting, "startet neu", "starten neu"))
unbekannt = int(row["unknown"] or 0)
if unbekannt:
teile.append(f"{DIM}{unbekannt} ohne Pruefung{RESET}")
return " · ".join(teile)
def _dhcp() -> str:
"""Aktive Leases aus der Kea-CSV. Laeuft als root, braucht keinen Helfer."""
datei = Path(LEASE_FILE)
if not datei.is_file():
return "keine Lease-Datei"
try:
zeilen = datei.read_text(encoding="utf-8", errors="replace").splitlines()
except OSError:
return "Lease-Datei nicht lesbar"
if len(zeilen) < 2:
return "0 Leases aktiv"
kopf = [name.strip().strip('"') for name in zeilen[0].split(",")]
try:
i_adresse = kopf.index("address")
i_ablauf = kopf.index("expire")
i_zustand = kopf.index("state")
except ValueError:
return "Lease-Datei mit unerwarteten Spalten"
jetzt = int(time.time())
letzte: dict[str, tuple[int, str]] = {}
for zeile in zeilen[1:]:
felder = [feld.strip().strip('"') for feld in zeile.split(",")]
if len(felder) < len(kopf):
continue
try:
letzte[felder[i_adresse]] = (int(felder[i_ablauf] or 0), felder[i_zustand] or "0")
except ValueError:
continue
aktiv = sum(1 for ablauf, zustand in letzte.values() if zustand == "0" and ablauf > jetzt)
return f"{aktiv} {'Lease' if aktiv == 1 else 'Leases'} aktiv"
def _lizenzen(conn: sqlite3.Connection) -> str:
kunden = int(conn.execute("SELECT COUNT(*) FROM license_customers").fetchone()[0] or 0)
lizenzen = int(
conn.execute(
"SELECT COUNT(*) FROM licenses WHERE status IN ('issued','active')"
).fetchone()[0]
or 0
)
return (
_zahl(kunden, "Kunde", "Kunden")
+ " · "
+ _zahl(lizenzen, "Lizenz ausgestellt", "Lizenzen ausgestellt")
)
def main(argv: list[str] | None = None) -> int:
parser = argparse.ArgumentParser(prog="tesm-motd", description="Banner fuer die SSH-Anmeldung")
parser.add_argument("--app", required=True, choices=sorted(TITEL))
parser.add_argument("--root", required=True, help="Verzeichnis der Installation")
parser.add_argument("--no-color", action="store_true")
args = parser.parse_args(argv)
if args.no_color:
globals().update(AMBER="", DIM="", BOLD="", RESET="")
root = Path(args.root)
# Kein isatty()-Test: dieses Skript schreibt beim Login nicht auf ein
# Terminal, sondern ueber run-parts in eine Datei. Der Betrachter ist
# trotzdem ein echtes Terminal -- deshalb immer Farbe.
print(f"{BOLD}{AMBER}TESM{RESET} · {TITEL[args.app]}")
if TAGLINE[args.app]:
print(f"{DIM}{TAGLINE[args.app]}{RESET}")
print()
try:
conn = _oeffnen(root)
except sqlite3.Error:
print(f"{DIM}Datenbank nicht lesbar: {root}{RESET}")
print()
return 0
try:
if args.app == "tesm":
print(f"{_flotte(conn)} · DHCP: {_dhcp()}")
else:
print(_lizenzen(conn))
except sqlite3.Error as fehler:
print(f"{DIM}Zustand nicht verfuegbar ({fehler}){RESET}")
finally:
conn.close()
print()
return 0
if __name__ == "__main__":
try:
raise SystemExit(main())
except SystemExit:
raise
except Exception: # noqa: BLE001 - ein Banner darf keinen Login stoeren
sys.exit(0)
+82 -44
View File
@@ -1,89 +1,127 @@
#!/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
# ============================================================================= # =============================================================================
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
exit 2 exit 2
} }
REPO="${TESM_RELEASE_REPO:-$APP}" # --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. Setzt: $TAG (aufgeloest) und $SRC (entpacktes
# Release). Raeumt sein Arbeitsverzeichnis beim Beenden auf.
#
# 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}"
WORK="$(mktemp -d /tmp/tesm-update-XXXXXX)" # Ein Wagenruecklauf im Paket macht jedes Shell-Skript auf dem Ziel unbrauchbar
trap 'rm -rf "$WORK"' EXIT # anders als im Vorgaenger wird aufgeraeumt # ("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() { [[ -n "$BOOTSTRAP_WORK" ]] && rm -rf "$BOOTSTRAP_WORK"; }
trap bootstrap_aufraeumen EXIT
release_holen() {
local repo="${TESM_RELEASE_REPO:-$APP}" asset
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 if [[ "$TAG" == "latest" ]]; then
echo "==> Ermittle aktuellen Release-Tag ..." echo "==> Ermittle aktuellen Release-Tag ..."
TAG="$(curl -fsS "$BASE/api/v1/repos/$OWNER/$REPO/releases/latest" \ TAG="$(curl -fsS "$BASE/api/v1/repos/$OWNER/$repo/releases/latest" \
| python3 -c 'import json,sys; print(json.load(sys.stdin)["tag_name"])')" | python3 -c 'import json,sys; print(json.load(sys.stdin)["tag_name"])')" || {
echo "Kein Release gefunden unter $BASE/$OWNER/$repo." >&2
echo "Andere Quelle: TESM_RELEASE_BASE, TESM_RELEASE_OWNER, TESM_RELEASE_REPO." >&2
return 1
}
echo " $TAG" echo " $TAG"
fi fi
ASSET="$REPO-$TAG.tar.gz" asset="$repo-$TAG.tar.gz"
echo "==> Lade $ASSET ..." echo "==> Lade $asset ..."
curl -fsSL -o "$WORK/$ASSET" "$BASE/$OWNER/$REPO/releases/download/$TAG/$ASSET" curl -fsSL -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 ..." echo "==> Entpacke ..."
tar -xzf "$WORK/$ASSET" -C "$WORK" tar -xzf "$BOOTSTRAP_WORK/$asset" -C "$BOOTSTRAP_WORK"
# Drei Ebenen, nicht zwei: release.sh packt mit dem Praefix "<name>/", also # 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 # liegt das Skript unter <name>/deploy/install.sh. Mit "-maxdepth 2" fand die
# Suche nie etwas -- und die Fehlermeldung schob es auf den Download. # Suche nie etwas -- und die Fehlermeldung schob es auf den Download.
SRC="$(find "$WORK" -maxdepth 3 -name install.sh -path '*/deploy/*' -printf '%h/..\n' | head -1)" SRC="$(find "$BOOTSTRAP_WORK" -maxdepth 3 -name install.sh -path '*/deploy/*' \
[[ -n "$SRC" ]] || { echo "Im Paket fehlt deploy/install.sh -- Download unbrauchbar." >&2; exit 1; } -printf '%h/..\n' | head -1)"
[[ -n "$SRC" ]] || {
echo "Im Paket fehlt deploy/install.sh -- Download unbrauchbar." >&2
return 1
}
SRC="$(cd "$SRC" && pwd)" SRC="$(cd "$SRC" && pwd)"
echo "==> Pruefe auf CRLF (Windows-Buildmaschinen) ..." echo "==> Pruefe auf CRLF (Windows-Buildmaschinen) ..."
if hat_crlf "$SRC/deploy/install.sh"; then if hat_crlf "$SRC/deploy/install.sh"; then
echo "Das Paket enthaelt CRLF-Zeilenenden. Beim Paketieren fehlte" >&2 echo "Das Paket enthaelt CRLF-Zeilenenden. Beim Paketieren fehlte" >&2
echo "'git -c core.autocrlf=false archive'." >&2 echo "'git -c core.autocrlf=false archive'." >&2
exit 1 return 1
fi fi
}
# --8<-- Ende Bootstrap ------------------------------------------------------
release_holen
read -r -p "Version $TAG fuer $APP${EXTRA[0]+ (${EXTRA[*]})} installieren? [j/N] " answer read -r -p "Version $TAG fuer $APP${EXTRA[0]+ (${EXTRA[*]})} installieren? [j/N] " answer
[[ "$answer" =~ ^[jJyY]$ ]] || exit 0 [[ "$answer" =~ ^[jJyY]$ ]] || exit 0
exec sudo bash "$SRC/deploy/install.sh" --app "$APP" --yes ${EXTRA[0]+"${EXTRA[@]}"} # 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[@]}"}
+430
View File
@@ -0,0 +1,430 @@
#!/usr/bin/env bash
# =============================================================================
# Upgrade vom Stand SONNET5 (Version 1.x) auf diese Fassung -- mit Datenuebernahme.
#
# 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 --instance test --source /srv/tesm
#
# Der Ablauf ist die Vorgabe und braucht keine Schalter:
#
# 1. **Sichern.** Vollstaendige Kopie der alten Installation nach
# /srv/<name>-upgrade-<zeitstempel>/alt. Erst danach wird irgendetwas
# angefasst -- und aus dieser Kopie stammen auch alle weiteren Schritte.
# 2. **Altes System abschalten.** Dienst und Timer werden gestoppt und
# deaktiviert, damit sie nach einem Neustart nicht zurueckkommen.
# 3. **Neu installieren.** install.sh dieses Verzeichnisses.
# 4. **Exportieren.** Aus der Kopie entsteht eine Exportdatei im **alten**
# Format -- genau die, die der Vorgaenger selbst geschrieben haette.
# 5. **Uebersetzen.** Daraus eine Datei im neuen Format.
# 6. **Einspielen.** Diese Datei geht durch denselben Import wie eine
# Sicherung.
#
# Warum die Sicherung vor dem Export steht und nicht danach: laeuft das
# Upgrade **in dasselbe Verzeichnis** (also ohne --instance), ueberschreibt
# install.sh die alte Installation. Wer erst exportiert und dann sichert,
# sichert im schlechtesten Fall nichts mehr. Gelesen wird ausschliesslich aus
# der Kopie -- das Ergebnis ist dasselbe, die Reihenfolge ist die sichere.
#
# Der Lizenzserver kennt Schritt 4 und 5 nicht: seine Uebernahme liest die alte
# Datenbank unmittelbar und **signiert jede Lizenz neu**. Dabei wird der alte
# Signaturschluessel uebernommen -- sonst muesste jeder Kunde einen
# Ausstellerwechsel bestaetigen. Wer das nicht will: --new-key. Dort ist die
# Kopie aus Schritt 1 der Export.
# =============================================================================
set -euo pipefail
APP=""
INSTANCE=""
SOURCE=""
PASSPHRASE=""
ASSUME_YES=0
DRY_RUN=0
WITH_DATA=1
STOP_OLD=1
ADOPT_KEY=1
TAG="latest"
#: Namenszusatz, unter dem ``rename.sh`` eine verdraengte Installation der
#: Fassung 1.x ablegt. Beide Skripte muessen denselben Wert benutzen; ein
#: Hygienetest vergleicht sie.
#:
#: Auf einer echten 1.2.8 gibt es das **nicht** -- dort liegt die Installation
#: unter ihrem Hauptnamen. Der Zusatz entsteht erst, wenn ``rename.sh`` den
#: Hauptnamen fuer die neue Fassung braucht. Deshalb ist er der zweite Ort, an
#: dem gesucht wird, nicht der erste.
ARCHIV_SUFFIX="sonnet5"
#: Nur fuer die Ausgabe: wurde die Quelle selbst gefunden oder angegeben?
info_quelle=""
EXTRA=()
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
REPO_DIR="$(cd "$SCRIPT_DIR/.." && pwd)"
COLOR_OK=$'\033[32m'; COLOR_WARN=$'\033[33m'; COLOR_ERR=$'\033[31m'; COLOR_OFF=$'\033[0m'
step() { printf '\n%s==>%s %s\n' "$COLOR_OK" "$COLOR_OFF" "$*"; }
warn() { printf '%s !!%s %s\n' "$COLOR_WARN" "$COLOR_OFF" "$*"; }
fail() { printf '%s !!%s %s\n' "$COLOR_ERR" "$COLOR_OFF" "$*" >&2; exit 1; }
info() { printf ' %s\n' "$*"; }
usage() {
cat <<'USAGE'
Verwendung: upgrade.sh --app <tesm|tesm-license> [Optionen]
--app <name> Welche Anwendung (Pflicht)
--source <pfad> Verzeichnis der alten Installation
(Vorgabe: /srv/tesm bzw. /srv/tesm-license)
--instance <name> Neue Installation als benannte Instanz anlegen, statt
das alte Verzeichnis zu ueberschreiben. Damit bleibt die
alte Installation liegen und ist zurueckholbar.
--passphrase <text> Passphrase der Zwischendateien. Ohne Angabe wird eine
erzeugt und in der Sicherung abgelegt.
--no-data Nur installieren, keine Datenuebernahme
--keep-old-running Alten Dienst nicht stoppen (nur mit --instance sinnvoll)
--new-key Nur Lizenzserver: einen neuen Signaturschluessel
erzeugen, statt den alten zu uebernehmen. Dann muessen
alle Kunden den Ausstellerwechsel bestaetigen -- die
Vorgabe ist deshalb die Uebernahme.
--tag <vX.Y.Z> Bestimmte Version installieren (Vorgabe: latest).
Wird nur beachtet, wenn das Release geladen wird.
--dry-run Nur zeigen, was geschehen wuerde
--yes Keine Rueckfrage
-h, --help Diese Hilfe
Alles weitere wird unveraendert an install.sh weitergegeben, etwa --https
oder --domain.
Beispiele:
# In dasselbe Verzeichnis, alte Installation wird ersetzt:
sudo bash deploy/upgrade.sh --app tesm
# Daneben, zum Vergleich, alte Installation bleibt unangetastet:
sudo bash deploy/upgrade.sh --app tesm --instance test --keep-old-running
USAGE
}
while [[ $# -gt 0 ]]; do
case "$1" in
--app) APP="${2:?}"; shift 2 ;;
--source) SOURCE="${2:?}"; shift 2 ;;
--instance) INSTANCE="${2:?}"; shift 2 ;;
--passphrase) PASSPHRASE="${2:?}"; shift 2 ;;
--no-data) WITH_DATA=0; shift ;;
--keep-old-running) STOP_OLD=0; shift ;;
--new-key) ADOPT_KEY=0; shift ;;
--adopt-key) ADOPT_KEY=1; shift ;; # Vorgabe, hier nur zur Deutlichkeit
--tag) TAG="${2:?}"; shift 2 ;;
--dry-run) DRY_RUN=1; shift ;;
--yes|-y) ASSUME_YES=1; shift ;;
-h|--help) usage; exit 0 ;;
*) EXTRA+=("$1"); shift ;;
esac
done
[[ "$APP" == "tesm" || "$APP" == "tesm-license" ]] || { usage; fail "--app fehlt oder ist unbekannt"; }
[[ "$(id -u)" -eq 0 ]] || fail "bitte mit sudo ausfuehren"
# --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. Setzt: $TAG (aufgeloest) und $SRC (entpacktes
# Release). Raeumt sein Arbeitsverzeichnis beim Beenden auf.
#
# 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 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() { [[ -n "$BOOTSTRAP_WORK" ]] && rm -rf "$BOOTSTRAP_WORK"; }
trap bootstrap_aufraeumen EXIT
release_holen() {
local repo="${TESM_RELEASE_REPO:-$APP}" asset
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 "$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
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 -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"
NAME="$APP"
[[ -n "$INSTANCE" ]] && NAME="$APP-$INSTANCE"
ADMIN="$NAME-admin"
NEW_ROOT="/srv/$NAME"
IN_PLACE=0
[[ "$(cd "$SOURCE" && pwd)" == "$NEW_ROOT" ]] && IN_PLACE=1
STAMP="$(date +%Y%m%d-%H%M%S)"
BACKUP="/srv/$NAME-upgrade-$STAMP"
ALT_KOPIE="$BACKUP/alt"
EXPORT_ALT="$BACKUP/export-alt.json"
IMPORT_NEU="$BACKUP/import-neu.json"
# Alte Einheiten der Vorgaengerfassung. Der Timer gehoert dazu: nur den Dienst
# zu stoppen liesse ihn beim naechsten Ablauf wieder anlaufen.
if [[ "$APP" == "tesm" ]]; then
ALTE_UNITS=(tesm.service tesm-check.timer tesm-check.service
tesm-check-restart.timer tesm-check-restart.service)
else
ALTE_UNITS=(tesm-license.service)
fi
step "Vorhaben"
info "Anwendung : $APP${INSTANCE:+ (Instanz $INSTANCE)}"
info "Neue Fassung : $RELEASE_HERKUNFT"
info "Alt : $SOURCE${info_quelle:+ (automatisch gefunden)}"
info "Neu : $NEW_ROOT$([[ $IN_PLACE -eq 1 ]] && echo ' << dasselbe Verzeichnis')"
info "Sicherung : $ALT_KOPIE"
info "Datenuebernahme: $([[ $WITH_DATA -eq 1 ]] && echo ja || echo nein)"
info "Altes System : $([[ $STOP_OLD -eq 1 ]] && echo 'stoppen und deaktivieren' || echo 'laeuft weiter')"
if [[ $IN_PLACE -eq 1 && $STOP_OLD -eq 0 ]]; then
warn "--keep-old-running bei gleichem Verzeichnis: der alte Dienst laeuft auf Dateien,"
warn "die gleich ersetzt werden. Das geht schief. Bitte --instance verwenden."
fi
if [[ $DRY_RUN -eq 1 ]]; then
step "--dry-run: hier ist Schluss."
exit 0
fi
if [[ $ASSUME_YES -eq 0 ]]; then
read -r -p "Fortfahren? [j/N] " answer
[[ "$answer" =~ ^[jJyY]$ ]] || { echo "Abgebrochen."; exit 0; }
fi
# -- 1. Sichern -------------------------------------------------------------- #
step "Sichern"
mkdir -p "$ALT_KOPIE"
chmod 700 "$BACKUP"
# -a: Rechte und Zeiten bleiben, damit die Kopie auch als Rueckweg taugt.
# Das venv der alten Installation bleibt aussen vor -- es ist nachbaubar,
# gross, und seine Shebangs zeigen ohnehin auf den alten Pfad.
tar -C "$SOURCE" --exclude=venv --exclude=__pycache__ -cf - . | tar -C "$ALT_KOPIE" -xf -
info "$(du -sh "$ALT_KOPIE" | cut -f1) kopiert (ohne venv)"
[[ -f "$ALT_KOPIE/sqlite.db" ]] || fail "In der Kopie fehlt die Datenbank -- Abbruch"
# -- 2. Altes System abschalten ---------------------------------------------- #
if [[ $STOP_OLD -eq 1 ]]; then
step "Altes System abschalten"
for unit in "${ALTE_UNITS[@]}"; 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 und deaktiviert"
fi
done
else
step "Altes System bleibt in Betrieb"
fi
# -- 3. Neu installieren ----------------------------------------------------- #
step "Neu installieren"
INSTALL_ARGS=(--app "$APP" --yes)
[[ -n "$INSTANCE" ]] && INSTALL_ARGS+=(--instance "$INSTANCE")
bash "$SRC/deploy/install.sh" "${INSTALL_ARGS[@]}" ${EXTRA[0]+"${EXTRA[@]}"}
if [[ $WITH_DATA -eq 0 ]]; then
step "Fertig -- ohne Datenuebernahme"
info "Sicherung der alten Installation: $ALT_KOPIE"
exit 0
fi
command -v "$ADMIN" >/dev/null 2>&1 || fail "$ADMIN nicht gefunden -- die Installation ist unvollstaendig"
if [[ -z "$PASSPHRASE" ]]; then
PASSPHRASE="$(head -c 24 /dev/urandom | base64 | tr -d '=+/' | cut -c1-24)"
# Die Passphrase liegt neben den Dateien, die sie schuetzt. Das ist hier kein
# Widerspruch: das alte Format verlangt eine, das Verzeichnis gehoert root
# allein (0700), und ohne sie waeren die beiden Dateien spaeter unbrauchbar --
# auch als Rueckweg.
printf '%s\n' "$PASSPHRASE" > "$BACKUP/passphrase.txt"
chmod 600 "$BACKUP/passphrase.txt"
fi
if [[ "$APP" == "tesm" ]]; then
# -- 4. Exportieren -------------------------------------------------------- #
step "Daten der alten Installation lesen"
"$ADMIN" legacy-export --source "$ALT_KOPIE" --out "$EXPORT_ALT" \
--passphrase "$PASSPHRASE" --force
chmod 600 "$EXPORT_ALT"
# -- 5. Uebersetzen -------------------------------------------------------- #
step "Uebersetzen"
"$ADMIN" convert-legacy "$EXPORT_ALT" --ausgabe "$IMPORT_NEU" \
--passphrase "$PASSPHRASE" --neue-passphrase "$PASSPHRASE" --force
chmod 600 "$IMPORT_NEU"
# -- 6. Einspielen --------------------------------------------------------- #
step "Einspielen"
"$ADMIN" legacy-import "$IMPORT_NEU" --passphrase "$PASSPHRASE" --actor upgrade
else
# -- 4.-6. in einem: die Uebernahme liest die alte Datenbank unmittelbar --- #
step "Lizenzbestand uebernehmen"
# Der alte Signaturschluessel wird uebernommen. Fuer ein Upgrade ist das
# die einzige sinnvolle Vorgabe: es ist derselbe Server mit neuerer Software,
# und ohne den alten Schluessel muesste jeder Kunde den Ausstellerwechsel
# bestaetigen. Ueberschrieben wird dabei nichts -- die Uebernahme lehnt ab,
# wenn die neue Installation schon einen eigenen Schluessel hat.
MIGRATE_ARGS=(migrate-legacy --source "$ALT_KOPIE")
[[ $ADOPT_KEY -eq 1 ]] && MIGRATE_ARGS+=(--adopt-key)
"$ADMIN" "${MIGRATE_ARGS[@]}"
if [[ $ADOPT_KEY -eq 0 ]]; then
warn "--new-key: es gilt ein neuer Signaturschluessel. Die Kunden muessen den"
warn "Ausstellerwechsel einmal bestaetigen."
fi
fi
# -- Abschluss --------------------------------------------------------------- #
step "Fertig"
info "Sicherung der alten Installation : $ALT_KOPIE"
if [[ "$APP" == "tesm" ]]; then
info "Export im alten Format : $EXPORT_ALT"
info "Uebersetzte Datei : $IMPORT_NEU"
[[ -f "$BACKUP/passphrase.txt" ]] && info "Passphrase beider Dateien : $BACKUP/passphrase.txt"
fi
cat <<TEXT
Von Hand nachzuziehen -- das laesst sich nicht uebersetzen:
* Lokale Konten: der alte Hash ist bcrypt, hier gilt Argon2id. Ein Hash
ist nicht umrechenbar; das ist sein Zweck. Erstes Konto anlegen mit
sudo -u $APP $ADMIN create-admin
* Gruppen und Rechte: die Rechteschluessel wurden neu geschnitten.
* Verzeichnisdienst (LDAP), Webserver und Zertifikat.
TEXT
# Der Rueckweg sieht in den beiden Faellen verschieden aus. Ein Hinweis, der
# das alte Verzeichnis wiederherstellen laesst, obwohl es nie angefasst wurde,
# waere schlimmer als keiner: er wuerde die Daten der neuen Installation mit
# einem alten Stand ueberschreiben.
if [[ $IN_PLACE -eq 1 ]]; then
cat <<TEXT
Zurueck auf den alten Stand -- das Verzeichnis wurde ersetzt, die Kopie
bringt es zurueck:
sudo systemctl disable --now $NAME
sudo rm -rf $SOURCE && sudo mkdir -p $SOURCE
sudo tar -C $ALT_KOPIE -cf - . | sudo tar -C $SOURCE -xf -
# venv der alten Installation neu bauen:
sudo python3 -m venv $SOURCE/venv
sudo $SOURCE/venv/bin/python -m pip install -r $SOURCE/requirements.txt
sudo systemctl enable --now ${ALTE_UNITS[0]}
TEXT
else
cat <<TEXT
Zurueck auf den alten Stand -- $SOURCE wurde nicht angefasst, es genuegt,
die neue Instanz abzuschalten:
sudo systemctl disable --now $NAME $NAME-monitor
sudo rm -f /etc/nginx/sites-enabled/$NAME && sudo systemctl reload nginx
sudo systemctl enable --now ${ALTE_UNITS[0]}
TEXT
fi
+169 -5
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
@@ -141,6 +148,86 @@ etwa auf eine Spiegelung:
Der erwartete Anhang heisst `<repo>-<tag>.tar.gz`. Der erwartete Anhang heisst `<repo>-<tag>.tar.gz`.
### Upgrade vom Stand 1.x (Branch `SONNET5`)
Ein eigenes Skript, weil dabei mehr passiert als ein Dateiabgleich: die alte
Fassung hat ein anderes Schema, andere Rechteschlüssel und ein anderes
Exportformat.
Auch `upgrade.sh` genügt als **einzelne Datei** -- und das ist hier der
Regelfall: auf einer 1.2.8 liegt nichts von dieser Fassung.
```bash
curl -fsSLO https://gitea.int.eertmoed.net/alientim/tesm/raw/branch/main/deploy/upgrade.sh
sudo bash upgrade.sh --app tesm
```
Sie lädt das aktuelle Release, entpackt es und benutzt dessen `install.sh`.
Liegt das Skript in einem entpackten Release, wird dieses benutzt statt eines
Downloads -- wer ein Paket ausgepackt hat, will nicht, dass ungefragt ein
anderes geladen wird:
```bash
sudo bash deploy/upgrade.sh --app tesm
sudo bash deploy/upgrade.sh --app tesm-license
```
Eine bestimmte Version statt `latest`: `--tag v2.0.8`.
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:
| | |
|---|---|
| 1. Sichern | Vollständige Kopie der alten Installation nach `/srv/<name>-upgrade-<zeitstempel>/alt` (ohne `venv`, Verzeichnis 0700) |
| 2. Abschalten | Alter Dienst **und seine Timer** werden gestoppt und deaktiviert |
| 3. Installieren | `install.sh` dieses Verzeichnisses |
| 4. Exportieren | Aus der Kopie entsteht `export-alt.json` im **alten** Format |
| 5. Übersetzen | Daraus `import-neu.json` im neuen Format |
| 6. Einspielen | Diese Datei geht durch denselben Import wie eine Sicherung |
Warum die Sicherung **vor** dem Export steht: ohne `--instance` installiert
`install.sh` in dasselbe Verzeichnis und ersetzt die alte Installation. Wer
erst exportiert und dann sichert, sichert im schlechtesten Fall nichts mehr.
Gelesen wird ausschliesslich aus der Kopie -- die alte Installation wird nie
verändert.
Die beiden Zwischendateien bleiben liegen; ihre Passphrase steht daneben in
`passphrase.txt` (0600, Verzeichnis 0700). `export-alt.json` ist ein echter
Export des Vorgängers und taugt deshalb auch als Rückweg.
**Daneben statt darüber.** Mit `--instance` entsteht eine zweite Installation,
und die alte bleibt vollständig liegen:
```bash
sudo bash deploy/upgrade.sh --app tesm --instance test --keep-old-running --port 5100 --http-port 8080
```
**Was nicht mitkommt** -- und warum:
* **Lokale Konten.** Der alte Hash ist bcrypt, hier gilt Argon2id. Ein Hash
ist nicht umrechenbar; das ist sein Zweck. Erstes Konto mit
`tesm-admin create-admin`.
* **Gruppen und Rechte.** Die Rechteschlüssel wurden neu geschnitten.
* **LDAP, Webserver, Zertifikat.** Von Hand nachziehen.
Beim **Lizenzserver** gibt es Schritt 4 und 5 nicht: die Übernahme liest die
alte Datenbank unmittelbar, holt Endpunkt und Anbieterdaten aus ihren
Einstellungen und **signiert jede Lizenz neu**. Der alte Signaturschlüssel wird
dabei übernommen -- sonst müsste jeder Kunde einen Ausstellerwechsel
bestätigen; `--new-key` erzwingt das Gegenteil. Der Bericht nennt namentlich,
wer eine neue Lizenzdatei braucht, denn die Bindung an eine Maschine gilt nur
für die alte Datei.
Nur die Anwendung, ohne Daten: `--no-data`. Nur zeigen, was geschähe:
`--dry-run`.
--- ---
## 4. HTTPS ## 4. HTTPS
@@ -193,6 +280,83 @@ würde). Der ACME-Pfad bleibt auch bei aktiver Umleitung erreichbar.
--- ---
## 4a. Anmeldebanner (MOTD)
`install.sh` legt `/etc/update-motd.d/50-<name>` an. Bei jeder SSH-Anmeldung
steht dann der Zustand der Installation im Login-Banner:
```
TESM · TimEShepManager
UEBERWACHEN · BOOTEN · ANBINDEN (DHCP) · BETRIEBSSYSTEM GEBEN (PXE)
3 Clients online · 0 nicht erreichbar · DHCP: 2 Leases aktiv
```
Das Skript liegt unter `/usr/local/lib/tesm/tesm-motd` und benutzt **nur die
Standardbibliothek**: kein venv, kein Import der Anwendung, die Datenbank wird
nur lesend geöffnet. Ein Lauf dauert Millisekunden. Der Vorgänger importierte
dafür die ganze Flask-Anwendung -- bei jeder Anmeldung, und nach einem Wechsel
des Quellverzeichnisses zeigte er stillschweigend die Zahlen einer Datenbank,
die niemand mehr füllte.
Von Hand prüfen:
```bash
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
hängender oder fehlschlagender Bannerlauf darf eine Anmeldung nie blockieren.
Ein Banner der Vorgängerfassung (`50-tesm`, das `motd_banner.py` aufruft) wird
entfernt -- nur dieses, nichts anderes in dem Verzeichnis.
---
## 4b. Installation umbenennen
Aus einer Instanz die Hauptinstallation machen (oder umgekehrt):
```bash
sudo bash deploy/rename.sh --app tesm --from tesm-test --to tesm
sudo bash deploy/rename.sh --app tesm-license --from tesm-license-test --to tesm-license
```
Der Name steckt an mehr Stellen als im Verzeichnisnamen, und drei davon sind
leicht zu übersehen:
* **`SITE_KEY`** in der Umgebungsdatei -- daraus entsteht der Name des
Sitzungscookies. Bleibt er alt, heisst das Cookie weiter wie die alte
Instanz.
* **`web_cert_path`, `web_key_path`, `web_acme_webroot`** in der Datenbank. Sie
enthalten den Namen; ein Zertifikat unter dem alten Pfad findet nginx nach
dem Umzug nicht mehr.
* **`server_name`** der nginx-Site. `install.sh` schreibt die Datei aus seiner
Vorlage neu, sobald sich die `alias`-Pfade ändern -- und die ändern sich hier
zwangsläufig. Ohne den Namen aus der Datenbank stünden danach zwei Sites mit
`server_name _` auf Port 80, und die erste gewinnt.
Ausserdem wird das **venv verworfen und neu gebaut**: seine Konsolenskripte
tragen den absoluten Pfad im Shebang, und nach dem Umzug zeigt
`venv/bin/gunicorn` auf einen Interpreter, den es nicht mehr gibt. systemd
meldet dann `No such file or directory` -- für eine Datei, die sichtbar da ist.
Belegt der Zielname noch eine Installation der Fassung 1.x, wandert **alles**,
was ihn belegt, in ein Archiv `/srv/<ziel>-sonnet5`: das Verzeichnis, die
Protokolle unter `/var/log/<ziel>`, der ACME-Pfad, die logrotate-Datei, und die
systemd-Units samt nginx-Site unter `systemd-alt/`. Gelöscht wird nichts.
Ports und HTTPS bleiben, wie sie waren; ein vorhandenes Zertifikat zieht mit
und wird weiterverwendet. **Alle angemeldeten Benutzer werden abgemeldet** --
das Sitzungscookie heisst danach anders.
Danach ist auch das Aktualisieren einfacher, weil der Instanzname wegfällt:
```bash
sudo bash /srv/tesm/deploy/update.sh tesm
```
---
## 5. Dienste ## 5. Dienste
```bash ```bash
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project] [project]
name = "tesm-core" name = "tesm-core"
version = "2.0.2" version = "2.0.9"
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
@@ -130,11 +130,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.2", app_version: str = "2.0.9",
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,
@@ -259,6 +259,17 @@ def create_base_app(
@app.after_request @app.after_request
def _finish_request(response): # type: ignore[no-untyped-def] def _finish_request(response): # type: ignore[no-untyped-def]
# Ein Aktualisierungsdurchlauf, der mit Sitzungscookie kommt und
# unangemeldet endet, hat gerade seine Sitzung verloren. Bei einer
# geschuetzten Seite sagt das schon der Status (401); bei einer, die
# auch oeffentlich antwortet -- die Statusuebersicht etwa -- kommt eine
# 200 mit den oeffentlichen Kacheln zurueck, und der offene Tab merkt
# nichts: er zeigt weiter eine Oberflaeche, als sei alles in Ordnung.
# Deshalb ein Kopf, den der Browser unabhaengig vom Status auswerten
# kann.
if session_store.is_automatic(request) and not current_user.is_authenticated:
if request.cookies.get(app.config["SESSION_COOKIE_NAME"], ""):
response.headers["X-Session-Expired"] = "1"
hsts = "" hsts = ""
if config.security.hsts_enabled: if config.security.hsts_enabled:
hsts = f"max-age={config.security.hsts_max_age}; includeSubDomains" hsts = f"max-age={config.security.hsts_max_age}; includeSubDomains"
@@ -10,6 +10,7 @@ from flask import abort, flash, g, redirect, request, session, url_for
from werkzeug.local import LocalProxy from werkzeug.local import LocalProxy
from ..rbac.model import Action, PermissionTree from ..rbac.model import Action, PermissionTree
from ..security.sessions import is_automatic
AUTH_SOURCES = ("local", "ldap") AUTH_SOURCES = ("local", "ldap")
@@ -189,9 +190,22 @@ def _deny(message: str) -> Any:
if not current_user.is_authenticated: if not current_user.is_authenticated:
if request.method == "GET" and not request.headers.get("X-Requested-With"): if request.method == "GET" and not request.headers.get("X-Requested-With"):
session["_after_login"] = request.full_path if request.query_string else request.path session["_after_login"] = request.full_path if request.query_string else request.path
# Eine automatische Aktualisierung fragt einen Ausschnitt ab und
# erwartet HTML. Eine Umleitung auf die Anmeldeseite wuerde sie
# dankbar entgegennehmen und eine ganze Seite in eine Kachel setzen --
# der Tab bliebe stumm stehen, obwohl die Sitzung abgelaufen ist. 401
# sagt es deutlich; der Browser laedt daraufhin die Seite neu und der
# Mensch sieht die Anmeldemaske.
if is_automatic(request):
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)
+1 -1
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
@@ -26,7 +26,8 @@ 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.sessions import SessionInterface, SessionMixin from flask.sessions import SessionInterface, SessionMixin
from werkzeug.datastructures import CallbackDict from werkzeug.datastructures import CallbackDict
@@ -34,6 +35,79 @@ from ..db import Database
SESSION_ID_BYTES = 32 SESSION_ID_BYTES = 32
#: Kopfzeile, mit der der Browser eine Anfrage als **automatisch** kennzeichnet:
#: ein Aktualisierungsdurchlauf einer Kachel, kein Klick eines Menschen.
#:
#: Ohne diese Unterscheidung ist die automatische Abmeldung wirkungslos, und das
#: war sie auch: ein offener Tab fragt seine Bereiche alle 15 Sekunden neu ab,
#: jede Abfrage verlaengerte das Leerlauffenster, und niemand wurde je
#: abgemeldet -- nachweisbar an einer Sitzung, deren ``last_seen_at`` eine
#: Minute alt war, obwohl seit zehn Stunden niemand am Rechner sass.
#:
#: Die Entscheidung gehoert zum Client: nur er weiss, ob ein Zeitgeber oder ein
#: Mensch die Anfrage ausgeloest hat. Ein Angreifer koennte den Kopf setzen --
#: er wuerde damit seine *eigene* Sitzung schneller ablaufen lassen.
AUTO_REFRESH_HEADER = "X-Auto-Refresh"
#: Marke auf der Ansichtsfunktion. Steht an der Route, damit man sie dort sieht,
#: wo sie gilt.
FRAGMENT_ATTRIBUT = "_tesm_fragment"
def fragment(view: Any) -> Any:
"""Kennzeichnet eine Route als Aktualisierungsausschnitt.
Solche Routen ruft ein Zeitgeber auf, kein Mensch. Sie duerfen die Sitzung
benutzen, halten sie aber nicht am Leben -- sonst bleibt jeder offene Tab
unbegrenzt angemeldet.
Der Kopf ``X-Auto-Refresh`` allein genuegt dafuer nicht: er setzt voraus,
dass der Browser das aktuelle Skript faehrt. Ein Tab, der seit Stunden
offen ist, faehrt das alte -- und das ist genau der Tab, um den es geht.
"""
setattr(view, FRAGMENT_ATTRIBUT, True)
return view
def _ist_fragment_route() -> bool:
"""Gehoert die laufende Anfrage zu einer als Ausschnitt markierten Route?
``request.endpoint`` steht hier nicht immer schon: Flask oeffnet die Sitzung
**vor** dem Routing (``RequestContext.push`` ruft ``open_session`` und erst
danach ``match_request``). In ``save_session`` ist der Endpunkt gesetzt, in
``open_session`` nicht -- und ohne diesen Nachtrag griff die Erkennung
genau dort nicht, wo sie gebraucht wird.
"""
from flask import current_app
try:
endpunkt = getattr(aktuelle_anfrage, "endpoint", None)
if not endpunkt:
# Selbst nachsehen. Ein zweiter Routenabgleich je Anfrage ist
# billiger als eine Abmeldung, die nicht stattfindet.
adapter = current_app.url_map.bind_to_environ(aktuelle_anfrage.environ)
endpunkt, _ = adapter.match(
aktuelle_anfrage.path, method=aktuelle_anfrage.method
)
ansicht = current_app.view_functions.get(str(endpunkt))
except Exception: # noqa: BLE001 - keine Route, Umleitung, kein App-Kontext
return False
return bool(getattr(ansicht, FRAGMENT_ATTRIBUT, False))
def is_automatic(request: Request) -> bool:
"""Kam die Anfrage von einem Zeitgeber statt von einem Menschen?
Zwei Kennzeichen, und eines genuegt: der Kopf, den das Skript setzt,
und die Route selbst. Das zweite traegt auch dann, wenn ein lange
offener Tab noch ein aelteres Skript faehrt -- und das ist der Tab,
um den es geht.
"""
if request.headers.get(AUTO_REFRESH_HEADER, "") == "1":
return True
return _ist_fragment_route()
def _now() -> datetime: def _now() -> datetime:
return datetime.now(timezone.utc) return datetime.now(timezone.utc)
@@ -135,6 +209,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:
@@ -154,8 +233,17 @@ class SqliteSessionInterface(SessionInterface):
} }
# Leerlauffenster verlaengern, aber hoechstens einmal pro Minute # Leerlauffenster verlaengern, aber hoechstens einmal pro Minute
# schreiben -- sonst kostet jeder AJAX-Poll einen Schreibvorgang. # schreiben -- sonst kostet jeder AJAX-Poll einen Schreibvorgang.
#
# Eine automatische Abfrage verlaengert gar nichts. Sie darf die
# Sitzung benutzen, solange diese lebt, aber sie haelt sie nicht am
# Leben: sonst bleibt jeder offene Tab bis in alle Ewigkeit
# angemeldet, und "Abmeldung nach 30 Minuten Leerlauf" ist eine
# Zusage, die die Anwendung nicht einhaelt. Damit bleibt
# ``last_seen_at`` ausserdem, was der Name sagt: der letzte
# Zeitpunkt, an dem ein Mensch da war.
last_seen = _parse(row.get("last_seen_at")) last_seen = _parse(row.get("last_seen_at"))
if last_seen is None or (now - last_seen) > timedelta(seconds=60): veraltet = last_seen is None or (now - last_seen) > timedelta(seconds=60)
if veraltet and not is_automatic(request):
with self.database.transaction(conn): with self.database.transaction(conn):
conn.execute( conn.execute(
"UPDATE user_sessions SET last_seen_at=?, idle_expires_at=? WHERE id=?", "UPDATE user_sessions SET last_seen_at=?, idle_expires_at=? WHERE id=?",
@@ -211,6 +299,31 @@ class SqliteSessionInterface(SessionInterface):
), ),
) )
session.new = False session.new = False
# ``save_session`` bekommt die Anfrage nicht als Argument -- anders als
# ``open_session``, wo ein Parameter denselben Namen traegt. Hier ist es
# der Anfragekontext von Flask; die Antwort wird darin erzeugt.
elif is_automatic(aktuelle_anfrage):
# Die zweite Schreibstelle, und die eigentliche Ursache dafuer,
# dass die Abmeldung nie ausgeloest hat: hier wurde das
# Leerlauffenster bei **jeder** Anfrage verlaengert, die die
# Sitzung anfasst. Die Pruefung in open_session lief damit ins
# Leere -- ein Aktualisierungsdurchlauf einer Kachel fasst die
# Sitzung an (Flash-Speicher, CSRF), und schon stand das Fenster
# wieder 30 Minuten offen. Auf dem Lizenzserver fiel es nicht
# auf: dort pollt keine Kachel.
#
# Geaenderte Sitzungsdaten muessen trotzdem erhalten bleiben --
# sie zu verwerfen waere eine zweite Fehlerquelle. Nur die
# Fristen bleiben stehen.
conn.execute(
"UPDATE user_sessions SET user_id=?, data=?, reauth_at=? WHERE id=?",
(
session.get("user_id"),
payload,
session.get("_reauth_at"),
session.sid,
),
)
else: else:
conn.execute( conn.execute(
"UPDATE user_sessions SET user_id=?, data=?, last_seen_at=?, " "UPDATE user_sessions SET user_id=?, data=?, last_seen_at=?, "
@@ -219,7 +332,11 @@ class SqliteSessionInterface(SessionInterface):
session.get("user_id"), session.get("user_id"),
payload, payload,
_iso(now), _iso(now),
_iso(now + timedelta(minutes=self.idle_minutes)), # ``_idle(conn)`` und nicht ``idle_minutes``: die Zeit ist
# zur Laufzeit einstellbar, und diese Stelle hat die
# Einstellung ignoriert. Bei der Vorgabe von 30 Minuten
# fiel das nicht auf -- bei einer geaenderten schon.
_iso(now + timedelta(minutes=self._idle(conn))),
session.get("_reauth_at"), session.get("_reauth_at"),
session.sid, session.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; }
@@ -695,7 +698,9 @@
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);
} }
/* 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 +854,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 +951,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 +1040,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 {
@@ -45,22 +45,45 @@ function csrfToken() {
return document.body?.dataset.csrf || ""; return document.body?.dataset.csrf || "";
} }
/** fetch mit CSRF-Header und JSON-Erwartung. */ /**
* fetch mit CSRF-Header und JSON-Erwartung.
*
* `auto: true` kennzeichnet eine Anfrage, die ein Zeitgeber ausgeloest hat und
* kein Mensch. Der Server verlaengert das Leerlauffenster der Sitzung dann
* nicht -- ohne diese Unterscheidung haelt jeder offene Tab die Anmeldung
* unbegrenzt am Leben, und die automatische Abmeldung findet nie statt.
*
* Der HTTP-Status haengt am geworfenen Fehler, damit der Aufrufer 401
* ("Sitzung abgelaufen") von einem Netzfehler unterscheiden kann.
*/
export async function api(url, options = {}) { export async function api(url, options = {}) {
const opts = { credentials: "same-origin", ...options }; const { auto = false, ...rest } = options;
const opts = { credentials: "same-origin", ...rest };
opts.headers = { opts.headers = {
"X-Requested-With": "fetch", "X-Requested-With": "fetch",
"X-CSRF-Token": csrfToken(), "X-CSRF-Token": csrfToken(),
...(auto ? { "X-Auto-Refresh": "1" } : {}),
...(options.headers || {}), ...(options.headers || {}),
}; };
const response = await fetch(url, opts); const response = await fetch(url, opts);
// Eine Seite, die auch oeffentlich antwortet -- die Statusuebersicht etwa --
// liefert nach dem Ablauf der Sitzung eine 200 mit den oeffentlichen
// Kacheln. Am Status ist der Verlust also nicht zu erkennen; an diesem Kopf
// schon. Behandelt wird er wie eine 401.
if (response.headers.get("X-Session-Expired") === "1") {
const abgelaufen = new Error("Die Sitzung ist abgelaufen.");
abgelaufen.status = 401;
throw abgelaufen;
}
const type = response.headers.get("content-type") || ""; const type = response.headers.get("content-type") || "";
const payload = type.includes("application/json") ? await response.json() : await response.text(); const payload = type.includes("application/json") ? await response.json() : await response.text();
if (!response.ok) { if (!response.ok) {
const message = const message =
(payload && typeof payload === "object" && (payload.error || payload.message)) || (payload && typeof payload === "object" && (payload.error || payload.message)) ||
`Anfrage fehlgeschlagen (HTTP ${response.status})`; `Anfrage fehlgeschlagen (HTTP ${response.status})`;
throw new Error(message); const error = new Error(message);
error.status = response.status;
throw error;
} }
return payload; return payload;
} }
@@ -556,7 +579,10 @@ function initAutoRefresh() {
const tick = async () => { const tick = async () => {
if (stopped || document.hidden) return schedule(); if (stopped || document.hidden) return schedule();
try { try {
const html = await api(region.dataset.refreshUrl, { headers: { Accept: "text/html" } }); const html = await api(region.dataset.refreshUrl, {
auto: true,
headers: { Accept: "text/html" },
});
// Eine vollstaendige Seite gehoert nie in einen Ausschnitt. Zeigt die // Eine vollstaendige Seite gehoert nie in einen Ausschnitt. Zeigt die
// URL versehentlich auf eine ganze Seite, waere das Ergebnis eine // URL versehentlich auf eine ganze Seite, waere das Ergebnis eine
// Seite in der Seite -- bei jedem Durchlauf eine Ebene tiefer. // Seite in der Seite -- bei jedem Durchlauf eine Ebene tiefer.
@@ -588,7 +614,16 @@ function initAutoRefresh() {
initRelativeTimes(region); initRelativeTimes(region);
initCountdowns(region); initCountdowns(region);
} }
} catch { } catch (error) {
// 401 heisst: die Sitzung ist abgelaufen. Weiterpollen waere sinnlos,
// und stehenbleiben waere irrefuehrend -- die Kacheln zeigten dann
// beliebig alte Zahlen, als sei alles in Ordnung. Neu laden bringt die
// Anmeldemaske mit ihrem Hinweis.
if (error && error.status === 401) {
stopped = true;
window.location.reload();
return;
}
/* Netzfehler: beim naechsten Intervall erneut versuchen. */ /* Netzfehler: beim naechsten Intervall erneut versuchen. */
} }
schedule(); schedule();
@@ -1,6 +1,9 @@
{% extends "tesm_core/standalone.html" %} {% extends "tesm_core/standalone.html" %}
{# Die Ueberschrift bleibt fuer die Titelzeile des Browsers -- sichtbar ist sie
hier nicht: der leere ``brand``-Block laesst Kachel, Ueberschrift und
Unterzeile weg. Die Wortmarke darueber sagt schon, wo man ist. #}
{% block heading %}Anmelden{% endblock %} {% block heading %}Anmelden{% endblock %}
{% block subheading %}{{ app_name }} &ndash; {{ app_edition }}{% endblock %} {% block brand %}{% endblock %}
{% block content %} {% block content %}
<form method="post" class="stack" data-no-guard autocomplete="on"> <form method="post" class="stack" data-no-guard autocomplete="on">
@@ -19,7 +19,7 @@
<td class="fs-sm">{{ row.actor }}</td> <td class="fs-sm">{{ row.actor }}</td>
<td class="mono fs-xs">{{ row.action }}</td> <td class="mono fs-xs">{{ row.action }}</td>
<td class="fs-sm">{{ row.target }}</td> <td class="fs-sm">{{ row.target }}</td>
<td class="fs-sm muted">{{ row.detail }}</td> <td class="fs-sm muted">{{ row.detail | audit_detail }}</td>
<td class="mono fs-xs faint">{{ row.hash[:12] }}</td> <td class="mono fs-xs faint">{{ row.hash[:12] }}</td>
</tr> </tr>
{% endfor %} {% endfor %}
@@ -64,7 +64,7 @@
<td class="fs-sm fw-600">{{ row.actor }}</td> <td class="fs-sm fw-600">{{ row.actor }}</td>
<td class="mono fs-xs">{{ row.action }}</td> <td class="mono fs-xs">{{ row.action }}</td>
<td class="fs-sm">{{ row.target }}</td> <td class="fs-sm">{{ row.target }}</td>
<td class="fs-sm muted break-all">{{ row.detail }}</td> <td class="fs-sm muted break-all">{{ row.detail | audit_detail }}</td>
<td> <td>
{# Ampel: Info grün, Hinweis gelb, Warnung rot. Vorher war Info {# Ampel: Info grün, Hinweis gelb, Warnung rot. Vorher war Info
grau und Hinweis blau -- dabei war nicht zu sehen, was harmlos grau und Hinweis blau -- dabei war nicht zu sehen, was harmlos
@@ -22,37 +22,51 @@
</head> </head>
<body data-csrf="{{ csrf_token() }}"> <body data-csrf="{{ csrf_token() }}">
<div class="auth-page"> <div class="auth-page">
{% if auth_tagline %} <div class="auth-stack">
{# Wortmarke mit Funktionszeile als tatsächlicher Seitenhintergrund -- {# Wortmarke als Seitenhintergrund -- fuer beide Anwendungen. Bewusst inline
übernommen vom Vorgänger. Bewusst inline und mit Themen-Tokens statt und mit Themen-Tokens statt zweier Dateien fuer hell und dunkel: bei der
zweier Dateien für hell und dunkel: bei der Einstellung "System" und Einstellung "System" und beim Umschalten von Hand waere die versteckte
beim Umschalten von Hand wäre die versteckte Variante die falsche. Variante die falsche. ``aria-hidden``, weil der Name in der Titelzeile
``aria-hidden``, weil der Name schon in der Überschrift steht. #} steht.
Die Geometrie ist symmetrisch um die Mitte (x=260) aufgebaut, damit die
Marke ohne Nachrechnen mittig sitzt: der Schriftzug haengt an
``text-anchor="middle"``, die beiden Zacken liegen spiegelbildlich, und
die Linie beginnt links so weit vor der Mitte, wie der Punkt rechts nach
ihr endet. Wer die Linie verlaengert, muss beide Enden anfassen.
Ohne Funktionszeile (Lizenzserver) ist der Kasten kuerzer -- sonst
stuende unter der Marke ein leerer Streifen. #}
<div class="auth-backdrop" aria-hidden="true"> <div class="auth-backdrop" aria-hidden="true">
<svg viewBox="0 0 620 190" xmlns="http://www.w3.org/2000/svg"> <svg viewBox="0 0 520 {{ 150 if auth_tagline else 116 }}" xmlns="http://www.w3.org/2000/svg">
<g transform="translate(150,15)"> <circle cx="452" cy="62" r="15" fill="var(--logo-mark)" opacity="0.22"/>
<circle cx="298" cy="70" r="15" fill="var(--logo-mark)" opacity="0.22"/> <polyline points="60,62 163,62 171,12 179,112 187,62 333,62 341,12 349,112 357,62 452,62"
<polyline points="0,70 90,70 100,70 108,20 116,120 124,70 134,70 200,70 210,70
218,35 226,105 234,70 244,70 300,70"
fill="none" stroke="var(--logo-mark)" stroke-width="3.2" fill="none" stroke="var(--logo-mark)" stroke-width="3.2"
stroke-linecap="round" stroke-linejoin="round" opacity="0.85"/> stroke-linecap="round" stroke-linejoin="round" opacity="0.85"/>
<text x="14" y="98" font-family="var(--font-mono)" font-weight="800" <text x="260" y="90" text-anchor="middle" font-family="var(--font-mono)"
font-size="82" letter-spacing="2" fill="var(--logo-text)">TESM</text> font-weight="800" font-size="82" letter-spacing="2"
<circle cx="298" cy="70" r="8" fill="var(--logo-mark)"/> fill="var(--logo-text)">TESM</text>
</g> <circle cx="452" cy="62" r="8" fill="var(--logo-mark)"/>
<text x="310" y="178" text-anchor="middle" font-family="var(--font-sans)" {% if auth_tagline %}
<text x="260" y="140" text-anchor="middle" font-family="var(--font-sans)"
font-weight="600" font-size="13" letter-spacing="1.5" font-weight="600" font-size="13" letter-spacing="1.5"
fill="var(--logo-mark)">{{ auth_tagline }}</text> fill="var(--logo-mark)">{{ auth_tagline }}</text>
{% endif %}
</svg> </svg>
</div> </div>
{% endif %}
<div class="auth-card {% block card_class %}{% endblock %}"> <div class="auth-card {% block card_class %}{% endblock %}">
{# Die Anmeldeseite laesst diesen Kopf weg: die Wortmarke darueber sagt
schon, wo man ist, und "Anmelden" steht auf dem Knopf. Die uebrigen
Standalone-Seiten behalten ihn -- "Zweiter Faktor" oder der Kundenname
im Portal sind dort die Auskunft, um die es geht. #}
{% block brand %}
<div class="auth-brand"> <div class="auth-brand">
<span class="auth-brand__mark" aria-hidden="true">{{ app_short }}</span> <span class="auth-brand__mark" aria-hidden="true">{{ app_short }}</span>
<h1>{% block heading %}{{ app_name }}{% endblock %}</h1> <h1>{% block heading %}{{ app_name }}{% endblock %}</h1>
{% set sub %}{% block subheading %}{% endblock %}{% endset %} {% set sub %}{% block subheading %}{% endblock %}{% endset %}
{% if sub | trim %}<p>{{ sub }}</p>{% endif %} {% if sub | trim %}<p>{{ sub }}</p>{% endif %}
</div> </div>
{% endblock %}
{% with messages = get_flashed_messages(with_categories=true) %} {% with messages = get_flashed_messages(with_categories=true) %}
{% if messages %} {% if messages %}
@@ -74,6 +88,7 @@
<p class="auth-footnote">{{ app_name }} {{ app_version }} &middot; &copy; {{ current_year }} {{ copyright_notice }}</p> <p class="auth-footnote">{{ app_name }} {{ app_version }} &middot; &copy; {{ current_year }} {{ copyright_notice }}</p>
</div> </div>
</div> </div>
</div>
<script type="module" src="{{ asset_url('js/app.js') }}" nonce="{{ csp_nonce }}"></script> <script type="module" src="{{ asset_url('js/app.js') }}" nonce="{{ csp_nonce }}"></script>
{% block scripts %}{% endblock %} {% block scripts %}{% endblock %}
</body> </body>
+86 -1
View File
@@ -8,12 +8,13 @@ nichts, was die CSP aufweichen muesste.
from __future__ import annotations from __future__ import annotations
import math import math
import re
from datetime import datetime, timezone from datetime import datetime, timezone
from pathlib import Path from pathlib import Path
from typing import Any from typing import Any
from flask import Flask, url_for from flask import Flask, url_for
from markupsafe import Markup from markupsafe import Markup, escape
ICONS: dict[str, str] = { ICONS: dict[str, str] = {
"grid": '<rect x="3" y="3" width="7" height="7" rx="1.5"/><rect x="14" y="3" width="7" height="7" rx="1.5"/><rect x="3" y="14" width="7" height="7" rx="1.5"/><rect x="14" y="14" width="7" height="7" rx="1.5"/>', "grid": '<rect x="3" y="3" width="7" height="7" rx="1.5"/><rect x="14" y="3" width="7" height="7" rx="1.5"/><rect x="3" y="14" width="7" height="7" rx="1.5"/><rect x="14" y="14" width="7" height="7" rx="1.5"/>',
@@ -263,6 +264,89 @@ def asset_url(app: Flask) -> Any:
return build return build
# --------------------------------------------------------------------------- #
# Auditeintraege: Dateinamen anklickbar machen
# --------------------------------------------------------------------------- #
#: Name einer Mitschrift, wie ``terminal.py`` sie bildet: Zeitstempel, Benutzer,
#: Ziel. Der Ausdruck laesst bewusst keinen Schraegstrich zu -- der Name kommt
#: aus einem Auditeintrag, und ein Eintrag ist Text, kein Pfad.
MITSCHRIFT = re.compile(r"\b(\d{8}-\d{6}-[A-Za-z0-9._-]{1,140}\.log)\b")
#: Unterverzeichnisse unter dem Protokollverzeichnis, in denen nachgesehen wird.
#: Der leere Eintrag steht fuer das Verzeichnis selbst.
PROTOKOLL_ORTE = ("terminal", "")
def _mitschrift_ziel(name: str) -> str | None:
"""Der Name relativ zum Protokollverzeichnis -- oder ``None``.
Ein Verweis auf eine Datei, die es nicht mehr gibt, ist schlechter als
keiner: er sieht nach einem Angebot aus und endet in 404. Protokolle werden
aufgeraeumt, Auditeintraege bleiben -- der Fall ist die Regel, nicht die
Ausnahme.
"""
from .extension import core
try:
wurzel = core().config.log_dir
except Exception: # noqa: BLE001 - ausserhalb eines App-Kontexts
return None
for ort in PROTOKOLL_ORTE:
relativ = f"{ort}/{name}" if ort else name
try:
if (wurzel / relativ).is_file():
return relativ
except OSError: # pragma: no cover - Verzeichnis nicht lesbar
continue
return None
def audit_detail(text: Any) -> Markup:
"""Auditeintrag als Text -- Dateinamen darin als Verweis.
Der Eintrag zur Konsole nennt seine Mitschrift beim Namen. Bisher stand
dort nur die Zeichenkette, und wer sehen wollte, was passiert ist, musste
sie von Hand in die Protokollansicht uebertragen. Es ist derselbe Weg wie
zuvor, nur einen Klick weit.
Verlinkt wird ausschliesslich, wenn die Datei existiert **und** der
Betrachter Protokolldateien lesen darf -- sonst bliebe ein Verweis, der in
403 endet. Der Rest des Eintrags wird maskiert; ein Auditeintrag enthaelt
Fremdeingaben (Geraetenamen etwa) und darf niemals als HTML gelten.
"""
from .auth.models import current_user
roh = "" if text is None else str(text)
if not roh or not MITSCHRIFT.search(roh):
return Markup(escape(roh))
try:
erlaubt = bool(current_user.is_authenticated and current_user.has("logs_history.view"))
except Exception: # noqa: BLE001 - ausserhalb einer Anfrage
erlaubt = False
if not erlaubt:
return Markup(escape(roh))
def verweis(name: str) -> str:
ziel = _mitschrift_ziel(name)
if ziel is None:
return str(escape(name))
try:
url = url_for("logs.history_file", name=ziel)
except Exception: # noqa: BLE001 - Route fehlt in dieser Anwendung
return str(escape(name))
return f'<a href="{escape(url)}" title="Mitschrift ansehen">{escape(name)}</a>'
teile: list[str] = []
ende = 0
for treffer in MITSCHRIFT.finditer(roh):
teile.append(str(escape(roh[ende : treffer.start()])))
teile.append(verweis(treffer.group(1)))
ende = treffer.end()
teile.append(str(escape(roh[ende:])))
return Markup("".join(teile))
def init_app(app: Flask) -> None: def init_app(app: Flask) -> None:
app.jinja_env.globals.update( app.jinja_env.globals.update(
icon=icon, icon=icon,
@@ -293,6 +377,7 @@ def init_app(app: Flask) -> None:
from_json=from_json, from_json=from_json,
issuer_source=issuer_source, issuer_source=issuer_source,
issuer_source_hint=issuer_source_hint, issuer_source_hint=issuer_source_hint,
audit_detail=audit_detail,
) )
app.jinja_env.trim_blocks = True app.jinja_env.trim_blocks = True
app.jinja_env.lstrip_blocks = True app.jinja_env.lstrip_blocks = True
@@ -24,6 +24,7 @@ from .. import audit
from ..auth.models import current_user, permission_required from ..auth.models import current_user, permission_required
from ..auth.service import client_ip from ..auth.service import client_ip
from ..extension import core, db, transaction from ..extension import core, db, transaction
from ..security.sessions import fragment
bp = Blueprint("logs", __name__, url_prefix="/protokolle") bp = Blueprint("logs", __name__, url_prefix="/protokolle")
@@ -53,6 +54,7 @@ def live():
@bp.get("/laufend/inhalt") @bp.get("/laufend/inhalt")
@permission_required("logs_live.view") @permission_required("logs_live.view")
@fragment
def live_fragment(): def live_fragment():
lines = min(int(request.args.get("lines") or 400), MAX_TAIL_LINES) lines = min(int(request.args.get("lines") or 400), MAX_TAIL_LINES)
needle = (request.args.get("q") or "").strip() needle = (request.args.get("q") or "").strip()
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project] [project]
name = "tesm-licensing" name = "tesm-licensing"
version = "2.0.2" version = "2.0.9"
description = "Ed25519-basiertes Lizenz- und Aktivierungsprotokoll fuer TESM und TESM-Lizenzserver" description = "Ed25519-basiertes Lizenz- und Aktivierungsprotokoll fuer TESM und TESM-Lizenzserver"
requires-python = ">=3.11" requires-python = ">=3.11"
dependencies = ["cryptography>=42"] dependencies = ["cryptography>=42"]