Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3bbddd7d29 | ||
|
|
7407f4bc00 | ||
|
|
635722089e | ||
|
|
6dff1ed137 | ||
|
|
b4529a0e31 | ||
|
|
313eb4bbf9 | ||
|
|
51ae39dd53 |
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "tesm"
|
name = "tesm"
|
||||||
version = "2.0.2"
|
version = "2.0.8"
|
||||||
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 = [
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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-opus --to tesm
|
||||||
|
# sudo bash deploy/rename.sh --app tesm-license --from tesm-license-opus \
|
||||||
|
# --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-opus (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."
|
||||||
@@ -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)
|
||||||
@@ -0,0 +1,316 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# =============================================================================
|
||||||
|
# Upgrade vom Stand SONNET5 (Version 1.x) auf diese Fassung -- mit Datenuebernahme.
|
||||||
|
#
|
||||||
|
# Aufruf (aus dem entpackten Release-Verzeichnis):
|
||||||
|
# sudo bash deploy/upgrade.sh --app tesm
|
||||||
|
# sudo bash deploy/upgrade.sh --app tesm-license
|
||||||
|
# sudo bash deploy/upgrade.sh --app tesm --instance opus --source /srv/tesm
|
||||||
|
#
|
||||||
|
# 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
|
||||||
|
#: Namenszusatz, unter dem ``rename.sh`` eine verdraengte Installation der
|
||||||
|
#: Fassung 1.x ablegt. Beide Skripte muessen denselben Wert benutzen; ein
|
||||||
|
#: Hygienetest vergleicht sie.
|
||||||
|
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.
|
||||||
|
--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 opus --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
|
||||||
|
--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"
|
||||||
|
[[ -f "$SCRIPT_DIR/install.sh" ]] || fail "install.sh fehlt neben diesem Skript"
|
||||||
|
|
||||||
|
# -- 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"
|
||||||
|
warn " /srv/$APP-$ARCHIV_SUFFIX (dorthin verschiebt rename.sh sie)"
|
||||||
|
[[ -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 "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 "$SCRIPT_DIR/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
|
||||||
+142
@@ -141,6 +141,71 @@ 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.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo bash deploy/upgrade.sh --app tesm
|
||||||
|
sudo bash deploy/upgrade.sh --app tesm-license
|
||||||
|
```
|
||||||
|
|
||||||
|
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 opus --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 +258,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-opus
|
||||||
|
```
|
||||||
|
|
||||||
|
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-opus --to tesm
|
||||||
|
sudo bash deploy/rename.sh --app tesm-license --from tesm-license-opus --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
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "tesm-core"
|
name = "tesm-core"
|
||||||
version = "2.0.2"
|
version = "2.0.8"
|
||||||
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 = [
|
||||||
|
|||||||
@@ -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.8",
|
||||||
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)
|
||||||
|
|
||||||
|
|||||||
@@ -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 }} – {{ 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.
|
||||||
<div class="auth-backdrop" aria-hidden="true">
|
|
||||||
<svg viewBox="0 0 620 190" xmlns="http://www.w3.org/2000/svg">
|
Die Geometrie ist symmetrisch um die Mitte (x=260) aufgebaut, damit die
|
||||||
<g transform="translate(150,15)">
|
Marke ohne Nachrechnen mittig sitzt: der Schriftzug haengt an
|
||||||
<circle cx="298" cy="70" r="15" fill="var(--logo-mark)" opacity="0.22"/>
|
``text-anchor="middle"``, die beiden Zacken liegen spiegelbildlich, und
|
||||||
<polyline points="0,70 90,70 100,70 108,20 116,120 124,70 134,70 200,70 210,70
|
die Linie beginnt links so weit vor der Mitte, wie der Punkt rechts nach
|
||||||
218,35 226,105 234,70 244,70 300,70"
|
ihr endet. Wer die Linie verlaengert, muss beide Enden anfassen.
|
||||||
fill="none" stroke="var(--logo-mark)" stroke-width="3.2"
|
|
||||||
stroke-linecap="round" stroke-linejoin="round" opacity="0.85"/>
|
Ohne Funktionszeile (Lizenzserver) ist der Kasten kuerzer -- sonst
|
||||||
<text x="14" y="98" font-family="var(--font-mono)" font-weight="800"
|
stuende unter der Marke ein leerer Streifen. #}
|
||||||
font-size="82" letter-spacing="2" fill="var(--logo-text)">TESM</text>
|
<div class="auth-backdrop" aria-hidden="true">
|
||||||
<circle cx="298" cy="70" r="8" fill="var(--logo-mark)"/>
|
<svg viewBox="0 0 520 {{ 150 if auth_tagline else 116 }}" xmlns="http://www.w3.org/2000/svg">
|
||||||
</g>
|
<circle cx="452" cy="62" r="15" fill="var(--logo-mark)" opacity="0.22"/>
|
||||||
<text x="310" y="178" text-anchor="middle" font-family="var(--font-sans)"
|
<polyline points="60,62 163,62 171,12 179,112 187,62 333,62 341,12 349,112 357,62 452,62"
|
||||||
|
fill="none" stroke="var(--logo-mark)" stroke-width="3.2"
|
||||||
|
stroke-linecap="round" stroke-linejoin="round" opacity="0.85"/>
|
||||||
|
<text x="260" y="90" text-anchor="middle" font-family="var(--font-mono)"
|
||||||
|
font-weight="800" font-size="82" letter-spacing="2"
|
||||||
|
fill="var(--logo-text)">TESM</text>
|
||||||
|
<circle cx="452" cy="62" r="8" fill="var(--logo-mark)"/>
|
||||||
|
{% 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>
|
||||||
</svg>
|
{% endif %}
|
||||||
</div>
|
</svg>
|
||||||
{% endif %}
|
</div>
|
||||||
<div class="auth-card {% block card_class %}{% endblock %}">
|
<div class="auth-card {% block card_class %}{% endblock %}">
|
||||||
<div class="auth-brand">
|
{# Die Anmeldeseite laesst diesen Kopf weg: die Wortmarke darueber sagt
|
||||||
<span class="auth-brand__mark" aria-hidden="true">{{ app_short }}</span>
|
schon, wo man ist, und "Anmelden" steht auf dem Knopf. Die uebrigen
|
||||||
<h1>{% block heading %}{{ app_name }}{% endblock %}</h1>
|
Standalone-Seiten behalten ihn -- "Zweiter Faktor" oder der Kundenname
|
||||||
{% set sub %}{% block subheading %}{% endblock %}{% endset %}
|
im Portal sind dort die Auskunft, um die es geht. #}
|
||||||
{% if sub | trim %}<p>{{ sub }}</p>{% endif %}
|
{% block brand %}
|
||||||
</div>
|
<div class="auth-brand">
|
||||||
|
<span class="auth-brand__mark" aria-hidden="true">{{ app_short }}</span>
|
||||||
|
<h1>{% block heading %}{{ app_name }}{% endblock %}</h1>
|
||||||
|
{% set sub %}{% block subheading %}{% endblock %}{% endset %}
|
||||||
|
{% if sub | trim %}<p>{{ sub }}</p>{% endif %}
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
{% with messages = get_flashed_messages(with_categories=true) %}
|
{% with messages = get_flashed_messages(with_categories=true) %}
|
||||||
{% if messages %}
|
{% if messages %}
|
||||||
@@ -72,6 +86,7 @@
|
|||||||
{% block content %}{% endblock %}
|
{% block content %}{% endblock %}
|
||||||
|
|
||||||
<p class="auth-footnote">{{ app_name }} {{ app_version }} · © {{ current_year }} {{ copyright_notice }}</p>
|
<p class="auth-footnote">{{ app_name }} {{ app_version }} · © {{ 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>
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "tesm-licensing"
|
name = "tesm-licensing"
|
||||||
version = "2.0.2"
|
version = "2.0.8"
|
||||||
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"]
|
||||||
|
|||||||
Reference in New Issue
Block a user