Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
47aeaba9c8 | ||
|
|
d31173fa5e | ||
|
|
05885bfff1 | ||
|
|
168d10bf87 | ||
|
|
2dea198bcc | ||
|
|
50da075c15 | ||
|
|
23980182b0 | ||
|
|
85bec34d7d | ||
|
|
ab5fda658e | ||
|
|
4e488625fe | ||
|
|
9799843cd2 | ||
|
|
8b39bfaf10 | ||
|
|
8e66d9e7bc |
@@ -22,3 +22,9 @@ dist/
|
|||||||
.pytest_cache/
|
.pytest_cache/
|
||||||
.ruff_cache/
|
.ruff_cache/
|
||||||
.mypy_cache/
|
.mypy_cache/
|
||||||
|
|
||||||
|
# Release-Pakete entstehen aus einem Tag und haengen am Release -- sie gehoeren
|
||||||
|
# nicht ins Repository. Einmal ist genau das passiert: der Tarball lag im
|
||||||
|
# Arbeitsbaum, wanderte in den Commit, und das naechste Paket enthielt das
|
||||||
|
# vorige. Doppelte Groesse, verschachtelter Inhalt.
|
||||||
|
*.tar.gz
|
||||||
|
|||||||
@@ -1,17 +1,17 @@
|
|||||||
# TESM-Lizenzserver
|
# TESM-Lizenzserver
|
||||||
|
|
||||||
Ausgabe und Verwaltung der Lizenzen fuer TESM: Kunde -> Ticket -> Lizenz,
|
Ausgabe und Verwaltung der Lizenzen für TESM: Kunde -> Ticket -> Lizenz,
|
||||||
Aktivierung online und offline, Kundenportal, Selbstbedienung, Versand ueber
|
Aktivierung online und offline, Kundenportal, Selbstbedienung, Versand über
|
||||||
MS Graph und eine Hierarchie aus Unter-Lizenzservern.
|
MS Graph und eine Hierarchie aus Unter-Lizenzservern.
|
||||||
|
|
||||||
**Dieses Repository enthaelt genau das, was zum Installieren und Betreiben des
|
**Dieses Repository enthält genau das, was zum Installieren und Betreiben des
|
||||||
Lizenzservers gebraucht wird** -- nichts darueber hinaus:
|
Lizenzservers gebraucht wird** -- nichts darüber hinaus:
|
||||||
|
|
||||||
```
|
```
|
||||||
apps/tesm-license die Anwendung
|
apps/tesm-license die Anwendung
|
||||||
packages/tesm-core gemeinsamer Kern (Anmeldung, Rechte, Sicherung, nginx, ...)
|
packages/tesm-core gemeinsamer Kern (Anmeldung, Rechte, Sicherung, nginx, ...)
|
||||||
packages/tesm-licensing Lizenzprotokoll, gemeinsame Haelfte
|
packages/tesm-licensing Lizenzprotokoll, gemeinsame Hälfte
|
||||||
packages/tesm-licensing-server Lizenzprotokoll, Serverhaelfte: ausstellen, erneuern, antworten
|
packages/tesm-licensing-server Lizenzprotokoll, Serverhälfte: ausstellen, erneuern, antworten
|
||||||
deploy/ install.sh, update.sh, release.sh, systemd, privilegierter Helfer
|
deploy/ install.sh, update.sh, release.sh, systemd, privilegierter Helfer
|
||||||
docs/ Betrieb und Sicherheit
|
docs/ Betrieb und Sicherheit
|
||||||
```
|
```
|
||||||
@@ -28,7 +28,7 @@ Die verwaltete Anwendung liegt hier **nicht** mit drin.
|
|||||||
| Zweig | Inhalt |
|
| Zweig | Inhalt |
|
||||||
|---|---|
|
|---|---|
|
||||||
| `main` | dieser Neubau (Version 2.0.0) |
|
| `main` | dieser Neubau (Version 2.0.0) |
|
||||||
| `SONNET5` | der Stand davor, unveraendert aufbewahrt |
|
| `SONNET5` | der Stand davor, unverändert aufbewahrt |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -41,18 +41,18 @@ sudo -u tesm-license tesm-license-admin bootstrap
|
|||||||
sudo -u tesm-license tesm-license-admin create-admin
|
sudo -u tesm-license tesm-license-admin create-admin
|
||||||
```
|
```
|
||||||
|
|
||||||
**`init-key` erzeugt den Signaturschluessel dieses Servers. Sichern Sie ihn
|
**`init-key` erzeugt den Signaturschlüssel dieses Servers. Sichern Sie ihn
|
||||||
sofort.** Ohne ihn laesst sich keine ausgestellte Lizenz mehr erneuern, und
|
sofort.** Ohne ihn lässt sich keine ausgestellte Lizenz mehr erneuern, und
|
||||||
Kunden muessten einen Ausstellerwechsel bestaetigen.
|
Kunden müssten einen Ausstellerwechsel bestätigen.
|
||||||
|
|
||||||
Zielzustand: **Port 443 mit TLS, Port 80 leitet dorthin um** -- ausser dem
|
Zielzustand: **Port 443 mit TLS, Port 80 leitet dorthin um** -- ausser dem
|
||||||
ACME-Pfad, der erreichbar bleiben muss, sonst kann Let's Encrypt nie erneuern.
|
ACME-Pfad, der erreichbar bleiben muss, sonst kann Let's Encrypt nie erneuern.
|
||||||
Sitzt ein eigener Reverse Proxy davor, der TLS beendet, laeuft der Dienst ohne
|
Sitzt ein eigener Reverse Proxy davor, der TLS beendet, läuft der Dienst ohne
|
||||||
`--https` schlicht auf Port 80.
|
`--https` schlicht auf Port 80.
|
||||||
|
|
||||||
Der Lizenzserver darf neben TESM auf demselben Host laufen; nginx unterscheidet
|
Der Lizenzserver darf neben TESM auf demselben Host laufen; nginx unterscheidet
|
||||||
die beiden am Servernamen, und jede Installation hat ihren eigenen `SITE_KEY`
|
die beiden am Servernamen, und jede Installation hat ihren eigenen `SITE_KEY`
|
||||||
fuer nginx-Site, logrotate, Zertifikate und Sitzungscookie.
|
für nginx-Site, logrotate, Zertifikate und Sitzungscookie.
|
||||||
|
|
||||||
### Aktualisieren
|
### Aktualisieren
|
||||||
|
|
||||||
@@ -60,7 +60,7 @@ fuer nginx-Site, logrotate, Zertifikate und Sitzungscookie.
|
|||||||
sudo bash deploy/update.sh --app tesm-license
|
sudo bash deploy/update.sh --app tesm-license
|
||||||
```
|
```
|
||||||
|
|
||||||
Vor jedem Update entsteht ein vollstaendiges Backup, danach laeuft ein
|
Vor jedem Update entsteht ein vollständiges Backup, danach läuft ein
|
||||||
Health-Check mit automatischem Rollback.
|
Health-Check mit automatischem Rollback.
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -69,16 +69,16 @@ Health-Check mit automatischem Rollback.
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
sudo -u tesm-license tesm-license-admin status
|
sudo -u tesm-license tesm-license-admin status
|
||||||
sudo -u tesm-license tesm-license-admin init-key # Signaturschluessel
|
sudo -u tesm-license tesm-license-admin init-key # Signaturschlüssel
|
||||||
sudo -u tesm-license tesm-license-admin bootstrap # Grunddaten
|
sudo -u tesm-license tesm-license-admin bootstrap # Grunddaten
|
||||||
sudo -u tesm-license tesm-license-admin create-admin
|
sudo -u tesm-license tesm-license-admin create-admin
|
||||||
sudo -u tesm-license tesm-license-admin migrate-legacy # Altbestand uebernehmen
|
sudo -u tesm-license tesm-license-admin migrate-legacy # Altbestand übernehmen
|
||||||
```
|
```
|
||||||
|
|
||||||
`migrate-legacy` liest die SQLite-Datei eines alten Lizenzservers **nur
|
`migrate-legacy` liest die SQLite-Datei eines alten Lizenzservers **nur
|
||||||
lesend**, uebernimmt dessen Signaturschluessel und stellt die Lizenzen mit ihrer
|
lesend**, übernimmt dessen Signaturschlüssel und stellt die Lizenzen mit ihrer
|
||||||
restlichen Laufzeit neu aus. Wird der Schluessel nicht uebernommen, muessen
|
restlichen Laufzeit neu aus. Wird der Schlüssel nicht übernommen, müssen
|
||||||
Kunden den Ausstellerwechsel einmal bestaetigen.
|
Kunden den Ausstellerwechsel einmal bestätigen.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -101,14 +101,14 @@ genau einmal.
|
|||||||
(`tesm/license-file/v2\0`) -- eine Signatur aus einem Kontext ist in einem
|
(`tesm/license-file/v2\0`) -- eine Signatur aus einem Kontext ist in einem
|
||||||
anderen nicht verwendbar.
|
anderen nicht verwendbar.
|
||||||
* Kanonisches JSON, damit dieselbe Aussage genau eine Signatur hat.
|
* Kanonisches JSON, damit dieselbe Aussage genau eine Signatur hat.
|
||||||
* Nonce gegen Wiedereinspielung, `seq` gegen das Zurueckrollen auf eine aeltere
|
* Nonce gegen Wiedereinspielung, `seq` gegen das Zurückrollen auf eine ältere
|
||||||
Lizenz.
|
Lizenz.
|
||||||
* Aussteller wird beim ersten Kontakt gemerkt (TOFU); ein Wechsel muss
|
* Aussteller wird beim ersten Kontakt gemerkt (TOFU); ein Wechsel muss
|
||||||
ausdruecklich bestaetigt werden.
|
ausdrücklich bestätigt werden.
|
||||||
* Der Fingerabdruck der Maschine enthaelt **keinen** Hostnamen -- Umbenennen
|
* Der Fingerabdruck der Maschine enthält **keinen** Hostnamen -- Umbenennen
|
||||||
brach im Vorgaenger die Bindung.
|
brach im Vorgänger die Bindung.
|
||||||
* Offline-Aktivierung ueber einen kurzen Code in beide Richtungen; die Lizenz
|
* Offline-Aktivierung über einen kurzen Code in beide Richtungen; die Lizenz
|
||||||
bleibt auf dem Server als "Aktivierung offen" markiert, bis sie zurueckkommt.
|
bleibt auf dem Server als "Aktivierung offen" markiert, bis sie zurückkommt.
|
||||||
* Keine Lizenz ohne `master_endpoint`: eine Ausstellung ohne Endpunkt wird
|
* Keine Lizenz ohne `master_endpoint`: eine Ausstellung ohne Endpunkt wird
|
||||||
abgelehnt statt eine Lizenz zu erzeugen, die sich nie aktivieren kann.
|
abgelehnt statt eine Lizenz zu erzeugen, die sich nie aktivieren kann.
|
||||||
|
|
||||||
@@ -130,8 +130,8 @@ Entwicklungsrepository, ebenso die Tests.
|
|||||||
|
|
||||||
* **Keine doppelte `licensing.py`.** Ein Paket, das beide Anwendungen
|
* **Keine doppelte `licensing.py`.** Ein Paket, das beide Anwendungen
|
||||||
installieren -- statt zweier Dateien, die byte-identisch bleiben sollen.
|
installieren -- statt zweier Dateien, die byte-identisch bleiben sollen.
|
||||||
* **Kein root fuer die Anwendung.** Ein einziger allowlisted Helfer mit
|
* **Kein root für die Anwendung.** Ein einziger allowlisted Helfer mit
|
||||||
Argumentpruefung.
|
Argumentprüfung.
|
||||||
* **CSRF-Schutz ueberhaupt.** Der Vorgaenger hatte keinen.
|
* **CSRF-Schutz überhaupt.** Der Vorgänger hatte keinen.
|
||||||
* **Rechte werden serverseitig geprueft**, nicht nur im Template ausgeblendet.
|
* **Rechte werden serverseitig geprüft**, nicht nur im Template ausgeblendet.
|
||||||
* **Verschachtelte Datenbankverbindungen sind ein Fehler**, kein Deadlock.
|
* **Verschachtelte Datenbankverbindungen sind ein Fehler**, kein Deadlock.
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "tesm-license"
|
name = "tesm-license"
|
||||||
version = "2.0.0"
|
version = "2.0.6"
|
||||||
description = "TESM-Lizenzserver -- Kunden, Tickets, Lizenzen, Aktivierung"
|
description = "TESM-Lizenzserver -- Kunden, Tickets, Lizenzen, Aktivierung"
|
||||||
requires-python = ">=3.11"
|
requires-python = ">=3.11"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ from .permissions import PERMISSIONS, ROLE_PRESETS
|
|||||||
from .schema import LICENSE_MIGRATIONS
|
from .schema import LICENSE_MIGRATIONS
|
||||||
from .settings_spec import LICENSE_SERVER_SETTINGS
|
from .settings_spec import LICENSE_SERVER_SETTINGS
|
||||||
|
|
||||||
__version__ = "2.0.0"
|
__version__ = "2.0.2"
|
||||||
|
|
||||||
APP_KEY = "tesm-license"
|
APP_KEY = "tesm-license"
|
||||||
APP_NAME = "TESM-Lizenzserver"
|
APP_NAME = "TESM-Lizenzserver"
|
||||||
|
|||||||
@@ -336,6 +336,28 @@ def cmd_migrate_legacy(args: argparse.Namespace) -> int:
|
|||||||
key = master_key.load(key_path)
|
key = master_key.load(key_path)
|
||||||
|
|
||||||
with app.app_context(), extension.database.session() as conn:
|
with app.app_context(), extension.database.session() as conn:
|
||||||
|
# Anbieterdaten und Endpunkt zuerst: bei einem Upgrade ist die neue
|
||||||
|
# Installation frisch, dort steht also nichts, und die Pruefung
|
||||||
|
# weiter unten haette jedes Upgrade abgebrochen. Beides gehoert zur
|
||||||
|
# Anbieteridentitaet -- genau die soll ein Upgrade nicht wechseln.
|
||||||
|
# Uebernommen wird nur, was hier leer ist.
|
||||||
|
if not args.dry_run:
|
||||||
|
with extension.database.transaction(conn):
|
||||||
|
uebernommen = legacy_import.adopt_vendor_settings(
|
||||||
|
legacy,
|
||||||
|
lambda key: extension.settings.get(conn, key),
|
||||||
|
lambda key, wert: extension.settings.set(
|
||||||
|
conn, key, wert, actor="migration"
|
||||||
|
),
|
||||||
|
)
|
||||||
|
# Nicht "key" als Laufvariable: darueber steht der geladene
|
||||||
|
# Signaturschluessel mit demselben Namen, und ihn zu
|
||||||
|
# ueberschreiben lief in ein AttributeError mitten in der
|
||||||
|
# Uebernahme.
|
||||||
|
for einstellung, wert in uebernommen.items():
|
||||||
|
gekuerzt = wert if len(wert) <= 60 else wert[:57] + "..."
|
||||||
|
print(f"Uebernommen: {einstellung} = {gekuerzt}")
|
||||||
|
|
||||||
vendor = {
|
vendor = {
|
||||||
"name": str(extension.settings.get(conn, "vendor_name") or "Eigenbetrieb"),
|
"name": str(extension.settings.get(conn, "vendor_name") or "Eigenbetrieb"),
|
||||||
"email": str(extension.settings.get(conn, "vendor_email") or ""),
|
"email": str(extension.settings.get(conn, "vendor_email") or ""),
|
||||||
@@ -346,8 +368,9 @@ def cmd_migrate_legacy(args: argparse.Namespace) -> int:
|
|||||||
endpoint = str(extension.settings.get(conn, "master_endpoint") or "")
|
endpoint = str(extension.settings.get(conn, "master_endpoint") or "")
|
||||||
if not endpoint and not args.dry_run:
|
if not endpoint and not args.dry_run:
|
||||||
print(
|
print(
|
||||||
"Es ist kein Endpunkt hinterlegt (Einstellungen -> Anbieter). Ohne ihn "
|
"Es ist kein Endpunkt hinterlegt, und die alte Installation nennt "
|
||||||
"koennen uebernommene Lizenzen sich spaeter nicht aktivieren.",
|
"auch keinen. Bitte unter Einstellungen -> Anbieter eintragen: ohne "
|
||||||
|
"ihn koennen uebernommene Lizenzen sich spaeter nicht aktivieren.",
|
||||||
file=sys.stderr,
|
file=sys.stderr,
|
||||||
)
|
)
|
||||||
return 2
|
return 2
|
||||||
|
|||||||
@@ -86,7 +86,7 @@ NAVIGATION = NavTree(
|
|||||||
),
|
),
|
||||||
NavItem(
|
NavItem(
|
||||||
key="logs_audit",
|
key="logs_audit",
|
||||||
label="Aenderungen",
|
label="Audit",
|
||||||
icon="history",
|
icon="history",
|
||||||
endpoint="logs.audit_view",
|
endpoint="logs.audit_view",
|
||||||
permissions=("logs_audit.view", "logs_audit.export"),
|
permissions=("logs_audit.view", "logs_audit.export"),
|
||||||
|
|||||||
@@ -25,6 +25,8 @@ angefasst, nicht angehalten und nicht veraendert.
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import base64
|
||||||
|
import binascii
|
||||||
import json
|
import json
|
||||||
import sqlite3
|
import sqlite3
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
@@ -110,6 +112,39 @@ def open_legacy(root: Path) -> sqlite3.Connection:
|
|||||||
return conn
|
return conn
|
||||||
|
|
||||||
|
|
||||||
|
def _standard_b64(wert: str, feld: str) -> str:
|
||||||
|
"""Nimmt Standard- **und** URL-Alphabet an, gibt Standard-Base64 zurueck.
|
||||||
|
|
||||||
|
Der Vorgaenger kodierte seine Schluessel mit ``urlsafe_b64encode``: dort
|
||||||
|
stehen ``-`` und ``_`` an den Stellen von ``+`` und ``/``. Das neue
|
||||||
|
Protokoll prueft Base64 streng -- richtig so, aber beim Lesen einer alten
|
||||||
|
Datei fuehrte es dazu, dass die Uebernahme mit einem Traceback ueber
|
||||||
|
"kein gueltiges Base64" abbrach, bei jedem echten Altbestand.
|
||||||
|
"""
|
||||||
|
roh = wert.strip()
|
||||||
|
if not roh:
|
||||||
|
raise LegacyImportError(f"Im alten Signaturschluessel fehlt {feld!r}.")
|
||||||
|
# Fehlende Polsterung ergaenzen -- der Vorgaenger schrieb sie mit, andere
|
||||||
|
# Werkzeuge lassen sie weg.
|
||||||
|
gepolstert = roh + "=" * (-len(roh) % 4)
|
||||||
|
# Erst das Standard-, dann das URL-Alphabet -- beide mit Pruefung. Ohne
|
||||||
|
# ``validate`` verschluckt der Decoder unbekannte Zeichen einfach, und aus
|
||||||
|
# "###" wuerde eine leere Ausgabe statt einer Fehlermeldung.
|
||||||
|
versuche = (gepolstert, gepolstert.replace("-", "+").replace("_", "/"))
|
||||||
|
for kandidat in versuche:
|
||||||
|
try:
|
||||||
|
rohbytes = base64.b64decode(kandidat, validate=True)
|
||||||
|
except (ValueError, binascii.Error):
|
||||||
|
continue
|
||||||
|
if len(rohbytes) != 32:
|
||||||
|
raise LegacyImportError(
|
||||||
|
f"{feld} im alten Signaturschluessel ist {len(rohbytes)} Byte lang, "
|
||||||
|
"erwartet werden 32 (Ed25519)."
|
||||||
|
)
|
||||||
|
return base64.b64encode(rohbytes).decode("ascii")
|
||||||
|
raise LegacyImportError(f"{feld} im alten Signaturschluessel ist kein Base64.")
|
||||||
|
|
||||||
|
|
||||||
def read_legacy_key(root: Path) -> tuple[str, str]:
|
def read_legacy_key(root: Path) -> tuple[str, str]:
|
||||||
"""Liest den alten Signaturschluessel. Rueckgabe: ``(privat, oeffentlich)``."""
|
"""Liest den alten Signaturschluessel. Rueckgabe: ``(privat, oeffentlich)``."""
|
||||||
path = Path(root) / "master_signing_key.json"
|
path = Path(root) / "master_signing_key.json"
|
||||||
@@ -120,12 +155,23 @@ def read_legacy_key(root: Path) -> tuple[str, str]:
|
|||||||
except (OSError, ValueError) as exc:
|
except (OSError, ValueError) as exc:
|
||||||
raise LegacyImportError(f"Der alte Signaturschluessel ist unlesbar: {exc}") from exc
|
raise LegacyImportError(f"Der alte Signaturschluessel ist unlesbar: {exc}") from exc
|
||||||
|
|
||||||
# Die Vorgaengerfassung nannte die Felder "private"/"public".
|
# Die Vorgaengerfassung nannte die Felder "private"/"public" und kodierte
|
||||||
|
# sie URL-sicher.
|
||||||
private = str(data.get("private") or data.get("private_key") or "")
|
private = str(data.get("private") or data.get("private_key") or "")
|
||||||
public = str(data.get("public") or data.get("public_key") or "")
|
public = str(data.get("public") or data.get("public_key") or "")
|
||||||
if not private or not public:
|
if not private or not public:
|
||||||
raise LegacyImportError("Der alte Signaturschluessel ist unvollstaendig.")
|
raise LegacyImportError("Der alte Signaturschluessel ist unvollstaendig.")
|
||||||
if licsrv.public_from_private(private) != public:
|
private = _standard_b64(private, "private")
|
||||||
|
public = _standard_b64(public, "public")
|
||||||
|
# Alles, was aus der Protokollschicht kommt, wird hier zu einer Meldung im
|
||||||
|
# Klartext. Ein Traceback ueber Base64 mitten in einer Uebernahme sagt dem
|
||||||
|
# Betreiber nichts -- und die Uebernahme bricht ohne --adopt-key gar nicht
|
||||||
|
# ab, sie merkt sich nur den alten Aussteller nicht.
|
||||||
|
try:
|
||||||
|
passt = licsrv.public_from_private(private) == public
|
||||||
|
except (ValueError, TypeError) as exc:
|
||||||
|
raise LegacyImportError(f"Der alte Signaturschluessel ist unbrauchbar: {exc}") from exc
|
||||||
|
if not passt:
|
||||||
raise LegacyImportError(
|
raise LegacyImportError(
|
||||||
"Im alten Signaturschluessel passen privater und oeffentlicher Teil nicht "
|
"Im alten Signaturschluessel passen privater und oeffentlicher Teil nicht "
|
||||||
"zueinander. Die Datei ist beschaedigt."
|
"zueinander. Die Datei ist beschaedigt."
|
||||||
@@ -187,6 +233,55 @@ def _modules(raw: Any) -> list[str]:
|
|||||||
return sorted(set(str(value) for value in values) & set(lic.ALL_MODULES))
|
return sorted(set(str(value) for value in values) & set(lic.ALL_MODULES))
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
#: Einstellungen, die zur Anbieteridentitaet gehoeren und in beiden Fassungen
|
||||||
|
#: gleich heissen. ``master_endpoint`` ist darunter das entscheidende: ohne ihn
|
||||||
|
#: findet eine uebernommene Lizenz ihren Server spaeter nicht wieder.
|
||||||
|
VENDOR_SETTINGS = (
|
||||||
|
"master_endpoint",
|
||||||
|
"vendor_name",
|
||||||
|
"vendor_email",
|
||||||
|
"vendor_phone",
|
||||||
|
"vendor_address",
|
||||||
|
"vendor_logo_base64",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def read_vendor_settings(legacy: sqlite3.Connection) -> dict[str, str]:
|
||||||
|
"""Liest die Anbietereinstellungen der alten Installation."""
|
||||||
|
try:
|
||||||
|
rows = legacy.execute(
|
||||||
|
"SELECT key, value FROM settings WHERE key IN ({})".format(
|
||||||
|
",".join("?" for _ in VENDOR_SETTINGS)
|
||||||
|
),
|
||||||
|
VENDOR_SETTINGS,
|
||||||
|
).fetchall()
|
||||||
|
except sqlite3.Error: # pragma: no cover - alte Fassung ohne settings
|
||||||
|
return {}
|
||||||
|
return {str(row["key"]): str(row["value"] or "") for row in rows if str(row["value"] or "")}
|
||||||
|
|
||||||
|
|
||||||
|
def adopt_vendor_settings(
|
||||||
|
legacy: sqlite3.Connection,
|
||||||
|
read: Any,
|
||||||
|
write: Any,
|
||||||
|
) -> dict[str, str]:
|
||||||
|
"""Fuellt leere Anbietereinstellungen aus der alten Installation.
|
||||||
|
|
||||||
|
``read(key)`` und ``write(key, wert)`` kommen von aussen, damit diese
|
||||||
|
Funktion nichts ueber den Einstellungsspeicher wissen muss. Rueckgabe: was
|
||||||
|
tatsaechlich uebernommen wurde -- der Aufrufer soll es benennen koennen.
|
||||||
|
"""
|
||||||
|
alt = read_vendor_settings(legacy)
|
||||||
|
uebernommen: dict[str, str] = {}
|
||||||
|
for key, wert in alt.items():
|
||||||
|
if str(read(key) or "").strip():
|
||||||
|
continue # was hier schon steht, gilt
|
||||||
|
write(key, wert)
|
||||||
|
uebernommen[key] = wert
|
||||||
|
return uebernommen
|
||||||
|
|
||||||
|
|
||||||
def import_all(
|
def import_all(
|
||||||
legacy: sqlite3.Connection,
|
legacy: sqlite3.Connection,
|
||||||
conn: sqlite3.Connection,
|
conn: sqlite3.Connection,
|
||||||
|
|||||||
@@ -1,26 +1,26 @@
|
|||||||
{% extends "tesm_core/base.html" %}
|
{% extends "tesm_core/base.html" %}
|
||||||
{% from "tesm_core/macros.html" import field, alert, pill %}
|
{% from "tesm_core/macros.html" import field, alert, pill %}
|
||||||
|
|
||||||
{% block page_title %}Sicherung und Uebertragung{% endblock %}
|
{% block page_title %}Sicherung und Übertragung{% endblock %}
|
||||||
{% block page_sub %}Verschluesselter Export · zweistufiger Import{% endblock %}
|
{% block page_sub %}Verschlüsselter Export · zweistufiger Import{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
{{ alert('Der Export wird mit Argon2id aus Ihrer Passphrase verschluesselt (AES-256-GCM). Ohne
|
{{ alert('Der Export wird mit Argon2id aus Ihrer Passphrase verschlüsselt (AES-256-GCM). Ohne
|
||||||
diese Passphrase ist die Datei nicht wiederherstellbar -- es gibt keine Hintertuer.', 'warning',
|
diese Passphrase ist die Datei nicht wiederherstellbar -- es gibt keine Hintertür.', 'warning',
|
||||||
'Passphrase') }}
|
'Passphrase') }}
|
||||||
|
|
||||||
{{ alert('Der Signaturschluessel (' ~ signing_key_path ~ ') ist bewusst
|
{{ alert('Der Signaturschlüssel (' ~ signing_key_path ~ ') ist bewusst
|
||||||
nicht Teil dieser Sicherung. Er ist der Kern des Vertrauens und gehoert in eine
|
nicht Teil dieser Sicherung. Er ist der Kern des Vertrauens und gehört in eine
|
||||||
getrennte, bewusst verwahrte Sicherung -- nicht in dieselbe Datei wie die Kundendaten. Ohne ihn
|
getrennte, bewusst verwahrte Sicherung -- nicht in dieselbe Datei wie die Kundendaten. Ohne ihn
|
||||||
ist nach einer Wiederherstellung keine ausgestellte Lizenz mehr pruefbar.', 'danger',
|
ist nach einer Wiederherstellung keine ausgestellte Lizenz mehr prüfbar.', 'danger',
|
||||||
'Wichtig: Schluessel getrennt sichern') }}
|
'Wichtig: Schlüssel getrennt sichern') }}
|
||||||
|
|
||||||
<div class="grid grid--split">
|
<div class="grid grid--split">
|
||||||
<section class="card">
|
<section class="card">
|
||||||
<div class="card__header">
|
<div class="card__header">
|
||||||
<div class="flex-1">
|
<div class="flex-1">
|
||||||
<div class="card__title">{{ icon('download', size=17) }} Sicherung erstellen</div>
|
<div class="card__title">{{ icon('download', size=17) }} Sicherung erstellen</div>
|
||||||
<div class="card__sub">Verlangt eine frische Passwortbestaetigung</div>
|
<div class="card__sub">Verlangt eine frische Passwortbestätigung</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<form method="post" action="{{ url_for('backup.export') }}" data-no-guard>
|
<form method="post" action="{{ url_for('backup.export') }}" data-no-guard>
|
||||||
@@ -40,7 +40,7 @@ ist nach einer Wiederherstellung keine ausgestellte Lizenz mehr pruefbar.', 'dan
|
|||||||
{% if category.admin_only %}{{ pill('nur Admin', 'accent') }}{% endif %}
|
{% if category.admin_only %}{{ pill('nur Admin', 'accent') }}{% endif %}
|
||||||
<div class="fs-xs faint">{{ category.hint }}</div>
|
<div class="fs-xs faint">{{ category.hint }}</div>
|
||||||
{% if not allowed %}
|
{% if not allowed %}
|
||||||
<div class="fs-xs danger-text">Dafuer fehlt Ihnen das Recht.</div>
|
<div class="fs-xs danger-text">Dafür fehlt Ihnen das Recht.</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</span>
|
</span>
|
||||||
</label>
|
</label>
|
||||||
@@ -66,8 +66,8 @@ ist nach einer Wiederherstellung keine ausgestellte Lizenz mehr pruefbar.', 'dan
|
|||||||
<section class="card card--accent">
|
<section class="card card--accent">
|
||||||
<div class="card__header">
|
<div class="card__header">
|
||||||
<div class="flex-1">
|
<div class="flex-1">
|
||||||
<div class="card__title">{{ icon('upload', size=17) }} Geoeffnete Sicherung</div>
|
<div class="card__title">{{ icon('upload', size=17) }} Geöffnete Sicherung</div>
|
||||||
<div class="card__sub">Gueltig fuer {{ preview_ttl }} Minuten</div>
|
<div class="card__sub">Gültig für {{ preview_ttl }} Minuten</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<form method="post" action="{{ url_for('backup.import_apply') }}">
|
<form method="post" action="{{ url_for('backup.import_apply') }}">
|
||||||
@@ -75,7 +75,7 @@ ist nach einer Wiederherstellung keine ausgestellte Lizenz mehr pruefbar.', 'dan
|
|||||||
<div class="card__body">
|
<div class="card__body">
|
||||||
<div class="table-wrap">
|
<div class="table-wrap">
|
||||||
<table class="table table--compact">
|
<table class="table table--compact">
|
||||||
<thead><tr><th></th><th>Kategorie</th><th class="numeric">Eintraege</th></tr></thead>
|
<thead><tr><th></th><th>Kategorie</th><th class="numeric">Einträge</th></tr></thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{% for entry in preview.summary %}
|
{% for entry in preview.summary %}
|
||||||
<tr>
|
<tr>
|
||||||
@@ -89,17 +89,17 @@ ist nach einer Wiederherstellung keine ausgestellte Lizenz mehr pruefbar.', 'dan
|
|||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
{{ alert('Bereits vorhandene Lizenzen werden nie ueberschrieben -- ihr Inhalt ist
|
{{ alert('Bereits vorhandene Lizenzen werden nie überschrieben -- ihr Inhalt ist
|
||||||
kryptographisch festgelegt. Kunden werden ueber ihren Namen zusammengefuehrt.',
|
kryptographisch festgelegt. Kunden werden über ihren Namen zusammengeführt.',
|
||||||
'info') }}
|
'info') }}
|
||||||
</div>
|
</div>
|
||||||
<div class="card__footer modal__footer--split">
|
<div class="card__footer modal__footer--split">
|
||||||
<button class="btn btn--sm btn--ghost" type="submit"
|
<button class="btn btn--sm btn--ghost" type="submit"
|
||||||
formaction="{{ url_for('backup.import_discard') }}">Verwerfen</button>
|
formaction="{{ url_for('backup.import_discard') }}">Verwerfen</button>
|
||||||
<button class="btn btn--primary" type="submit"
|
<button class="btn btn--primary" type="submit"
|
||||||
data-confirm="Ausgewaehlte Kategorien jetzt uebernehmen?"
|
data-confirm="Ausgewählte Kategorien jetzt übernehmen?"
|
||||||
data-confirm-title="Sicherung uebernehmen" data-confirm-ok="Uebernehmen"
|
data-confirm-title="Sicherung übernehmen" data-confirm-ok="Übernehmen"
|
||||||
data-confirm-style="primary">Uebernehmen</button>
|
data-confirm-style="primary">Übernehmen</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</section>
|
</section>
|
||||||
@@ -108,7 +108,7 @@ ist nach einer Wiederherstellung keine ausgestellte Lizenz mehr pruefbar.', 'dan
|
|||||||
<div class="card__header">
|
<div class="card__header">
|
||||||
<div class="flex-1">
|
<div class="flex-1">
|
||||||
<div class="card__title">{{ icon('upload', size=17) }} Sicherung einspielen</div>
|
<div class="card__title">{{ icon('upload', size=17) }} Sicherung einspielen</div>
|
||||||
<div class="card__sub">Erst oeffnen und pruefen, dann uebernehmen</div>
|
<div class="card__sub">Erst öffnen und prüfen, dann übernehmen</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<form method="post" enctype="multipart/form-data" data-no-guard
|
<form method="post" enctype="multipart/form-data" data-no-guard
|
||||||
@@ -120,8 +120,8 @@ ist nach einer Wiederherstellung keine ausgestellte Lizenz mehr pruefbar.', 'dan
|
|||||||
autocomplete='off') }}
|
autocomplete='off') }}
|
||||||
</div>
|
</div>
|
||||||
<div class="card__footer">
|
<div class="card__footer">
|
||||||
<button class="btn btn--primary" type="submit" data-busy-label="Oeffne ...">
|
<button class="btn btn--primary" type="submit" data-busy-label="Öffne ...">
|
||||||
Datei oeffnen
|
Datei öffnen
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
@@ -70,7 +70,7 @@
|
|||||||
{% if licenses %}
|
{% if licenses %}
|
||||||
<div class="table-wrap">
|
<div class="table-wrap">
|
||||||
<table class="table table--compact">
|
<table class="table table--compact">
|
||||||
<thead><tr><th>Lizenz</th><th>Typ</th><th>Zustand</th><th>Laeuft ab</th><th>Host</th></tr></thead>
|
<thead><tr><th>Lizenz</th><th>Typ</th><th>Zustand</th><th>Läuft ab</th><th>Host</th></tr></thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{% for entry in licenses %}
|
{% for entry in licenses %}
|
||||||
<tr>
|
<tr>
|
||||||
@@ -119,7 +119,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="card__body stack">
|
<div class="card__body stack">
|
||||||
{{ alert('Diese Daten verwaltet der Sub-Lizenzserver selbst. Sie sind hier rein
|
{{ alert('Diese Daten verwaltet der Sub-Lizenzserver selbst. Sie sind hier rein
|
||||||
informativ und werden bei jedem Bericht vollstaendig ersetzt.', 'info') }}
|
informativ und werden bei jedem Bericht vollständig ersetzt.', 'info') }}
|
||||||
{% for entry in reported.customers %}
|
{% for entry in reported.customers %}
|
||||||
<details class="card">
|
<details class="card">
|
||||||
<summary class="card__header" style="cursor:pointer">
|
<summary class="card__header" style="cursor:pointer">
|
||||||
@@ -193,7 +193,7 @@
|
|||||||
<div class="divider"></div>
|
<div class="divider"></div>
|
||||||
<dl class="kv fs-xs">
|
<dl class="kv fs-xs">
|
||||||
<dt>Angelegt</dt><dd>{{ customer.created_at | dt }} von {{ customer.created_by or '-' }}</dd>
|
<dt>Angelegt</dt><dd>{{ customer.created_at | dt }} von {{ customer.created_by or '-' }}</dd>
|
||||||
<dt>Geaendert</dt><dd>{{ customer.updated_at | dt }}</dd>
|
<dt>Geändert</dt><dd>{{ customer.updated_at | dt }}</dd>
|
||||||
<dt>Portal-Zugang</dt>
|
<dt>Portal-Zugang</dt>
|
||||||
<dd><span class="mono break-all">{{ portal_url }}</span>
|
<dd><span class="mono break-all">{{ portal_url }}</span>
|
||||||
<button class="btn btn--sm btn--ghost" type="button" data-copy="{{ portal_url }}">
|
<button class="btn btn--sm btn--ghost" type="button" data-copy="{{ portal_url }}">
|
||||||
@@ -205,9 +205,9 @@
|
|||||||
{% if current_user.has('customers.delete') %}
|
{% if current_user.has('customers.delete') %}
|
||||||
<button class="btn btn--sm btn--ghost danger-text" type="submit"
|
<button class="btn btn--sm btn--ghost danger-text" type="submit"
|
||||||
formaction="{{ url_for('customers.delete', customer_id=customer.id) }}"
|
formaction="{{ url_for('customers.delete', customer_id=customer.id) }}"
|
||||||
data-confirm="Kunde {{ customer.name }} loeschen? Das ist nur moeglich, solange kein Ticket mehr an ihm haengt."
|
data-confirm="Kunde {{ customer.name }} löschen? Das ist nur möglich, solange kein Ticket mehr an ihm hängt."
|
||||||
data-confirm-title="Kunde loeschen" data-confirm-ok="Loeschen">
|
data-confirm-title="Kunde löschen" data-confirm-ok="Löschen">
|
||||||
Loeschen
|
Löschen
|
||||||
</button>
|
</button>
|
||||||
{% else %}<span></span>{% endif %}
|
{% else %}<span></span>{% endif %}
|
||||||
<button class="btn btn--primary" type="submit">Speichern</button>
|
<button class="btn btn--primary" type="submit">Speichern</button>
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
{{ field('name', 'Firmenname', value=customer.name if customer else '', required=True) }}
|
{{ field('name', 'Firmenname', value=customer.name if customer else '', required=True) }}
|
||||||
{{ field('contact_email', 'E-Mail', value=customer.contact_email if customer else '',
|
{{ field('contact_email', 'E-Mail', value=customer.contact_email if customer else '',
|
||||||
type='email', required=not customer,
|
type='email', required=not customer,
|
||||||
hint_text='Pflicht bei der Neuanlage -- ueber diese Adresse laeuft das Kundenportal.') }}
|
hint_text='Pflicht bei der Neuanlage -- über diese Adresse läuft das Kundenportal.') }}
|
||||||
{{ field('contact_person', 'Ansprechpartner', value=customer.contact_person if customer else '') }}
|
{{ field('contact_person', 'Ansprechpartner', value=customer.contact_person if customer else '') }}
|
||||||
{{ field('contact_phone', 'Telefon', value=customer.contact_phone if customer else '') }}
|
{{ field('contact_phone', 'Telefon', value=customer.contact_phone if customer else '') }}
|
||||||
{{ field('street', 'Strasse', value=customer.street if customer else '') }}
|
{{ field('street', 'Strasse', value=customer.street if customer else '') }}
|
||||||
@@ -72,7 +72,7 @@
|
|||||||
|
|
||||||
{% block dialogs %}
|
{% block dialogs %}
|
||||||
{% if current_user.has('customers.create') %}
|
{% if current_user.has('customers.create') %}
|
||||||
{% call modal('customer-new', 'Kunde anlegen', 'Adresse in Einzelfeldern -- fuer Rechnungen weiterverwendbar', 'wide') %}
|
{% call modal('customer-new', 'Kunde anlegen', 'Adresse in Einzelfeldern -- für Rechnungen weiterverwendbar', 'wide') %}
|
||||||
<form method="post" action="{{ url_for('customers.create') }}">
|
<form method="post" action="{{ url_for('customers.create') }}">
|
||||||
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
|
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
|
||||||
<div class="modal__body">{{ customer_form() }}</div>
|
<div class="modal__body">{{ customer_form() }}</div>
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{% extends "tesm_core/base.html" %}
|
{% extends "tesm_core/base.html" %}
|
||||||
{% from "tesm_core/macros.html" import pill, empty_state, alert, stat %}
|
{% from "tesm_core/macros.html" import pill, empty_state, alert, stat %}
|
||||||
|
|
||||||
{% block page_title %}Uebersicht{% endblock %}
|
{% block page_title %}Übersicht{% endblock %}
|
||||||
{% block page_sub %}
|
{% block page_sub %}
|
||||||
{% if summary %}
|
{% if summary %}
|
||||||
{{ summary.counts.customers }} Kunden · {{ summary.counts.licenses_active }} aktive Lizenzen
|
{{ summary.counts.customers }} Kunden · {{ summary.counts.licenses_active }} aktive Lizenzen
|
||||||
@@ -24,13 +24,13 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{% if summary.counts.tickets_blocked %}
|
{% if summary.counts.tickets_blocked %}
|
||||||
{{ alert(summary.counts.tickets_blocked ~ ' Ticket(s) sind gesperrt -- fuer diese Kunden
|
{{ alert(summary.counts.tickets_blocked ~ ' Ticket(s) sind gesperrt -- für diese Kunden
|
||||||
lassen sich keine neuen Lizenzen ausstellen.', 'warning') }}
|
lassen sich keine neuen Lizenzen ausstellen.', 'warning') }}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
{% for key, label, kind, hint in [
|
{% for key, label, kind, hint in [
|
||||||
('red', 'Kritisch', 'danger', 'Weniger als 30 Tage Restlaufzeit oder bereits abgelaufen'),
|
('red', 'Kritisch', 'danger', 'Weniger als 30 Tage Restlaufzeit oder bereits abgelaufen'),
|
||||||
('orange', 'Bald faellig', 'warning', '30 bis 90 Tage Restlaufzeit'),
|
('orange', 'Bald fällig', 'warning', '30 bis 90 Tage Restlaufzeit'),
|
||||||
('green', 'In Ordnung', 'ok', 'Mehr als 90 Tage oder unbefristet')] %}
|
('green', 'In Ordnung', 'ok', 'Mehr als 90 Tage oder unbefristet')] %}
|
||||||
{% set entries = summary.buckets[key] %}
|
{% set entries = summary.buckets[key] %}
|
||||||
{% if entries %}
|
{% if entries %}
|
||||||
|
|||||||
@@ -26,13 +26,13 @@
|
|||||||
{% block content %}
|
{% block content %}
|
||||||
{% if state.revision_pending %}
|
{% if state.revision_pending %}
|
||||||
{{ alert('Es wurde eine neue Revision signiert, die der Kunde noch nicht abgeholt hat.
|
{{ alert('Es wurde eine neue Revision signiert, die der Kunde noch nicht abgeholt hat.
|
||||||
Eine erreichbare Installation holt sie beim naechsten Heartbeat; eine Installation ohne
|
Eine erreichbare Installation holt sie beim nächsten Heartbeat; eine Installation ohne
|
||||||
Verbindung braucht dafuer den Offline-Weg -- der Kunde findet ihn in seinem Portal.',
|
Verbindung braucht dafür den Offline-Weg -- der Kunde findet ihn in seinem Portal.',
|
||||||
'warning', 'Revision ausstehend') }}
|
'warning', 'Revision ausstehend') }}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
{% if license.status == 'revoked' %}
|
{% if license.status == 'revoked' %}
|
||||||
{{ alert('Diese Lizenz ist widerrufen. Der Client erfaehrt das beim naechsten Heartbeat und
|
{{ alert('Diese Lizenz ist widerrufen. Der Client erfährt das beim nächsten Heartbeat und
|
||||||
schaltet seine lizenzpflichtigen Funktionen ab.' ~ ((' Grund: ' ~ license.revoke_reason) if
|
schaltet seine lizenzpflichtigen Funktionen ab.' ~ ((' Grund: ' ~ license.revoke_reason) if
|
||||||
license.revoke_reason else ''), 'danger', 'Widerrufen') }}
|
license.revoke_reason else ''), 'danger', 'Widerrufen') }}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
@@ -72,7 +72,7 @@
|
|||||||
{% else %}<span class="faint">keine</span>{% endif %}
|
{% else %}<span class="faint">keine</span>{% endif %}
|
||||||
</dd>
|
</dd>
|
||||||
<dt>Ausgestellt</dt><dd>{{ license.issued_at | dt }} von {{ license.created_by or '-' }}</dd>
|
<dt>Ausgestellt</dt><dd>{{ license.issued_at | dt }} von {{ license.created_by or '-' }}</dd>
|
||||||
<dt>Laeuft ab</dt>
|
<dt>Läuft ab</dt>
|
||||||
<dd>
|
<dd>
|
||||||
{% if license.lifetime %}{{ pill('unbefristet', 'online') }}
|
{% if license.lifetime %}{{ pill('unbefristet', 'online') }}
|
||||||
{% else %}
|
{% else %}
|
||||||
@@ -105,9 +105,9 @@
|
|||||||
<section class="card">
|
<section class="card">
|
||||||
<div class="card__header">
|
<div class="card__header">
|
||||||
<div class="flex-1">
|
<div class="flex-1">
|
||||||
<div class="card__title">{{ icon('refresh', size=17) }} Nachtraeglich anpassen</div>
|
<div class="card__title">{{ icon('refresh', size=17) }} Nachträglich anpassen</div>
|
||||||
<div class="card__sub">
|
<div class="card__sub">
|
||||||
Signiert eine neue Revision. Der Client erhaelt sie beim naechsten Heartbeat --
|
Signiert eine neue Revision. Der Client erhält sie beim nächsten Heartbeat --
|
||||||
ohne erneute Aktivierung.
|
ohne erneute Aktivierung.
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -126,9 +126,9 @@
|
|||||||
</div>
|
</div>
|
||||||
</fieldset>
|
</fieldset>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{{ field('extend_days', 'Laufzeit verlaengern (Tage)', type='number',
|
{{ field('extend_days', 'Laufzeit verlängern (Tage)', type='number',
|
||||||
placeholder='0',
|
placeholder='0',
|
||||||
hint_text='Wird auf das aktuelle Ablaufdatum aufgeschlagen.') }}
|
hint_text='Wird auf das aktülle Ablaufdatum aufgeschlagen.') }}
|
||||||
</div>
|
</div>
|
||||||
<div class="card__footer">
|
<div class="card__footer">
|
||||||
<button class="btn btn--primary" type="submit">Neue Revision signieren</button>
|
<button class="btn btn--primary" type="submit">Neue Revision signieren</button>
|
||||||
@@ -141,13 +141,13 @@
|
|||||||
<div class="card__header">
|
<div class="card__header">
|
||||||
<div class="flex-1">
|
<div class="flex-1">
|
||||||
<div class="card__title">{{ icon('transfer', size=17) }} Zustellungen</div>
|
<div class="card__title">{{ icon('transfer', size=17) }} Zustellungen</div>
|
||||||
<div class="card__sub">{{ deliveries | length }} Vorgaenge</div>
|
<div class="card__sub">{{ deliveries | length }} Vorgänge</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{% if deliveries %}
|
{% if deliveries %}
|
||||||
<div class="table-wrap">
|
<div class="table-wrap">
|
||||||
<table class="table table--compact">
|
<table class="table table--compact">
|
||||||
<thead><tr><th>Zeit</th><th>Weg</th><th>Empfaenger</th><th>Durch</th><th>Detail</th></tr></thead>
|
<thead><tr><th>Zeit</th><th>Weg</th><th>Empfänger</th><th>Durch</th><th>Detail</th></tr></thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{% for entry in deliveries %}
|
{% for entry in deliveries %}
|
||||||
<tr>
|
<tr>
|
||||||
@@ -170,7 +170,7 @@
|
|||||||
<summary class="card__header" style="cursor:pointer">
|
<summary class="card__header" style="cursor:pointer">
|
||||||
<div class="flex-1">
|
<div class="flex-1">
|
||||||
<div class="card__title">{{ icon('file-text', size=17) }} Signiertes Dokument</div>
|
<div class="card__title">{{ icon('file-text', size=17) }} Signiertes Dokument</div>
|
||||||
<div class="card__sub">Genau so, wie es signiert wurde -- jede Aenderung bricht die Signatur</div>
|
<div class="card__sub">Genau so, wie es signiert wurde -- jede Änderung bricht die Signatur</div>
|
||||||
</div>
|
</div>
|
||||||
{{ icon('chevron-down', size=16) }}
|
{{ icon('chevron-down', size=16) }}
|
||||||
</summary>
|
</summary>
|
||||||
@@ -185,7 +185,7 @@
|
|||||||
<div class="flex-1">
|
<div class="flex-1">
|
||||||
<div class="card__title">{{ icon('mail', size=17) }} Per E-Mail versenden</div>
|
<div class="card__title">{{ icon('mail', size=17) }} Per E-Mail versenden</div>
|
||||||
<div class="card__sub">
|
<div class="card__sub">
|
||||||
{% if mail_ready %}Ueber Microsoft Graph{% else %}Nicht konfiguriert{% endif %}
|
{% if mail_ready %}Über Microsoft Graph{% else %}Nicht konfiguriert{% endif %}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -193,7 +193,7 @@
|
|||||||
data-no-guard>
|
data-no-guard>
|
||||||
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
|
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
|
||||||
<div class="card__body">
|
<div class="card__body">
|
||||||
{{ field('recipient', 'Empfaenger', value=license.contact_email, type='email',
|
{{ field('recipient', 'Empfänger', value=license.contact_email, type='email',
|
||||||
disabled=not mail_ready) }}
|
disabled=not mail_ready) }}
|
||||||
</div>
|
</div>
|
||||||
<div class="card__footer">
|
<div class="card__footer">
|
||||||
@@ -215,7 +215,7 @@
|
|||||||
data-dialog-open="revoke-dialog">Widerrufen</button>
|
data-dialog-open="revoke-dialog">Widerrufen</button>
|
||||||
<p class="fs-xs faint">
|
<p class="fs-xs faint">
|
||||||
Das Ticket bleibt offen -- eine neue Lizenz kann sofort ausgestellt werden. Genau so
|
Das Ticket bleibt offen -- eine neue Lizenz kann sofort ausgestellt werden. Genau so
|
||||||
laeuft ein Systemwechsel.
|
läuft ein Systemwechsel.
|
||||||
</p>
|
</p>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% if current_user.has('licenses.delete') %}
|
{% if current_user.has('licenses.delete') %}
|
||||||
@@ -225,9 +225,9 @@
|
|||||||
<button class="btn btn--sm btn--ghost danger-text btn--block" type="submit"
|
<button class="btn btn--sm btn--ghost danger-text btn--block" type="submit"
|
||||||
{% if license.status not in ('revoked', 'issued') %}disabled
|
{% if license.status not in ('revoked', 'issued') %}disabled
|
||||||
title="Eine aktive Lizenz muss erst widerrufen werden"{% endif %}
|
title="Eine aktive Lizenz muss erst widerrufen werden"{% endif %}
|
||||||
data-confirm="Lizenz endgueltig aus der Datenbank entfernen? Die Historie geht verloren."
|
data-confirm="Lizenz endgültig aus der Datenbank entfernen? Die Historie geht verloren."
|
||||||
data-confirm-title="Lizenz loeschen" data-confirm-ok="Loeschen">
|
data-confirm-title="Lizenz löschen" data-confirm-ok="Löschen">
|
||||||
Endgueltig loeschen
|
Endgültig löschen
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
@@ -244,7 +244,7 @@
|
|||||||
<form method="post" action="{{ url_for('licenses.revoke', license_id=license.license_id) }}">
|
<form method="post" action="{{ url_for('licenses.revoke', license_id=license.license_id) }}">
|
||||||
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
|
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
|
||||||
<div class="modal__body">
|
<div class="modal__body">
|
||||||
{{ alert('Der Client erfaehrt den Widerruf beim naechsten Heartbeat und schaltet
|
{{ alert('Der Client erfährt den Widerruf beim nächsten Heartbeat und schaltet
|
||||||
lizenzpflichtige Funktionen ab. Das Ticket bleibt offen.', 'warning') }}
|
lizenzpflichtige Funktionen ab. Das Ticket bleibt offen.', 'warning') }}
|
||||||
{{ field('reason', 'Grund (erscheint im Protokoll)', placeholder='Systemwechsel') }}
|
{{ field('reason', 'Grund (erscheint im Protokoll)', placeholder='Systemwechsel') }}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -2,12 +2,12 @@
|
|||||||
{% from "tesm_core/macros.html" import alert %}
|
{% from "tesm_core/macros.html" import alert %}
|
||||||
|
|
||||||
{% block page_title %}Offline-Aktivierung{% endblock %}
|
{% block page_title %}Offline-Aktivierung{% endblock %}
|
||||||
{% block page_sub %}Code des Kunden verarbeiten und Antwortcode zurueckgeben{% endblock %}
|
{% block page_sub %}Code des Kunden verarbeiten und Antwortcode zurückgeben{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
{{ alert('Beide Codes sind signierte Protokollobjekte -- inhaltlich identisch mit dem
|
{{ alert('Beide Codes sind signierte Protokollobjekte -- inhaltlich identisch mit dem
|
||||||
Online-Weg, nur als kopierbarer Text. Die Pruefungen (Signatur, Frische, Nonce, Fingerprint)
|
Online-Weg, nur als kopierbarer Text. Die Prüfungen (Signatur, Frische, Nonce, Fingerprint)
|
||||||
laufen exakt gleich; es gibt hier keine Abkuerzung.', 'info') }}
|
laufen exakt gleich; es gibt hier keine Abkürzung.', 'info') }}
|
||||||
|
|
||||||
<div class="grid grid--split">
|
<div class="grid grid--split">
|
||||||
<section class="card">
|
<section class="card">
|
||||||
@@ -20,8 +20,8 @@ laufen exakt gleich; es gibt hier keine Abkuerzung.', 'info') }}
|
|||||||
<label class="field">
|
<label class="field">
|
||||||
<span class="field__label">Anfragecode</span>
|
<span class="field__label">Anfragecode</span>
|
||||||
<textarea class="textarea input--mono" name="code" rows="8" required
|
<textarea class="textarea input--mono" name="code" rows="8" required
|
||||||
placeholder="Vom Kunden erhaltenen Code einfuegen ..."></textarea>
|
placeholder="Vom Kunden erhaltenen Code einfügen ..."></textarea>
|
||||||
<span class="field__hint">Umbrueche und Leerzeichen sind erlaubt.</span>
|
<span class="field__hint">Umbrüche und Leerzeichen sind erlaubt.</span>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
<div class="card__footer">
|
<div class="card__footer">
|
||||||
|
|||||||
@@ -5,14 +5,14 @@
|
|||||||
{% block page_sub %}
|
{% block page_sub %}
|
||||||
{% if state and state.valid and state.activated %}
|
{% if state and state.valid and state.activated %}
|
||||||
aktiv · Rolle {{ role_labels.get(state.role, state.role) }}
|
aktiv · Rolle {{ role_labels.get(state.role, state.role) }}
|
||||||
{% elif state and state.valid %}gueltig, nicht aktiviert
|
{% elif state and state.valid %}gültig, nicht aktiviert
|
||||||
{% elif state and state.present %}ungueltig
|
{% elif state and state.present %}ungültig
|
||||||
{% else %}keine Lizenz installiert{% endif %}
|
{% else %}keine Lizenz installiert{% endif %}
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block topbar_extra %}
|
{% block topbar_extra %}
|
||||||
<a class="btn btn--sm" href="{{ url_for('own_license.signing_key_info') }}">
|
<a class="btn btn--sm" href="{{ url_for('own_license.signing_key_info') }}">
|
||||||
{{ icon('key', size=15) }} Signaturschluessel
|
{{ icon('key', size=15) }} Signaturschlüssel
|
||||||
</a>
|
</a>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
@@ -23,14 +23,14 @@
|
|||||||
{% if state and state.error %}{{ alert(state.error, 'danger') }}{% endif %}
|
{% if state and state.error %}{{ alert(state.error, 'danger') }}{% endif %}
|
||||||
|
|
||||||
{% if state and state.role == 'master' %}
|
{% if state and state.role == 'master' %}
|
||||||
{{ alert('Diese Instanz ist die Wurzelinstanz. Nur sie darf Lizenzen fuer
|
{{ alert('Diese Instanz ist die Wurzelinstanz. Nur sie darf Lizenzen für
|
||||||
weitere Lizenzserver ausstellen -- diese Grenze haengt an der signierten Rolle dieser
|
weitere Lizenzserver ausstellen -- diese Grenze hängt an der signierten Rolle dieser
|
||||||
Lizenzdatei, nicht an einem Gruppenrecht. Ein lokaler Administrator eines Sub-Lizenzservers
|
Lizenzdatei, nicht an einem Gruppenrecht. Ein lokaler Administrator eines Sub-Lizenzservers
|
||||||
kann sie sich damit nicht selbst einraeumen.', 'info', 'Rolle: Master') }}
|
kann sie sich damit nicht selbst einräumen.', 'info', 'Rolle: Master') }}
|
||||||
{% elif state and state.role == 'license_server' %}
|
{% elif state and state.role == 'license_server' %}
|
||||||
{{ alert('Diese Instanz ist ein Sub-Lizenzserver. Sie darf eigene Kunden,
|
{{ alert('Diese Instanz ist ein Sub-Lizenzserver. Sie darf eigene Kunden,
|
||||||
Tickets und Lizenzen verwalten -- aber keine weiteren Lizenzserver-Lizenzen ausstellen. Der
|
Tickets und Lizenzen verwalten -- aber keine weiteren Lizenzserver-Lizenzen ausstellen. Der
|
||||||
eigene Bestand wird automatisch an den uebergeordneten Master gemeldet.', 'info',
|
eigene Bestand wird automatisch an den übergeordneten Master gemeldet.', 'info',
|
||||||
'Rolle: Lizenzserver') }}
|
'Rolle: Lizenzserver') }}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
@@ -57,7 +57,7 @@
|
|||||||
<dt>Inhaber</dt><dd>{{ state.customer_name or '–' | safe }}</dd>
|
<dt>Inhaber</dt><dd>{{ state.customer_name or '–' | safe }}</dd>
|
||||||
<dt>Lizenz-ID</dt><dd class="mono fs-xs break-all">{{ state.status.license_id }}</dd>
|
<dt>Lizenz-ID</dt><dd class="mono fs-xs break-all">{{ state.status.license_id }}</dd>
|
||||||
<dt>Revision</dt><dd>{{ state.status.seq }}</dd>
|
<dt>Revision</dt><dd>{{ state.status.seq }}</dd>
|
||||||
<dt>Laeuft ab</dt>
|
<dt>Läuft ab</dt>
|
||||||
<dd>
|
<dd>
|
||||||
{% if state.status.lifetime %}{{ pill('unbefristet', 'online') }}
|
{% if state.status.lifetime %}{{ pill('unbefristet', 'online') }}
|
||||||
{% else %}
|
{% else %}
|
||||||
@@ -66,7 +66,7 @@
|
|||||||
{{ pill('abgelaufen', 'danger') }}
|
{{ pill('abgelaufen', 'danger') }}
|
||||||
{% if state.status.grace_active %}
|
{% if state.status.grace_active %}
|
||||||
<div class="fs-xs warning-text">
|
<div class="fs-xs warning-text">
|
||||||
Kulanzfrist laeuft ({{ grace_days }} Tage).
|
Kulanzfrist läuft ({{ grace_days }} Tage).
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% elif state.status.expiring_soon %}
|
{% elif state.status.expiring_soon %}
|
||||||
@@ -76,7 +76,7 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</dd>
|
</dd>
|
||||||
<dt>Uebergeordneter Server</dt>
|
<dt>Übergeordneter Server</dt>
|
||||||
<dd class="break-all">
|
<dd class="break-all">
|
||||||
{{ state.status.master_endpoint or 'keiner (Wurzelinstanz oder Offline-Betrieb)' }}
|
{{ state.status.master_endpoint or 'keiner (Wurzelinstanz oder Offline-Betrieb)' }}
|
||||||
</dd>
|
</dd>
|
||||||
@@ -116,7 +116,7 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
</section>
|
</section>
|
||||||
{% else %}
|
{% else %}
|
||||||
{{ empty_state('Keine gueltige Lizenz',
|
{{ empty_state('Keine gültige Lizenz',
|
||||||
'Spielen Sie eine Lizenzdatei ein oder erzeugen Sie mit "tesm-license-admin bootstrap"
|
'Spielen Sie eine Lizenzdatei ein oder erzeugen Sie mit "tesm-license-admin bootstrap"
|
||||||
die eigene Lizenz der Wurzelinstanz.', 'key') }}
|
die eigene Lizenz der Wurzelinstanz.', 'key') }}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
@@ -133,12 +133,12 @@
|
|||||||
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
|
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
|
||||||
<div class="card__body">
|
<div class="card__body">
|
||||||
<label class="field">
|
<label class="field">
|
||||||
<span class="field__label">Antwortcode des uebergeordneten Servers</span>
|
<span class="field__label">Antwortcode des übergeordneten Servers</span>
|
||||||
<textarea class="textarea input--mono" name="code" rows="4" required></textarea>
|
<textarea class="textarea input--mono" name="code" rows="4" required></textarea>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
<div class="card__footer">
|
<div class="card__footer">
|
||||||
<button class="btn btn--primary" type="submit">Antwort uebernehmen</button>
|
<button class="btn btn--primary" type="submit">Antwort übernehmen</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</section>
|
</section>
|
||||||
@@ -150,7 +150,7 @@
|
|||||||
<div class="flex-1">
|
<div class="flex-1">
|
||||||
<div class="card__title">{{ icon('upload', size=17) }} Lizenzdatei einspielen</div>
|
<div class="card__title">{{ icon('upload', size=17) }} Lizenzdatei einspielen</div>
|
||||||
<div class="card__sub">
|
<div class="card__sub">
|
||||||
Fuer einen Sub-Lizenzserver: die Datei, die der Master ausgestellt hat
|
Für einen Sub-Lizenzserver: die Datei, die der Master ausgestellt hat
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -160,7 +160,7 @@
|
|||||||
<div class="card__body stack">
|
<div class="card__body stack">
|
||||||
<input class="input" type="file" name="license_file" accept=".json,.lic" required>
|
<input class="input" type="file" name="license_file" accept=".json,.lic" required>
|
||||||
{% if trusted_keys and current_user.is_admin %}
|
{% if trusted_keys and current_user.is_admin %}
|
||||||
{{ checkbox('allow_new_issuer', 'Wechsel des Ausstellers ausdruecklich zulassen') }}
|
{{ checkbox('allow_new_issuer', 'Wechsel des Ausstellers ausdrücklich zulassen') }}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
<div class="card__footer">
|
<div class="card__footer">
|
||||||
@@ -182,7 +182,7 @@
|
|||||||
<dl class="kv fs-sm">
|
<dl class="kv fs-sm">
|
||||||
<dt>Hostname</dt><dd class="mono">{{ hostname }}</dd>
|
<dt>Hostname</dt><dd class="mono">{{ hostname }}</dd>
|
||||||
<dt>Fingerprint</dt><dd class="mono fs-xs break-all">{{ fingerprint }}</dd>
|
<dt>Fingerprint</dt><dd class="mono fs-xs break-all">{{ fingerprint }}</dd>
|
||||||
<dt>Signaturschluessel</dt>
|
<dt>Signaturschlüssel</dt>
|
||||||
<dd class="mono">{{ signing_key_health.get('key_id') or 'fehlt' }}</dd>
|
<dd class="mono">{{ signing_key_health.get('key_id') or 'fehlt' }}</dd>
|
||||||
</dl>
|
</dl>
|
||||||
</div>
|
</div>
|
||||||
@@ -191,8 +191,8 @@
|
|||||||
<section class="card">
|
<section class="card">
|
||||||
<div class="card__header">
|
<div class="card__header">
|
||||||
<div class="flex-1">
|
<div class="flex-1">
|
||||||
<div class="card__title">{{ icon('shield', size=17) }} Vertrauenswuerdige Aussteller</div>
|
<div class="card__title">{{ icon('shield', size=17) }} Vertrauenswürdige Aussteller</div>
|
||||||
<div class="card__sub">{{ trusted_keys | length }} gepinnte Schluessel</div>
|
<div class="card__sub">{{ trusted_keys | length }} gepinnte Schlüssel</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="card__body">
|
<div class="card__body">
|
||||||
@@ -200,7 +200,7 @@
|
|||||||
<dl class="kv fs-sm">
|
<dl class="kv fs-sm">
|
||||||
{% for key in trusted_keys %}
|
{% for key in trusted_keys %}
|
||||||
<dt class="mono">{{ key.key_id }}</dt>
|
<dt class="mono">{{ key.key_id }}</dt>
|
||||||
<dd>{{ key.label }} <span class="faint">({{ key.source }}, {{ key.added_at | date }})</span></dd>
|
<dd>{{ key.label }} <span class="faint">({{ key.source | issuer_source }}, {{ key.added_at | date }})</span></dd>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</dl>
|
</dl>
|
||||||
{% else %}
|
{% else %}
|
||||||
|
|||||||
@@ -19,7 +19,7 @@
|
|||||||
<div class="card__header">
|
<div class="card__header">
|
||||||
<div class="flex-1">
|
<div class="flex-1">
|
||||||
<div class="card__title">{{ icon('shield', size=17) }} Ihre Lizenzen</div>
|
<div class="card__title">{{ icon('shield', size=17) }} Ihre Lizenzen</div>
|
||||||
<div class="card__sub">{{ tickets | length }} Vertrag/Vertraege</div>
|
<div class="card__sub">{{ tickets | length }} Vertrag/Verträge</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="card__body stack stack--tight">
|
<div class="card__body stack stack--tight">
|
||||||
@@ -68,7 +68,7 @@
|
|||||||
{{ field('contact_person', 'Ansprechpartner', value=customer.contact_person) }}
|
{{ field('contact_person', 'Ansprechpartner', value=customer.contact_person) }}
|
||||||
{{ field('contact_phone', 'Telefon', value=customer.contact_phone) }}
|
{{ field('contact_phone', 'Telefon', value=customer.contact_phone) }}
|
||||||
{{ field('contact_email', 'E-Mail', value=customer.contact_email, type='email',
|
{{ field('contact_email', 'E-Mail', value=customer.contact_email, type='email',
|
||||||
hint_text='Ueber diese Adresse laeuft der Zugang zu diesem Portal.') }}
|
hint_text='Über diese Adresse läuft der Zugang zu diesem Portal.') }}
|
||||||
{{ field('street', 'Strasse', value=customer.street) }}
|
{{ field('street', 'Strasse', value=customer.street) }}
|
||||||
{{ field('house_number', 'Hausnummer', value=customer.house_number) }}
|
{{ field('house_number', 'Hausnummer', value=customer.house_number) }}
|
||||||
{{ field('postal_code', 'PLZ', value=customer.postal_code) }}
|
{{ field('postal_code', 'PLZ', value=customer.postal_code) }}
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
{% block content %}
|
{% block content %}
|
||||||
<p class="fs-sm muted center">
|
<p class="fs-sm muted center">
|
||||||
Geben Sie die bei uns hinterlegte E-Mail-Adresse ein. Sie erhalten einen Link, der
|
Geben Sie die bei uns hinterlegte E-Mail-Adresse ein. Sie erhalten einen Link, der
|
||||||
{{ minutes }} Minuten gueltig ist und sich einmal verwenden laesst.
|
{{ minutes }} Minuten gültig ist und sich einmal verwenden lässt.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<form method="post" class="stack" data-no-guard>
|
<form method="post" class="stack" data-no-guard>
|
||||||
@@ -22,7 +22,7 @@
|
|||||||
</form>
|
</form>
|
||||||
|
|
||||||
<p class="auth-footnote">
|
<p class="auth-footnote">
|
||||||
Aus Datenschutzgruenden bekommen Sie immer dieselbe Rueckmeldung -- unabhaengig davon, ob die
|
Aus Datenschutzgründen bekommen Sie immer dieselbe Rückmeldung -- unabhängig davon, ob die
|
||||||
Adresse bei uns bekannt ist.
|
Adresse bei uns bekannt ist.
|
||||||
</p>
|
</p>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -13,7 +13,7 @@
|
|||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
{{ alert('Diese Daten verwaltet der Sub-Lizenzserver selbst. Sie sind hier rein informativ und
|
{{ alert('Diese Daten verwaltet der Sub-Lizenzserver selbst. Sie sind hier rein informativ und
|
||||||
werden bei jedem Bericht vollstaendig ersetzt.', 'info') }}
|
werden bei jedem Bericht vollständig ersetzt.', 'info') }}
|
||||||
|
|
||||||
{% if reported.customers %}
|
{% if reported.customers %}
|
||||||
<div class="stack">
|
<div class="stack">
|
||||||
@@ -71,7 +71,7 @@ werden bei jedem Bericht vollstaendig ersetzt.', 'info') }}
|
|||||||
</div>
|
</div>
|
||||||
{% else %}
|
{% else %}
|
||||||
{{ empty_state('Noch kein Bericht',
|
{{ empty_state('Noch kein Bericht',
|
||||||
'Dieser Lizenzserver hat noch nichts gemeldet. Der erste Bericht kommt mit dem naechsten
|
'Dieser Lizenzserver hat noch nichts gemeldet. Der erste Bericht kommt mit dem nächsten
|
||||||
Heartbeat.', 'share') }}
|
Heartbeat.', 'share') }}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -6,8 +6,8 @@
|
|||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
{{ alert('Ein Sub-Lizenzserver meldet seinen kompletten Bestand -- huckepack auf dem
|
{{ alert('Ein Sub-Lizenzserver meldet seinen kompletten Bestand -- huckepack auf dem
|
||||||
' ~ heartbeat_hours ~ '-Stunden-Heartbeat und zusaetzlich sofort nach jeder Aenderung. Jeder
|
' ~ heartbeat_hours ~ '-Stunden-Heartbeat und zusätzlich sofort nach jeder Änderung. Jeder
|
||||||
Bericht ist ein vollstaendiger Schnappschuss: Geloeschtes verschwindet automatisch auch hier.',
|
Bericht ist ein vollständiger Schnappschuss: Gelöschtes verschwindet automatisch auch hier.',
|
||||||
'info') }}
|
'info') }}
|
||||||
|
|
||||||
{% if sources %}
|
{% if sources %}
|
||||||
|
|||||||
@@ -20,7 +20,7 @@
|
|||||||
<div class="card__header">
|
<div class="card__header">
|
||||||
<div class="flex-1">
|
<div class="flex-1">
|
||||||
<div class="card__title">
|
<div class="card__title">
|
||||||
{{ icon('shield', size=17) }} Aktuelle Lizenz
|
{{ icon('shield', size=17) }} Aktülle Lizenz
|
||||||
{% if current.status == 'active' %}{{ pill('aktiv', 'online') }}
|
{% if current.status == 'active' %}{{ pill('aktiv', 'online') }}
|
||||||
{% else %}{{ pill('bereit zum Einspielen', 'info') }}{% endif %}
|
{% else %}{{ pill('bereit zum Einspielen', 'info') }}{% endif %}
|
||||||
</div>
|
</div>
|
||||||
@@ -34,7 +34,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="card__body stack">
|
<div class="card__body stack">
|
||||||
<dl class="kv fs-sm">
|
<dl class="kv fs-sm">
|
||||||
<dt>Gueltig bis</dt>
|
<dt>Gültig bis</dt>
|
||||||
<dd>{% if current.lifetime %}unbefristet{% else %}{{ current.expires_at | date }}{% endif %}</dd>
|
<dd>{% if current.lifetime %}unbefristet{% else %}{{ current.expires_at | date }}{% endif %}</dd>
|
||||||
{% if current.hostname %}<dt>Installiert auf</dt><dd>{{ current.hostname }}</dd>{% endif %}
|
{% if current.hostname %}<dt>Installiert auf</dt><dd>{{ current.hostname }}</dd>{% endif %}
|
||||||
<dt>Ausgestellt</dt><dd>{{ current.issued_at | date }}</dd>
|
<dt>Ausgestellt</dt><dd>{{ current.issued_at | date }}</dd>
|
||||||
@@ -64,7 +64,7 @@
|
|||||||
action="{{ url_for('selfservice.revoke', ticket_id=ticket.id, license_id=current.license_id) }}">
|
action="{{ url_for('selfservice.revoke', ticket_id=ticket.id, license_id=current.license_id) }}">
|
||||||
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
|
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
|
||||||
<button class="btn btn--ghost danger-text btn--block" type="submit"
|
<button class="btn btn--ghost danger-text btn--block" type="submit"
|
||||||
data-confirm="Lizenz freigeben? Ihre bisherige Installation verliert damit die lizenzpflichtigen Funktionen. Danach koennen Sie sofort eine neue Lizenz fuer Ihr neues System ausstellen."
|
data-confirm="Lizenz freigeben? Ihre bisherige Installation verliert damit die lizenzpflichtigen Funktionen. Danach können Sie sofort eine neue Lizenz für Ihr neues System ausstellen."
|
||||||
data-confirm-title="Lizenz freigeben" data-confirm-ok="Freigeben">
|
data-confirm-title="Lizenz freigeben" data-confirm-ok="Freigeben">
|
||||||
Lizenz freigeben (Systemwechsel)
|
Lizenz freigeben (Systemwechsel)
|
||||||
</button>
|
</button>
|
||||||
@@ -81,7 +81,7 @@
|
|||||||
<div class="card__header">
|
<div class="card__header">
|
||||||
<div class="flex-1">
|
<div class="flex-1">
|
||||||
<div class="card__title">{{ icon('shield', size=17) }} Noch keine Lizenz</div>
|
<div class="card__title">{{ icon('shield', size=17) }} Noch keine Lizenz</div>
|
||||||
<div class="card__sub">Sie koennen Ihre Lizenz jetzt erstellen</div>
|
<div class="card__sub">Sie können Ihre Lizenz jetzt erstellen</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="card__body">
|
<div class="card__body">
|
||||||
@@ -101,8 +101,8 @@
|
|||||||
<details class="card">
|
<details class="card">
|
||||||
<summary class="card__header" style="cursor:pointer">
|
<summary class="card__header" style="cursor:pointer">
|
||||||
<div class="flex-1">
|
<div class="flex-1">
|
||||||
<div class="card__title">{{ icon('history', size=17) }} Frueher ausgestellt</div>
|
<div class="card__title">{{ icon('history', size=17) }} Früher ausgestellt</div>
|
||||||
<div class="card__sub">{{ history | length }} Eintraege</div>
|
<div class="card__sub">{{ history | length }} Einträge</div>
|
||||||
</div>
|
</div>
|
||||||
{{ icon('chevron-down', size=16) }}
|
{{ icon('chevron-down', size=16) }}
|
||||||
</summary>
|
</summary>
|
||||||
@@ -115,7 +115,7 @@
|
|||||||
<td class="fs-sm">{{ entry.issued_at | date }}</td>
|
<td class="fs-sm">{{ entry.issued_at | date }}</td>
|
||||||
<td class="fs-sm">
|
<td class="fs-sm">
|
||||||
{% if entry.status == 'revoked' %}freigegeben
|
{% if entry.status == 'revoked' %}freigegeben
|
||||||
{% elif entry.status == 'deactivated' %}abgeloest
|
{% elif entry.status == 'deactivated' %}abgelöst
|
||||||
{% else %}{{ entry.status }}{% endif %}
|
{% else %}{{ entry.status }}{% endif %}
|
||||||
</td>
|
</td>
|
||||||
<td class="fs-sm">{{ entry.hostname or '–' | safe }}</td>
|
<td class="fs-sm">{{ entry.hostname or '–' | safe }}</td>
|
||||||
|
|||||||
@@ -9,9 +9,9 @@
|
|||||||
<section class="card">
|
<section class="card">
|
||||||
<div class="card__body stack">
|
<div class="card__body stack">
|
||||||
{{ alert('In Ihrer TESM-Installation unter Verwaltung -- Lizenz auf "Lizenz aktivieren"
|
{{ alert('In Ihrer TESM-Installation unter Verwaltung -- Lizenz auf "Lizenz aktivieren"
|
||||||
klicken. Erscheint dort ein Anfragecode, fuegen Sie ihn hier ein. Sie bekommen einen
|
klicken. Erscheint dort ein Anfragecode, fügen Sie ihn hier ein. Sie bekommen einen
|
||||||
Antwortcode zurueck, den Sie in TESM eintragen. Beide Codes sind signiert und lassen
|
Antwortcode zurück, den Sie in TESM eintragen. Beide Codes sind signiert und lassen
|
||||||
sich unterwegs nicht veraendern.', 'info', 'So geht es') }}
|
sich unterwegs nicht verändern.', 'info', 'So geht es') }}
|
||||||
|
|
||||||
{% if response_code %}
|
{% if response_code %}
|
||||||
<div class="field">
|
<div class="field">
|
||||||
@@ -35,7 +35,7 @@
|
|||||||
<label class="field">
|
<label class="field">
|
||||||
<span class="field__label">Anfragecode aus Ihrer TESM-Installation</span>
|
<span class="field__label">Anfragecode aus Ihrer TESM-Installation</span>
|
||||||
<textarea class="textarea input--mono" name="code" rows="6" required
|
<textarea class="textarea input--mono" name="code" rows="6" required
|
||||||
placeholder="Code einfuegen ..."></textarea>
|
placeholder="Code einfügen ..."></textarea>
|
||||||
</label>
|
</label>
|
||||||
<div class="row row--end mt-3">
|
<div class="row row--end mt-3">
|
||||||
<button class="btn btn--primary" type="submit">Antwortcode erzeugen</button>
|
<button class="btn btn--primary" type="submit">Antwortcode erzeugen</button>
|
||||||
@@ -44,7 +44,7 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
<p class="fs-xs faint">
|
<p class="fs-xs faint">
|
||||||
<a href="{{ url_for('selfservice.overview', ticket_id=ticket.id) }}">Zurueck zur Uebersicht</a>
|
<a href="{{ url_for('selfservice.overview', ticket_id=ticket.id) }}">Zurück zur Übersicht</a>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
{% block page_title %}E-Mail-Versand{% endblock %}
|
{% block page_title %}E-Mail-Versand{% endblock %}
|
||||||
{% block page_sub %}
|
{% block page_sub %}
|
||||||
Microsoft Graph (App-only) ·
|
Microsoft Graph (App-only) ·
|
||||||
{% if ready %}einsatzbereit{% else %}nicht vollstaendig konfiguriert{% endif %}
|
{% if ready %}einsatzbereit{% else %}nicht vollständig konfiguriert{% endif %}
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block topbar_extra %}
|
{% block topbar_extra %}
|
||||||
@@ -19,8 +19,8 @@
|
|||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
{{ alert('Der Versand laeuft ueber den Client-Credentials-Flow von Microsoft Graph -- kein SMTP,
|
{{ alert('Der Versand läuft über den Client-Credentials-Flow von Microsoft Graph -- kein SMTP,
|
||||||
kein zusaetzliches Paket. Die Anwendung braucht in Azure die Anwendungsberechtigung
|
kein zusätzliches Paket. Die Anwendung braucht in Azure die Anwendungsberechtigung
|
||||||
Mail.Send. Der Verbindungstest holt nur ein Token und verschickt
|
Mail.Send. Der Verbindungstest holt nur ein Token und verschickt
|
||||||
bewusst keine echte Nachricht.', 'info') }}
|
bewusst keine echte Nachricht.', 'info') }}
|
||||||
|
|
||||||
@@ -46,7 +46,7 @@ bewusst keine echte Nachricht.', 'info') }}
|
|||||||
</div>
|
</div>
|
||||||
<fieldset class="fieldset">
|
<fieldset class="fieldset">
|
||||||
<legend>Client-Secret {% if has_secret %}{{ pill('gesetzt', 'online') }}{% else %}{{ pill('fehlt', 'warning') }}{% endif %}</legend>
|
<legend>Client-Secret {% if has_secret %}{{ pill('gesetzt', 'online') }}{% else %}{{ pill('fehlt', 'warning') }}{% endif %}</legend>
|
||||||
{{ field('client_secret', 'Neues Secret (leer = unveraendert)', type='password',
|
{{ field('client_secret', 'Neues Secret (leer = unverändert)', type='password',
|
||||||
autocomplete='new-password', disabled=not can_secrets) }}
|
autocomplete='new-password', disabled=not can_secrets) }}
|
||||||
{% if not can_secrets %}
|
{% if not can_secrets %}
|
||||||
<p class="fs-xs faint mt-2">
|
<p class="fs-xs faint mt-2">
|
||||||
@@ -63,17 +63,17 @@ bewusst keine echte Nachricht.', 'info') }}
|
|||||||
|
|
||||||
<section class="card">
|
<section class="card">
|
||||||
<div class="card__header">
|
<div class="card__header">
|
||||||
<div class="flex-1"><div class="card__title">{{ icon('info', size=17) }} Wofuer der Versand genutzt wird</div></div>
|
<div class="flex-1"><div class="card__title">{{ icon('info', size=17) }} Wofür der Versand genutzt wird</div></div>
|
||||||
</div>
|
</div>
|
||||||
<div class="card__body">
|
<div class="card__body">
|
||||||
<ul class="stack stack--tight fs-sm muted">
|
<ul class="stack stack--tight fs-sm muted">
|
||||||
<li>Lizenzdatei an den Kunden senden (mit Anhang).</li>
|
<li>Lizenzdatei an den Kunden senden (mit Anhang).</li>
|
||||||
<li>Zugangslink fuer das Kundenportal.</li>
|
<li>Zugangslink für das Kundenportal.</li>
|
||||||
</ul>
|
</ul>
|
||||||
<div class="divider"></div>
|
<div class="divider"></div>
|
||||||
<p class="fs-sm muted">
|
<p class="fs-sm muted">
|
||||||
Das Secret liegt AES-256-GCM-verschluesselt in der Datenbank, gebunden an seinen
|
Das Secret liegt AES-256-GCM-verschlüsselt in der Datenbank, gebunden an seinen
|
||||||
Verwendungszweck. Ohne die Schluesseldatei dieser Installation ist es nicht lesbar.
|
Verwendungszweck. Ohne die Schlüsseldatei dieser Installation ist es nicht lesbar.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@@ -6,8 +6,8 @@
|
|||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
{% if not sysops_available %}
|
{% if not sysops_available %}
|
||||||
{{ alert('Systemhelfer nicht verfuegbar -- Hostname und Zeitzone koennen hier nur angezeigt,
|
{{ alert('Systemhelfer nicht verfügbar -- Hostname und Zeitzone können hier nur angezeigt,
|
||||||
nicht geaendert werden.', 'info') }}
|
nicht geändert werden.', 'info') }}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
<div class="grid grid--split">
|
<div class="grid grid--split">
|
||||||
@@ -16,7 +16,7 @@
|
|||||||
<div class="card__header">
|
<div class="card__header">
|
||||||
<div class="flex-1">
|
<div class="flex-1">
|
||||||
<div class="card__title">{{ icon('users', size=17) }} Kundenportal und Self-Service</div>
|
<div class="card__title">{{ icon('users', size=17) }} Kundenportal und Self-Service</div>
|
||||||
<div class="card__sub">Zwei unabhaengige Zeitfenster: Link und Sitzung</div>
|
<div class="card__sub">Zwei unabhängige Zeitfenster: Link und Sitzung</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<form method="post" action="{{ url_for('settings.system_save') }}">
|
<form method="post" action="{{ url_for('settings.system_save') }}">
|
||||||
@@ -24,10 +24,10 @@
|
|||||||
<div class="card__body stack">
|
<div class="card__body stack">
|
||||||
{{ checkbox('portal_enabled', 'Kundenportal aktiv', checked=values.portal_enabled) }}
|
{{ checkbox('portal_enabled', 'Kundenportal aktiv', checked=values.portal_enabled) }}
|
||||||
<div class="form-grid">
|
<div class="form-grid">
|
||||||
{{ field('portal_link_minutes', 'Zugangslink gueltig (Minuten)',
|
{{ field('portal_link_minutes', 'Zugangslink gültig (Minuten)',
|
||||||
value=values.portal_link_minutes, type='number',
|
value=values.portal_link_minutes, type='number',
|
||||||
hint_text=specs['portal_link_minutes'].hint) }}
|
hint_text=specs['portal_link_minutes'].hint) }}
|
||||||
{{ field('portal_session_minutes', 'Portalsitzung gueltig (Minuten)',
|
{{ field('portal_session_minutes', 'Portalsitzung gültig (Minuten)',
|
||||||
value=values.portal_session_minutes, type='number',
|
value=values.portal_session_minutes, type='number',
|
||||||
hint_text=specs['portal_session_minutes'].hint) }}
|
hint_text=specs['portal_session_minutes'].hint) }}
|
||||||
</div>
|
</div>
|
||||||
@@ -35,7 +35,7 @@
|
|||||||
{{ checkbox('self_service_enabled', 'Self-Service-Link je Ticket aktiv',
|
{{ checkbox('self_service_enabled', 'Self-Service-Link je Ticket aktiv',
|
||||||
checked=values.self_service_enabled,
|
checked=values.self_service_enabled,
|
||||||
hint_text=specs['self_service_enabled'].hint) }}
|
hint_text=specs['self_service_enabled'].hint) }}
|
||||||
{{ checkbox('allow_customer_revoke', 'Kunden duerfen ihre Lizenz selbst freigeben',
|
{{ checkbox('allow_customer_revoke', 'Kunden dürfen ihre Lizenz selbst freigeben',
|
||||||
checked=values.allow_customer_revoke,
|
checked=values.allow_customer_revoke,
|
||||||
hint_text=specs['allow_customer_revoke'].hint) }}
|
hint_text=specs['allow_customer_revoke'].hint) }}
|
||||||
</div>
|
</div>
|
||||||
@@ -60,6 +60,9 @@
|
|||||||
selected=values.log_rotate_interval) }}
|
selected=values.log_rotate_interval) }}
|
||||||
{{ field('log_rotate_keep', 'Aufbewahrte Rotationen', value=values.log_rotate_keep,
|
{{ field('log_rotate_keep', 'Aufbewahrte Rotationen', value=values.log_rotate_keep,
|
||||||
type='number') }}
|
type='number') }}
|
||||||
|
{{ field('session_idle_minutes', 'Automatische Abmeldung (Minuten)',
|
||||||
|
value=values.session_idle_minutes, type='number',
|
||||||
|
hint_text=specs['session_idle_minutes'].hint) }}
|
||||||
{{ field('trash_retention_days', 'Papierkorb-Aufbewahrung (Tage)',
|
{{ field('trash_retention_days', 'Papierkorb-Aufbewahrung (Tage)',
|
||||||
value=values.trash_retention_days, type='number') }}
|
value=values.trash_retention_days, type='number') }}
|
||||||
</div>
|
</div>
|
||||||
@@ -105,7 +108,7 @@
|
|||||||
<div class="card__header">
|
<div class="card__header">
|
||||||
<div class="flex-1">
|
<div class="flex-1">
|
||||||
<div class="card__title">{{ icon('sliders', size=17) }} Navigationsreihenfolge</div>
|
<div class="card__title">{{ icon('sliders', size=17) }} Navigationsreihenfolge</div>
|
||||||
<div class="card__sub">Gilt fuer alle Benutzer</div>
|
<div class="card__sub">Gilt für alle Benutzer</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<form method="post" action="{{ url_for('admin.nav_order') }}">
|
<form method="post" action="{{ url_for('admin.nav_order') }}">
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
{{ alert('Diese Angaben werden in jede Lizenzdatei eingebettet und beim Kunden auf der
|
{{ alert('Diese Angaben werden in jede Lizenzdatei eingebettet und beim Kunden auf der
|
||||||
Lizenzseite angezeigt -- inklusive Logo. Der oeffentliche Endpunkt entscheidet zusaetzlich,
|
Lizenzseite angezeigt -- inklusive Logo. Der öffentliche Endpunkt entscheidet zusätzlich,
|
||||||
welche Basis-URL in kundengerichteten Links steht.', 'info') }}
|
welche Basis-URL in kundengerichteten Links steht.', 'info') }}
|
||||||
|
|
||||||
<div class="grid grid--split">
|
<div class="grid grid--split">
|
||||||
@@ -26,18 +26,18 @@ welche Basis-URL in kundengerichteten Links steht.', 'info') }}
|
|||||||
disabled=not current_user.has('settings_vendor.edit')) }}
|
disabled=not current_user.has('settings_vendor.edit')) }}
|
||||||
</div>
|
</div>
|
||||||
{{ textarea_field('vendor_address', 'Adresse', value=values.vendor_address, rows=3) }}
|
{{ textarea_field('vendor_address', 'Adresse', value=values.vendor_address, rows=3) }}
|
||||||
{{ field('master_endpoint', 'Oeffentlicher Endpunkt', value=values.master_endpoint,
|
{{ field('master_endpoint', 'Öffentlicher Endpunkt', value=values.master_endpoint,
|
||||||
placeholder='https://lizenz.firma.de',
|
placeholder='https://lizenz.firma.de',
|
||||||
disabled=not current_user.has('settings_vendor.edit'),
|
disabled=not current_user.has('settings_vendor.edit'),
|
||||||
hint_text='Basis-URL, unter der Kundeninstanzen diesen Server erreichen. Wird in
|
hint_text='Basis-URL, unter der Kundeninstanzen diesen Server erreichen. Wird in
|
||||||
jede Lizenz eingebettet und fuer alle kundengerichteten Links verwendet -- statt
|
jede Lizenz eingebettet und für alle kundengerichteten Links verwendet -- statt
|
||||||
des Host-Headers der aktuellen Anfrage, der bei internen Aufrufen falsch waere.',
|
des Host-Headers der aktüllen Anfrage, der bei internen Aufrufen falsch wäre.',
|
||||||
extra_class='field--full') }}
|
extra_class='field--full') }}
|
||||||
<fieldset class="fieldset">
|
<fieldset class="fieldset">
|
||||||
<legend>Logo (maximal {{ max_logo_kb }} KB)</legend>
|
<legend>Logo (maximal {{ max_logo_kb }} KB)</legend>
|
||||||
<div class="stack stack--tight">
|
<div class="stack stack--tight">
|
||||||
{% if values.vendor_logo_base64 %}
|
{% if values.vendor_logo_base64 %}
|
||||||
<img src="{{ values.vendor_logo_base64 }}" alt="Aktuelles Logo"
|
<img src="{{ values.vendor_logo_base64 }}" alt="Aktülles Logo"
|
||||||
style="max-height:64px;background:var(--surface-2);padding:var(--sp-2);border-radius:var(--r-sm)">
|
style="max-height:64px;background:var(--surface-2);padding:var(--sp-2);border-radius:var(--r-sm)">
|
||||||
{% else %}
|
{% else %}
|
||||||
<p class="faint fs-sm">Kein Logo hinterlegt.</p>
|
<p class="faint fs-sm">Kein Logo hinterlegt.</p>
|
||||||
@@ -45,7 +45,7 @@ welche Basis-URL in kundengerichteten Links steht.', 'info') }}
|
|||||||
<input class="input" type="file" name="vendor_logo" accept="image/png,image/jpeg,image/svg+xml"
|
<input class="input" type="file" name="vendor_logo" accept="image/png,image/jpeg,image/svg+xml"
|
||||||
{% if not current_user.has('settings_vendor.edit') %}disabled{% endif %}>
|
{% if not current_user.has('settings_vendor.edit') %}disabled{% endif %}>
|
||||||
<span class="field__hint">
|
<span class="field__hint">
|
||||||
Ein zu grosses Logo wird abgelehnt, die uebrigen Angaben werden trotzdem gespeichert.
|
Ein zu grosses Logo wird abgelehnt, die übrigen Angaben werden trotzdem gespeichert.
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</fieldset>
|
</fieldset>
|
||||||
@@ -75,7 +75,7 @@ welche Basis-URL in kundengerichteten Links steht.', 'info') }}
|
|||||||
<div class="divider"></div>
|
<div class="divider"></div>
|
||||||
<p class="fs-sm muted">
|
<p class="fs-sm muted">
|
||||||
Die Angaben werden zum Zeitpunkt der Ausstellung in die Lizenz kopiert und signiert. Eine
|
Die Angaben werden zum Zeitpunkt der Ausstellung in die Lizenz kopiert und signiert. Eine
|
||||||
Aenderung hier wirkt auf neue Lizenzen -- bestehende behalten ihren Stand, bis eine neue
|
Änderung hier wirkt auf neue Lizenzen -- bestehende behalten ihren Stand, bis eine neue
|
||||||
Revision signiert wird.
|
Revision signiert wird.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,34 +1,34 @@
|
|||||||
{% extends "tesm_core/base.html" %}
|
{% extends "tesm_core/base.html" %}
|
||||||
{% from "tesm_core/macros.html" import alert %}
|
{% from "tesm_core/macros.html" import alert %}
|
||||||
{% block page_title %}Signaturschluessel{% endblock %}
|
{% block page_title %}Signaturschlüssel{% endblock %}
|
||||||
{% block page_sub %}Schluessel-ID {{ key_id }}{% endblock %}
|
{% block page_sub %}Schlüssel-ID {{ key_id }}{% endblock %}
|
||||||
{% block breadcrumb %}
|
{% block breadcrumb %}
|
||||||
<nav class="breadcrumb">
|
<nav class="breadcrumb">
|
||||||
<span><a href="{{ url_for('own_license.overview') }}">Eigene Lizenz</a></span><span>Signaturschluessel</span>
|
<span><a href="{{ url_for('own_license.overview') }}">Eigene Lizenz</a></span><span>Signaturschlüssel</span>
|
||||||
</nav>
|
</nav>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
{% block content %}
|
{% block content %}
|
||||||
{{ alert('Der oeffentliche Teil ist kein Geheimnis -- er reist in jeder ausgestellten Lizenz mit.
|
{{ alert('Der öffentliche Teil ist kein Geheimnis -- er reist in jeder ausgestellten Lizenz mit.
|
||||||
Ihn hier anzuzeigen erlaubt es, ihn unabhaengig zu vergleichen, bevor ein Kunde ihn als
|
Ihn hier anzuzeigen erlaubt es, ihn unabhängig zu vergleichen, bevor ein Kunde ihn als
|
||||||
vertrauenswuerdig hinterlegt.', 'info') }}
|
vertrauenswürdig hinterlegt.', 'info') }}
|
||||||
{% if health.problems %}
|
{% if health.problems %}
|
||||||
{% for problem in health.problems %}{{ alert(problem, 'danger') }}{% endfor %}
|
{% for problem in health.problems %}{{ alert(problem, 'danger') }}{% endfor %}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<section class="card" style="max-width:760px">
|
<section class="card" style="max-width:760px">
|
||||||
<div class="card__header">
|
<div class="card__header">
|
||||||
<div class="flex-1"><div class="card__title">{{ icon('key', size=17) }} Oeffentlicher Schluessel</div>
|
<div class="flex-1"><div class="card__title">{{ icon('key', size=17) }} Öffentlicher Schlüssel</div>
|
||||||
<div class="card__sub">Erzeugt {{ created_at | dt }}</div></div>
|
<div class="card__sub">Erzeugt {{ created_at | dt }}</div></div>
|
||||||
<button class="btn btn--sm" type="button" data-copy="#pubkey">{{ icon('copy', size=14) }} Kopieren</button>
|
<button class="btn btn--sm" type="button" data-copy="#pubkey">{{ icon('copy', size=14) }} Kopieren</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="card__body stack">
|
<div class="card__body stack">
|
||||||
<dl class="kv fs-sm">
|
<dl class="kv fs-sm">
|
||||||
<dt>Schluessel-ID</dt><dd class="mono">{{ key_id }}</dd>
|
<dt>Schlüssel-ID</dt><dd class="mono">{{ key_id }}</dd>
|
||||||
<dt>Verfahren</dt><dd>Ed25519 (EdDSA)</dd>
|
<dt>Verfahren</dt><dd>Ed25519 (EdDSA)</dd>
|
||||||
</dl>
|
</dl>
|
||||||
<pre class="code-block code-block--wrap" id="pubkey">{{ public_key }}</pre>
|
<pre class="code-block code-block--wrap" id="pubkey">{{ public_key }}</pre>
|
||||||
{{ alert('Der private Teil liegt ausschliesslich auf diesem Host (Modus 0600) und ist aus dem
|
{{ alert('Der private Teil liegt ausschliesslich auf diesem Host (Modus 0600) und ist aus dem
|
||||||
Deployment-Abgleich ausgeschlossen. Ginge er verloren, waere jede
|
Deployment-Abgleich ausgeschlossen. Ginge er verloren, wäre jede
|
||||||
ausgestellte Lizenz nicht mehr pruefbar -- bitte getrennt sichern.', 'warning') }}
|
ausgestellte Lizenz nicht mehr prüfbar -- bitte getrennt sichern.', 'warning') }}
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -17,11 +17,11 @@
|
|||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
{% if endpoint_missing %}
|
{% if endpoint_missing %}
|
||||||
{{ alert(endpoint_missing, 'danger', 'Keine Ausstellung moeglich') }}
|
{{ alert(endpoint_missing, 'danger', 'Keine Ausstellung möglich') }}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% if ticket.status == 'blocked' %}
|
{% if ticket.status == 'blocked' %}
|
||||||
{{ alert('Dieses Ticket ist gesperrt. Es lassen sich keine neuen Lizenzen ausstellen -- weder
|
{{ alert('Dieses Ticket ist gesperrt. Es lassen sich keine neuen Lizenzen ausstellen -- weder
|
||||||
hier noch ueber den Self-Service des Kunden. Bereits ausgestellte Lizenzen bleiben gueltig.',
|
hier noch über den Self-Service des Kunden. Bereits ausgestellte Lizenzen bleiben gültig.',
|
||||||
'danger', 'Gesperrt') }}
|
'danger', 'Gesperrt') }}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
@@ -58,7 +58,7 @@
|
|||||||
<input type="hidden" name="as_trial" value="1">
|
<input type="hidden" name="as_trial" value="1">
|
||||||
<button class="btn" type="submit"
|
<button class="btn" type="submit"
|
||||||
{% if has_open_license or ticket.status == 'blocked' %}disabled{% endif %}
|
{% if has_open_license or ticket.status == 'blocked' %}disabled{% endif %}
|
||||||
data-confirm="Testlizenz ausstellen? {{ trial_days }} Tage Laufzeit mit allen Modulen. Das Ticket selbst bleibt unveraendert."
|
data-confirm="Testlizenz ausstellen? {{ trial_days }} Tage Laufzeit mit allen Modulen. Das Ticket selbst bleibt unverändert."
|
||||||
data-confirm-title="Testlizenz" data-confirm-ok="Ausstellen"
|
data-confirm-title="Testlizenz" data-confirm-ok="Ausstellen"
|
||||||
data-confirm-style="primary">
|
data-confirm-style="primary">
|
||||||
Als Testlizenz ({{ trial_days }} Tage, alle Module)
|
Als Testlizenz ({{ trial_days }} Tage, alle Module)
|
||||||
@@ -81,7 +81,7 @@
|
|||||||
{% if licenses %}
|
{% if licenses %}
|
||||||
<div class="table-wrap">
|
<div class="table-wrap">
|
||||||
<table class="table table--compact">
|
<table class="table table--compact">
|
||||||
<thead><tr><th>Lizenz</th><th>Ausgestellt</th><th>Zustand</th><th>Laeuft ab</th><th>Host</th></tr></thead>
|
<thead><tr><th>Lizenz</th><th>Ausgestellt</th><th>Zustand</th><th>Läuft ab</th><th>Host</th></tr></thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{% for entry in licenses %}
|
{% for entry in licenses %}
|
||||||
<tr>
|
<tr>
|
||||||
@@ -115,7 +115,7 @@
|
|||||||
<section class="card">
|
<section class="card">
|
||||||
<div class="card__header">
|
<div class="card__header">
|
||||||
<div class="flex-1">
|
<div class="flex-1">
|
||||||
<div class="card__title">{{ icon('link', size=17) }} Self-Service fuer den Kunden</div>
|
<div class="card__title">{{ icon('link', size=17) }} Self-Service für den Kunden</div>
|
||||||
<div class="card__sub">
|
<div class="card__sub">
|
||||||
Ohne Anmeldung nutzbar – die Berechtigung ist der Besitz dieses Links
|
Ohne Anmeldung nutzbar – die Berechtigung ist der Besitz dieses Links
|
||||||
</div>
|
</div>
|
||||||
@@ -128,7 +128,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<p class="fs-xs faint">
|
<p class="fs-xs faint">
|
||||||
Damit kann der Kunde seine Lizenz herunterladen, bei einem Systemwechsel freigeben und
|
Damit kann der Kunde seine Lizenz herunterladen, bei einem Systemwechsel freigeben und
|
||||||
neu ausstellen. Der Link enthaelt 192 Bit Zufall und ist nicht ratbar -- behandeln Sie
|
neu ausstellen. Der Link enthält 192 Bit Zufall und ist nicht ratbar -- behandeln Sie
|
||||||
ihn wie ein Passwort.
|
ihn wie ein Passwort.
|
||||||
</p>
|
</p>
|
||||||
<div class="divider"></div>
|
<div class="divider"></div>
|
||||||
@@ -143,7 +143,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
{% elif ticket.role != 'customer' %}
|
{% elif ticket.role != 'customer' %}
|
||||||
{{ alert('Fuer Lizenzserver-Tickets ist der Self-Service vollstaendig gesperrt -- diese
|
{{ alert('Für Lizenzserver-Tickets ist der Self-Service vollständig gesperrt -- diese
|
||||||
Lizenzen werden ausschliesslich hier verwaltet.', 'info') }}
|
Lizenzen werden ausschliesslich hier verwaltet.', 'info') }}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
@@ -158,8 +158,8 @@
|
|||||||
<form method="post" action="{{ url_for('tickets.update', ticket_id=ticket.id) }}">
|
<form method="post" action="{{ url_for('tickets.update', ticket_id=ticket.id) }}">
|
||||||
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
|
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
|
||||||
<div class="card__body stack">
|
<div class="card__body stack">
|
||||||
{{ alert('Aenderungen wirken auf die naechste Ausstellung. Kunde und Rolle sind
|
{{ alert('Änderungen wirken auf die nächste Ausstellung. Kunde und Rolle sind
|
||||||
unveraenderlich.', 'info') }}
|
unveränderlich.', 'info') }}
|
||||||
<label class="field">
|
<label class="field">
|
||||||
<span class="field__label">Lizenztyp</span>
|
<span class="field__label">Lizenztyp</span>
|
||||||
<select class="select" name="type" id="ticket-type"
|
<select class="select" name="type" id="ticket-type"
|
||||||
@@ -206,7 +206,7 @@
|
|||||||
{% if current_user.has('tickets.edit') or current_user.has('tickets.delete') %}
|
{% if current_user.has('tickets.edit') or current_user.has('tickets.delete') %}
|
||||||
<section class="card">
|
<section class="card">
|
||||||
<div class="card__header">
|
<div class="card__header">
|
||||||
<div class="flex-1"><div class="card__title">{{ icon('lock', size=17) }} Sperre und Loeschung</div></div>
|
<div class="flex-1"><div class="card__title">{{ icon('lock', size=17) }} Sperre und Löschung</div></div>
|
||||||
</div>
|
</div>
|
||||||
<div class="card__body stack stack--tight">
|
<div class="card__body stack stack--tight">
|
||||||
{% if current_user.has('tickets.edit') %}
|
{% if current_user.has('tickets.edit') %}
|
||||||
@@ -216,7 +216,7 @@
|
|||||||
<button class="btn btn--sm btn--block {{ 'btn--danger' if ticket.status != 'blocked' }}"
|
<button class="btn btn--sm btn--block {{ 'btn--danger' if ticket.status != 'blocked' }}"
|
||||||
type="submit"
|
type="submit"
|
||||||
data-confirm="{% if ticket.status == 'blocked' %}Ticket entsperren?{% else %}Ticket sperren? Der Kunde kann danach keine neuen Lizenzen mehr ausstellen.{% endif %}"
|
data-confirm="{% if ticket.status == 'blocked' %}Ticket entsperren?{% else %}Ticket sperren? Der Kunde kann danach keine neuen Lizenzen mehr ausstellen.{% endif %}"
|
||||||
data-confirm-title="Ticketsperre" data-confirm-ok="Bestaetigen"
|
data-confirm-title="Ticketsperre" data-confirm-ok="Bestätigen"
|
||||||
data-confirm-style="{{ 'primary' if ticket.status == 'blocked' else 'danger' }}">
|
data-confirm-style="{{ 'primary' if ticket.status == 'blocked' else 'danger' }}">
|
||||||
{{ 'Entsperren' if ticket.status == 'blocked' else 'Sperren' }}
|
{{ 'Entsperren' if ticket.status == 'blocked' else 'Sperren' }}
|
||||||
</button>
|
</button>
|
||||||
@@ -228,9 +228,9 @@
|
|||||||
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
|
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
|
||||||
<button class="btn btn--sm btn--ghost danger-text btn--block" type="submit"
|
<button class="btn btn--sm btn--ghost danger-text btn--block" type="submit"
|
||||||
{% if has_open_license %}disabled title="Erst die offene Lizenz widerrufen"{% endif %}
|
{% if has_open_license %}disabled title="Erst die offene Lizenz widerrufen"{% endif %}
|
||||||
data-confirm="Ticket und seine gesamte Lizenzhistorie loeschen? Das laesst sich nicht rueckgaengig machen."
|
data-confirm="Ticket und seine gesamte Lizenzhistorie löschen? Das lässt sich nicht rückgängig machen."
|
||||||
data-confirm-title="Ticket loeschen" data-confirm-ok="Loeschen">
|
data-confirm-title="Ticket löschen" data-confirm-ok="Löschen">
|
||||||
Ticket loeschen
|
Ticket löschen
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|||||||
@@ -23,7 +23,7 @@
|
|||||||
<label class="field">
|
<label class="field">
|
||||||
<span class="field__label">Kunde <span class="req">*</span></span>
|
<span class="field__label">Kunde <span class="req">*</span></span>
|
||||||
<select class="select" name="customer_id" required>
|
<select class="select" name="customer_id" required>
|
||||||
<option value="">bitte waehlen</option>
|
<option value="">bitte wählen</option>
|
||||||
{% for customer in customers %}
|
{% for customer in customers %}
|
||||||
<option value="{{ customer.id }}"
|
<option value="{{ customer.id }}"
|
||||||
{% if preselected_customer == customer.id %}selected{% endif %}>
|
{% if preselected_customer == customer.id %}selected{% endif %}>
|
||||||
@@ -32,7 +32,7 @@
|
|||||||
{% endfor %}
|
{% endfor %}
|
||||||
</select>
|
</select>
|
||||||
<span class="field__hint">
|
<span class="field__hint">
|
||||||
Der Kunde ist nach dem Anlegen unveraenderlich -- der Self-Service-Link ist an ihn
|
Der Kunde ist nach dem Anlegen unveränderlich -- der Self-Service-Link ist an ihn
|
||||||
gebunden.
|
gebunden.
|
||||||
</span>
|
</span>
|
||||||
</label>
|
</label>
|
||||||
@@ -47,13 +47,13 @@
|
|||||||
{% endfor %}
|
{% endfor %}
|
||||||
</select>
|
</select>
|
||||||
<span class="field__hint">
|
<span class="field__hint">
|
||||||
Standard: Grundfunktionen. Individuell: Module frei waehlbar. Enterprise: alle
|
Standard: Grundfunktionen. Individüll: Module frei wählbar. Enterprise: alle
|
||||||
Module. Testlizenz: 30 Tage.
|
Module. Testlizenz: 30 Tage.
|
||||||
</span>
|
</span>
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
<fieldset class="fieldset" id="module-box">
|
<fieldset class="fieldset" id="module-box">
|
||||||
<legend>Module (nur bei „Individuell“)</legend>
|
<legend>Module (nur bei „Individüll“)</legend>
|
||||||
<div class="stack stack--tight">
|
<div class="stack stack--tight">
|
||||||
{% for module in modules %}
|
{% for module in modules %}
|
||||||
{{ checkbox('modules', module_labels[module], value=module) }}
|
{{ checkbox('modules', module_labels[module], value=module) }}
|
||||||
@@ -65,7 +65,7 @@
|
|||||||
{{ field('valid_days', 'Laufzeit (Tage)', value=default_valid_days, type='number') }}
|
{{ field('valid_days', 'Laufzeit (Tage)', value=default_valid_days, type='number') }}
|
||||||
</div>
|
</div>
|
||||||
{{ checkbox('lifetime', 'Unbefristet',
|
{{ checkbox('lifetime', 'Unbefristet',
|
||||||
hint_text='Nicht kombinierbar mit einer Testlizenz -- das waere eine
|
hint_text='Nicht kombinierbar mit einer Testlizenz -- das wäre eine
|
||||||
permanente Lizenz ohne Module.') }}
|
permanente Lizenz ohne Module.') }}
|
||||||
{{ textarea_field('notes', 'Interne Notiz', rows=2) }}
|
{{ textarea_field('notes', 'Interne Notiz', rows=2) }}
|
||||||
</div>
|
</div>
|
||||||
@@ -77,15 +77,15 @@
|
|||||||
<div class="flex-1">
|
<div class="flex-1">
|
||||||
<div class="card__title">{{ icon('share', size=17) }} Lizenzserver-Lizenz</div>
|
<div class="card__title">{{ icon('share', size=17) }} Lizenzserver-Lizenz</div>
|
||||||
<div class="card__sub">
|
<div class="card__sub">
|
||||||
Erzeugt eine neue Vertrauensgrenze -- der Empfaenger verwaltet danach eigene Kunden
|
Erzeugt eine neue Vertrauensgrenze -- der Empfänger verwaltet danach eigene Kunden
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="card__body stack">
|
<div class="card__body stack">
|
||||||
{{ alert('Ein so lizenzierter Lizenzserver darf alles, was ein Lizenzserver koennen
|
{{ alert('Ein so lizenzierter Lizenzserver darf alles, was ein Lizenzserver können
|
||||||
soll -- ausser selbst weitere Lizenzserver-Lizenzen auszustellen. Er meldet seinen
|
soll -- ausser selbst weitere Lizenzserver-Lizenzen auszustellen. Er meldet seinen
|
||||||
Bestand automatisch hierher zurueck. Module sind fuer diese Rolle bedeutungslos und
|
Bestand automatisch hierher zurück. Module sind für diese Rolle bedeutungslos und
|
||||||
werden ignoriert. Self-Service und Kundenportal sind fuer solche Tickets gesperrt.',
|
werden ignoriert. Self-Service und Kundenportal sind für solche Tickets gesperrt.',
|
||||||
'warning') }}
|
'warning') }}
|
||||||
<label class="check">
|
<label class="check">
|
||||||
<input type="checkbox" name="role" value="license_server">
|
<input type="checkbox" name="role" value="license_server">
|
||||||
|
|||||||
@@ -17,11 +17,11 @@
|
|||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
{% if endpoint_missing %}
|
{% if endpoint_missing %}
|
||||||
{{ alert(endpoint_missing, 'danger', 'Keine Ausstellung moeglich') }}
|
{{ alert(endpoint_missing, 'danger', 'Keine Ausstellung möglich') }}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{{ alert('Ein Ticket beschreibt den Anspruch (Typ, Module, Laufzeit); die Lizenz ist die daraus
|
{{ alert('Ein Ticket beschreibt den Anspruch (Typ, Module, Laufzeit); die Lizenz ist die daraus
|
||||||
erzeugte, signierte Datei. Aenderungen am Ticket wirken auf die naechste
|
erzeugte, signierte Datei. Änderungen am Ticket wirken auf die nächste
|
||||||
Ausstellung -- bereits ausgestellte Lizenzen bleiben unveraendert.', 'info') }}
|
Ausstellung -- bereits ausgestellte Lizenzen bleiben unverändert.', 'info') }}
|
||||||
|
|
||||||
<section class="card">
|
<section class="card">
|
||||||
<div class="table-wrap">
|
<div class="table-wrap">
|
||||||
@@ -85,7 +85,7 @@ Ausstellung -- bereits ausgestellte Lizenzen bleiben unveraendert.', 'info') }}
|
|||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
{% if not tickets %}
|
{% if not tickets %}
|
||||||
{{ empty_state('Keine Tickets', 'Legen Sie das erste Ticket fuer einen Kunden an.', 'ticket') }}
|
{{ empty_state('Keine Tickets', 'Legen Sie das erste Ticket für einen Kunden an.', 'ticket') }}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</section>
|
</section>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
+52
-1
@@ -112,6 +112,21 @@ MONITOR_UNIT="$NAME-monitor.service"
|
|||||||
NGINX_SITE="/etc/nginx/sites-available/$NAME"
|
NGINX_SITE="/etc/nginx/sites-available/$NAME"
|
||||||
ENV_PREFIX="TESM_"
|
ENV_PREFIX="TESM_"
|
||||||
[[ "$APP" == "tesm-license" ]] && ENV_PREFIX="TESM_LICENSE_"
|
[[ "$APP" == "tesm-license" ]] && ENV_PREFIX="TESM_LICENSE_"
|
||||||
|
# Bei einer Aktualisierung gilt zuerst, was schon eingerichtet ist. Sonst
|
||||||
|
# verschiebt ein Update ohne Portangaben die Anwendung: eine benannte Instanz
|
||||||
|
# faellt auf 8080/8443 zurueck, auch wenn sie seit der Installation auf Port 80
|
||||||
|
# lief -- und ein Reverse Proxy davor zeigt dann ins Leere. Dieselbe Regel wie
|
||||||
|
# beim Secure-Flag weiter unten: eine Aktualisierung nimmt nichts weg, was
|
||||||
|
# nicht ausdruecklich neu gesetzt wird.
|
||||||
|
ENV_FILE="/etc/tesm/$NAME.env"
|
||||||
|
if [[ -z "$PORT" && -f "$ENV_FILE" ]]; then
|
||||||
|
PORT="$(sed -n "s/^${ENV_PREFIX}BIND=127\.0\.0\.1:\([0-9]\{1,5\}\)$/\1/p" "$ENV_FILE" | head -1)"
|
||||||
|
fi
|
||||||
|
if [[ -f "$NGINX_SITE" ]]; then
|
||||||
|
[[ -z "$HTTP_PORT" ]] && HTTP_PORT="$(sed -n 's/^[[:space:]]*listen[[:space:]]\([0-9]\{1,5\}\);.*/\1/p' "$NGINX_SITE" | head -1)"
|
||||||
|
[[ -z "$HTTPS_PORT" ]] && HTTPS_PORT="$(sed -n 's/^[[:space:]]*listen[[:space:]]\([0-9]\{1,5\}\)[[:space:]]\+ssl.*/\1/p' "$NGINX_SITE" | head -1)"
|
||||||
|
fi
|
||||||
|
|
||||||
if [[ -z "$PORT" ]]; then
|
if [[ -z "$PORT" ]]; then
|
||||||
PORT=5000
|
PORT=5000
|
||||||
[[ "$APP" == "tesm-license" ]] && PORT=5001
|
[[ "$APP" == "tesm-license" ]] && PORT=5001
|
||||||
@@ -134,7 +149,7 @@ fi
|
|||||||
# Browser sendet das Cookie dann weiter nur ueber HTTPS, die Anwendung setzt es
|
# Browser sendet das Cookie dann weiter nur ueber HTTPS, die Anwendung setzt es
|
||||||
# aber ohne das Flag neu, und beide reden aneinander vorbei.
|
# aber ohne das Flag neu, und beide reden aneinander vorbei.
|
||||||
COOKIE_SECURE=0
|
COOKIE_SECURE=0
|
||||||
if [[ -f "/etc/tesm/$NAME.env" ]] && grep -q "^${ENV_PREFIX}COOKIE_SECURE=1" "/etc/tesm/$NAME.env"; then
|
if [[ -f "$ENV_FILE" ]] && grep -q "^${ENV_PREFIX}COOKIE_SECURE=1" "$ENV_FILE"; then
|
||||||
COOKIE_SECURE=1
|
COOKIE_SECURE=1
|
||||||
fi
|
fi
|
||||||
[[ $HTTPS -eq 1 ]] && COOKIE_SECURE=1
|
[[ $HTTPS -eq 1 ]] && COOKIE_SECURE=1
|
||||||
@@ -280,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."
|
||||||
@@ -59,7 +59,20 @@ RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX AF_NETLINK
|
|||||||
LockPersonality=yes
|
LockPersonality=yes
|
||||||
MemoryDenyWriteExecute=no
|
MemoryDenyWriteExecute=no
|
||||||
SystemCallArchitectures=native
|
SystemCallArchitectures=native
|
||||||
SystemCallFilter=@system-service
|
# "@mount" zusaetzlich zu @system-service: die Dateifreigaben werden ueber
|
||||||
|
# mount.cifs eingebunden, und @system-service enthaelt mount/umount2 nicht.
|
||||||
|
# Ohne diese Ergaenzung scheitert jede Einbindung mit "mount error(1):
|
||||||
|
# Operation not permitted" -- gemessen, und zwar unabhaengig von Rechten,
|
||||||
|
# Zugangsdaten und Mount-Optionen: aus einer normalen Root-Shell gelingt
|
||||||
|
# derselbe Aufruf sofort. Der Umweg ueber nsenter in den Namensraum des Wirts
|
||||||
|
# ist keine Alternative, weil "RestrictNamespaces=yes" setns verbietet.
|
||||||
|
#
|
||||||
|
# Eingebunden wird damit im Mount-Namensraum *dieses* Dienstes. Das ist genau
|
||||||
|
# richtig: nur er liest die Dateien, andere Prozesse haben dort nichts zu
|
||||||
|
# suchen, und mit dem Dienst verschwinden die Einbindungen wieder. Die
|
||||||
|
# Aufraeumaufgabe fuer abgelaufene Einbindungen laeuft deshalb im Webprozess --
|
||||||
|
# der Ueberwachungsdienst koennte sie nicht einmal sehen.
|
||||||
|
SystemCallFilter=@system-service @mount
|
||||||
SystemCallErrorNumber=EPERM
|
SystemCallErrorNumber=EPERM
|
||||||
# Bewusst **kein** einschraenkendes CapabilityBoundingSet: sudo braucht
|
# Bewusst **kein** einschraenkendes CapabilityBoundingSet: sudo braucht
|
||||||
# CAP_SETUID/CAP_SETGID, und der Helfer dahinter braucht Root. Was hier fehlt,
|
# CAP_SETUID/CAP_SETGID, und der Helfer dahinter braucht Root. Was hier fehlt,
|
||||||
|
|||||||
+35
-20
@@ -258,19 +258,17 @@ case "$verb" in
|
|||||||
# Kommandozeile (Prozessliste) und nie ueber die Umgebung
|
# Kommandozeile (Prozessliste) und nie ueber die Umgebung
|
||||||
# (/proc/<pid>/environ).
|
# (/proc/<pid>/environ).
|
||||||
read_stdin_limited > "$creds"
|
read_stdin_limited > "$creds"
|
||||||
# Der aufrufende Dienst laeuft in einem eigenen Mount-Namensraum
|
# Eingebunden wird im Mount-Namensraum des aufrufenden Dienstes -- absichtlich:
|
||||||
# (ProtectSystem, PrivateTmp, ReadWritePaths). Ein Einbinden *darin*
|
# nur dieser Dienst liest die Dateien, und mit ihm verschwinden die
|
||||||
# scheitert mit "mount error(1): Operation not permitted" -- gemessen: mit
|
# Einbindungen wieder. Damit der Syscall durchkommt, braucht die Unit
|
||||||
# denselben Optionen und denselben Zugangsdaten gelingt es aus einer
|
# "@mount" im SystemCallFilter. Ohne das scheitert es mit
|
||||||
# normalen Root-Shell sofort. Deshalb wird im Namensraum des Wirts
|
# "mount error(1): Operation not permitted"; gemessen war der Syscall-Filter
|
||||||
# eingebunden; von dort wird der Mount in den Namensraum des Dienstes
|
# die Ursache, nicht der Namensraum. Ein Umweg ueber nsenter in den
|
||||||
# weitergegeben (er haengt als "slave" darunter), die Anwendung sieht ihn
|
# Namensraum des Wirts ist keine Option -- "RestrictNamespaces=yes"
|
||||||
# also. Die Zugangsdatei unter /run ist ueber die Grenze lesbar, /run ist
|
# verbietet setns, und das Recht dafuer wieder aufzugeben waere teurer als
|
||||||
# nicht privatisiert.
|
# der Gewinn.
|
||||||
im_wirt=(nsenter --mount=/proc/1/ns/mnt --)
|
mkdir -p "$target"
|
||||||
command -v nsenter >/dev/null 2>&1 || im_wirt=()
|
if mount -t cifs "$share" "$target" \
|
||||||
"${im_wirt[@]}" mkdir -p "$target"
|
|
||||||
if "${im_wirt[@]}" mount -t cifs "$share" "$target" \
|
|
||||||
-o "credentials=$creds,uid=$(id -u tesm 2>/dev/null || echo 0),gid=$(id -g tesm 2>/dev/null || echo 0),file_mode=0640,dir_mode=0750,vers=3.1.1,seal"
|
-o "credentials=$creds,uid=$(id -u tesm 2>/dev/null || echo 0),gid=$(id -g tesm 2>/dev/null || echo 0),file_mode=0640,dir_mode=0750,vers=3.1.1,seal"
|
||||||
then
|
then
|
||||||
shred -u "$creds" 2>/dev/null || rm -f "$creds"
|
shred -u "$creds" 2>/dev/null || rm -f "$creds"
|
||||||
@@ -282,13 +280,12 @@ case "$verb" in
|
|||||||
|
|
||||||
umount)
|
umount)
|
||||||
check_mount_target "${1:?Ziel fehlt}"
|
check_mount_target "${1:?Ziel fehlt}"
|
||||||
# Gegenstueck zu mount-cifs: der Mount liegt im Namensraum des Wirts, also
|
# Gegenstueck zu mount-cifs, und deshalb im selben Namensraum: loesen kann
|
||||||
# muss er auch dort geloest werden. Ein umount im Dienst-Namensraum wuerde
|
# nur, wer die Einbindung auch sieht. Ein Aufruf aus einem anderen Dienst
|
||||||
# ihn nur dort ausblenden und die Freigabe bliebe eingebunden.
|
# greift ins Leere -- genau deshalb laeuft die Aufraeumaufgabe fuer
|
||||||
im_wirt=(nsenter --mount=/proc/1/ns/mnt --)
|
# abgelaufene Einbindungen im Webprozess und nicht im Ueberwachungsdienst.
|
||||||
command -v nsenter >/dev/null 2>&1 || im_wirt=()
|
umount -l "$1" || true
|
||||||
"${im_wirt[@]}" umount -l "$1" || true
|
rmdir "$1" 2>/dev/null || true
|
||||||
"${im_wirt[@]}" rmdir "$1" 2>/dev/null || true
|
|
||||||
;;
|
;;
|
||||||
|
|
||||||
apt-install)
|
apt-install)
|
||||||
@@ -296,9 +293,27 @@ case "$verb" in
|
|||||||
for package in "$@"; do
|
for package in "$@"; do
|
||||||
check_in_list "$package" "${PACKAGES_ALLOWED[@]}" || fail "Paket nicht erlaubt: $package"
|
check_in_list "$package" "${PACKAGES_ALLOWED[@]}" || fail "Paket nicht erlaubt: $package"
|
||||||
done
|
done
|
||||||
|
# apt laeuft ausserhalb des Sandkastens des aufrufenden Dienstes. Der wird
|
||||||
|
# sonst an dpkg und dessen Maintainer-Skripte weitervererbt, und dort
|
||||||
|
# scheitert etwas voellig Beilaeufiges: mit "ProtectKernelLogs=yes" gibt
|
||||||
|
# "dmesg" ein EPERM zurueck, ein Postinst-Skript bricht daran ab, und dpkg
|
||||||
|
# endet mit Code 1 -- die Installation schlaegt fehl, obwohl mit dem Paket
|
||||||
|
# alles in Ordnung ist. Gemessen und mit systemd-run reproduzierbar
|
||||||
|
# behoben: PID 1 startet den Prozess, also ohne unsere Haertung.
|
||||||
export DEBIAN_FRONTEND=noninteractive
|
export DEBIAN_FRONTEND=noninteractive
|
||||||
|
if command -v systemd-run >/dev/null 2>&1 && [[ -S /run/dbus/system_bus_socket ]]; then
|
||||||
|
# Zwei getrennte Aufrufe, damit keine Shell dazwischen steht: die
|
||||||
|
# Paketnamen gehen als eigene Argumente an apt-get, nicht durch eine
|
||||||
|
# Kommandozeile, die sie erst wieder zerlegen muesste.
|
||||||
|
ausserhalb=(systemd-run --wait --pipe --collect --quiet
|
||||||
|
--setenv=DEBIAN_FRONTEND=noninteractive)
|
||||||
|
"${ausserhalb[@]}" apt-get update -qq
|
||||||
|
"${ausserhalb[@]}" apt-get install -y -qq "$@"
|
||||||
|
else
|
||||||
|
# Kein systemd (Container, Entwicklungsrechner): direkt, wie bisher.
|
||||||
apt-get update -qq
|
apt-get update -qq
|
||||||
apt-get install -y -qq "$@"
|
apt-get install -y -qq "$@"
|
||||||
|
fi
|
||||||
;;
|
;;
|
||||||
|
|
||||||
certbot-issue)
|
certbot-issue)
|
||||||
|
|||||||
@@ -0,0 +1,188 @@
|
|||||||
|
#!/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 Anwendung
|
||||||
|
#: (``apps/tesm/src/tesm/__init__.py``: ``AUTH_TAGLINE``) -- ein Hygienetest
|
||||||
|
#: haelt beides zusammen.
|
||||||
|
#:
|
||||||
|
#: Der Lizenzserver hat bewusst keine: die Funktionsliste von TESM waere dort
|
||||||
|
#: falsch, und eine erfundene waere schlimmer als keine.
|
||||||
|
TAGLINE = {
|
||||||
|
"tesm": "UEBERWACHEN · BOOTEN · ANBINDEN (DHCP) · BETRIEBSSYSTEM GEBEN (PXE)",
|
||||||
|
"tesm-license": "",
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
+41
-8
@@ -12,17 +12,47 @@
|
|||||||
# =============================================================================
|
# =============================================================================
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
|
# Ein Wagenruecklauf im Paket macht jedes Shell-Skript auf dem Ziel
|
||||||
|
# unbrauchbar ("bad interpreter"). Die Pruefung darf dabei nicht selbst
|
||||||
|
# von einem Zusatzpaket abhaengen: mit "file" -- das auf einem schlanken
|
||||||
|
# Server fehlt -- endete sie in "command not found", grep bekam nichts zu
|
||||||
|
# sehen, und das Skript machte weiter, als waere alles in Ordnung.
|
||||||
|
hat_crlf() {
|
||||||
|
local cr
|
||||||
|
cr="$(printf '\r')"
|
||||||
|
# -U (binaer lesen) ist unter Linux ohne Wirkung, auf einer Windows-Buildmaschine
|
||||||
|
# aber entscheidend: dort entfernt grep den Wagenruecklauf beim Lesen und
|
||||||
|
# findet deshalb nie einen.
|
||||||
|
LC_ALL=C grep -qU "$cr" "$1"
|
||||||
|
}
|
||||||
|
|
||||||
APP="${1:-}"
|
APP="${1:-}"
|
||||||
TAG="${2:-latest}"
|
TAG="${2:-latest}"
|
||||||
BASE="${TESM_RELEASE_BASE:-https://gitea.example.invalid}"
|
|
||||||
OWNER="${TESM_RELEASE_OWNER:-wis}"
|
# Alles nach dem Tag geht unveraendert an install.sh weiter. Ohne das liesse
|
||||||
REPO="${TESM_RELEASE_REPO:-tesm}"
|
# sich eine benannte Instanz nicht aktualisieren: install.sh leitet den Namen
|
||||||
|
# aus "--instance" ab, und ohne den Schalter entstuende neben "tesm-opus" eine
|
||||||
|
# zweite Installation namens "tesm" -- mit eigener Datenbank, eigener
|
||||||
|
# nginx-Site und demselben Port.
|
||||||
|
shift $(( $# > 2 ? 2 : $# ))
|
||||||
|
EXTRA=("$@")
|
||||||
|
# Bezugsquelle. Die Vorgaben zeigen dorthin, wo die Releases tatsaechlich
|
||||||
|
# liegen -- ein Platzhalter wie "gitea.example.invalid" macht das Skript auf
|
||||||
|
# jedem Host unbrauchbar, und genau das war hier der Fall.
|
||||||
|
#
|
||||||
|
# Das Repository heisst wie die Anwendung; getrennte Repositorien, getrennte
|
||||||
|
# Releases. Der Anhang heisst "<repo>-<tag>.tar.gz", siehe unten.
|
||||||
|
BASE="${TESM_RELEASE_BASE:-https://gitea.int.eertmoed.net}"
|
||||||
|
OWNER="${TESM_RELEASE_OWNER:-alientim}"
|
||||||
|
|
||||||
[[ "$APP" == "tesm" || "$APP" == "tesm-license" ]] || {
|
[[ "$APP" == "tesm" || "$APP" == "tesm-license" ]] || {
|
||||||
echo "Verwendung: update.sh <tesm|tesm-license> [tag]" >&2
|
echo "Verwendung: update.sh <tesm|tesm-license> [tag] [install.sh-Argumente ...]" >&2
|
||||||
|
echo "Beispiel : update.sh tesm latest --instance opus" >&2
|
||||||
exit 2
|
exit 2
|
||||||
}
|
}
|
||||||
|
|
||||||
|
REPO="${TESM_RELEASE_REPO:-$APP}"
|
||||||
|
|
||||||
WORK="$(mktemp -d /tmp/tesm-update-XXXXXX)"
|
WORK="$(mktemp -d /tmp/tesm-update-XXXXXX)"
|
||||||
trap 'rm -rf "$WORK"' EXIT # anders als im Vorgaenger wird aufgeraeumt
|
trap 'rm -rf "$WORK"' EXIT # anders als im Vorgaenger wird aufgeraeumt
|
||||||
|
|
||||||
@@ -39,18 +69,21 @@ curl -fsSL -o "$WORK/$ASSET" "$BASE/$OWNER/$REPO/releases/download/$TAG/$ASSET"
|
|||||||
|
|
||||||
echo "==> Entpacke ..."
|
echo "==> Entpacke ..."
|
||||||
tar -xzf "$WORK/$ASSET" -C "$WORK"
|
tar -xzf "$WORK/$ASSET" -C "$WORK"
|
||||||
SRC="$(find "$WORK" -maxdepth 2 -name install.sh -path '*/deploy/*' -printf '%h/..\n' | head -1)"
|
# Drei Ebenen, nicht zwei: release.sh packt mit dem Praefix "<name>/", also
|
||||||
|
# liegt das Skript unter <name>/deploy/install.sh. Mit "-maxdepth 2" fand die
|
||||||
|
# Suche nie etwas -- und die Fehlermeldung schob es auf den Download.
|
||||||
|
SRC="$(find "$WORK" -maxdepth 3 -name install.sh -path '*/deploy/*' -printf '%h/..\n' | head -1)"
|
||||||
[[ -n "$SRC" ]] || { echo "Im Paket fehlt deploy/install.sh -- Download unbrauchbar." >&2; exit 1; }
|
[[ -n "$SRC" ]] || { echo "Im Paket fehlt deploy/install.sh -- Download unbrauchbar." >&2; exit 1; }
|
||||||
SRC="$(cd "$SRC" && pwd)"
|
SRC="$(cd "$SRC" && pwd)"
|
||||||
|
|
||||||
echo "==> Pruefe auf CRLF (Windows-Buildmaschinen) ..."
|
echo "==> Pruefe auf CRLF (Windows-Buildmaschinen) ..."
|
||||||
if file "$SRC/deploy/install.sh" | grep -q CRLF; then
|
if hat_crlf "$SRC/deploy/install.sh"; then
|
||||||
echo "Das Paket enthaelt CRLF-Zeilenenden. Beim Paketieren fehlte" >&2
|
echo "Das Paket enthaelt CRLF-Zeilenenden. Beim Paketieren fehlte" >&2
|
||||||
echo "'git -c core.autocrlf=false archive'." >&2
|
echo "'git -c core.autocrlf=false archive'." >&2
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
read -r -p "Version $TAG fuer $APP installieren? [j/N] " answer
|
read -r -p "Version $TAG fuer $APP${EXTRA[0]+ (${EXTRA[*]})} installieren? [j/N] " answer
|
||||||
[[ "$answer" =~ ^[jJyY]$ ]] || exit 0
|
[[ "$answer" =~ ^[jJyY]$ ]] || exit 0
|
||||||
|
|
||||||
exec sudo bash "$SRC/deploy/install.sh" --app "$APP" --yes
|
exec sudo bash "$SRC/deploy/install.sh" --app "$APP" --yes ${EXTRA[0]+"${EXTRA[@]}"}
|
||||||
|
|||||||
@@ -0,0 +1,288 @@
|
|||||||
|
#!/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
|
||||||
|
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"
|
||||||
|
|
||||||
|
[[ -n "$SOURCE" ]] || SOURCE="/srv/$APP"
|
||||||
|
[[ -d "$SOURCE" ]] || fail "Die alte Installation $SOURCE gibt es nicht (--source setzen)"
|
||||||
|
[[ -f "$SOURCE/sqlite.db" ]] || fail "In $SOURCE liegt keine sqlite.db -- das ist keine Installation der Vorgaengerfassung"
|
||||||
|
|
||||||
|
NAME="$APP"
|
||||||
|
[[ -n "$INSTANCE" ]] && NAME="$APP-$INSTANCE"
|
||||||
|
ADMIN="$NAME-admin"
|
||||||
|
NEW_ROOT="/srv/$NAME"
|
||||||
|
IN_PLACE=0
|
||||||
|
[[ "$(cd "$SOURCE" && pwd)" == "$NEW_ROOT" ]] && IN_PLACE=1
|
||||||
|
|
||||||
|
STAMP="$(date +%Y%m%d-%H%M%S)"
|
||||||
|
BACKUP="/srv/$NAME-upgrade-$STAMP"
|
||||||
|
ALT_KOPIE="$BACKUP/alt"
|
||||||
|
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 "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
|
||||||
+238
-65
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
Dieses Dokument richtet sich an die Person, die die Anlage installiert,
|
Dieses Dokument richtet sich an die Person, die die Anlage installiert,
|
||||||
aktualisiert und im Fehlerfall wieder zum Laufen bringt. Es beschreibt, was auf
|
aktualisiert und im Fehlerfall wieder zum Laufen bringt. Es beschreibt, was auf
|
||||||
dem Host tatsaechlich passiert -- nicht, wie die Oberflaeche bedient wird.
|
dem Host tatsächlich passiert -- nicht, wie die Oberfläche bedient wird.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -11,7 +11,7 @@ dem Host tatsaechlich passiert -- nicht, wie die Oberflaeche bedient wird.
|
|||||||
| Zweck | Pfad |
|
| Zweck | Pfad |
|
||||||
|---|---|
|
|---|---|
|
||||||
| Anwendung (Code, venv, statische Dateien) | `/srv/<name>/` |
|
| Anwendung (Code, venv, statische Dateien) | `/srv/<name>/` |
|
||||||
| Instanzdaten (Datenbank, Schluessel, Lizenz) | `/srv/<name>/data/` |
|
| Instanzdaten (Datenbank, Schlüssel, Lizenz) | `/srv/<name>/data/` |
|
||||||
| Protokolle | `/var/log/<name>/` |
|
| Protokolle | `/var/log/<name>/` |
|
||||||
| Umgebungsdatei | `/etc/tesm/<name>.env` |
|
| Umgebungsdatei | `/etc/tesm/<name>.env` |
|
||||||
| Zertifikate (selbstsigniert) | `/etc/tesm/certs/<name>/` |
|
| Zertifikate (selbstsigniert) | `/etc/tesm/certs/<name>/` |
|
||||||
@@ -20,13 +20,13 @@ dem Host tatsaechlich passiert -- nicht, wie die Oberflaeche bedient wird.
|
|||||||
| Privilegierter Helfer | `/usr/local/lib/tesm/tesm-helper` |
|
| Privilegierter Helfer | `/usr/local/lib/tesm/tesm-helper` |
|
||||||
| Verwaltungswerkzeug | `/usr/local/bin/<name>-admin` |
|
| Verwaltungswerkzeug | `/usr/local/bin/<name>-admin` |
|
||||||
|
|
||||||
`<name>` ist `tesm` bzw. `tesm-license` -- oder, bei einer zusaetzlichen
|
`<name>` ist `tesm` bzw. `tesm-license` -- oder, bei einer zusätzlichen
|
||||||
Instanz, `tesm-<instanz>`. Dieser Name (der **SITE_KEY**) benennt alles, was
|
Instanz, `tesm-<instanz>`. Dieser Name (der **SITE_KEY**) benennt alles, was
|
||||||
ausserhalb von `/srv/<name>` liegt. Deshalb koennen zwei Installationen
|
ausserhalb von `/srv/<name>` liegt. Deshalb können zwei Installationen
|
||||||
derselben Anwendung nebeneinander laufen, ohne sich gegenseitig die
|
derselben Anwendung nebeneinander laufen, ohne sich gegenseitig die
|
||||||
nginx-Site, die netplan-Datei oder das Sitzungscookie zu ueberschreiben.
|
nginx-Site, die netplan-Datei oder das Sitzungscookie zu überschreiben.
|
||||||
|
|
||||||
Alles, was ein Mensch verlieren wuerde, liegt unter `data/`. Das Verzeichnis
|
Alles, was ein Mensch verlieren würde, liegt unter `data/`. Das Verzeichnis
|
||||||
wird von `install.sh` nie angefasst.
|
wird von `install.sh` nie angefasst.
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -43,10 +43,10 @@ Wichtige Optionen:
|
|||||||
| Option | Wirkung |
|
| Option | Wirkung |
|
||||||
|---|---|
|
|---|---|
|
||||||
| `--https` | Port 443 mit TLS, Port 80 leitet dorthin um. Ohne `--domain` entsteht ein selbstsigniertes Zertifikat. |
|
| `--https` | Port 443 mit TLS, Port 80 leitet dorthin um. Ohne `--domain` entsteht ein selbstsigniertes Zertifikat. |
|
||||||
| `--domain <name>` | Oeffentlicher Name. Setzt `server_name`; die Site ist dann **nicht** mehr die Vorgabe-Site. |
|
| `--domain <name>` | Öffentlicher Name. Setzt `server_name`; die Site ist dann **nicht** mehr die Vorgabe-Site. |
|
||||||
| `--acme-email <a>` | Erst zusammen mit `--domain` wird ein Let's-Encrypt-Zertifikat angefordert (und HSTS gesetzt). Ohne die Adresse bleibt es selbstsigniert -- interne Namen wie `lizenz.firma.local` kann keine CA bestaetigen. |
|
| `--acme-email <a>` | Erst zusammen mit `--domain` wird ein Let's-Encrypt-Zertifikat angefordert (und HSTS gesetzt). Ohne die Adresse bleibt es selbstsigniert -- interne Namen wie `lizenz.firma.local` kann keine CA bestätigen. |
|
||||||
| `--instance <name>` | Zusaetzliche Instanz neben der Hauptinstallation. |
|
| `--instance <name>` | Zusätzliche Instanz neben der Hauptinstallation. |
|
||||||
| `--port` / `--http-port` / `--https-port` | Abweichende Ports (nur fuer Testinstanzen sinnvoll). |
|
| `--port` / `--http-port` / `--https-port` | Abweichende Ports (nur für Testinstanzen sinnvoll). |
|
||||||
| `--skip-packages` | Keine Systempakete installieren. |
|
| `--skip-packages` | Keine Systempakete installieren. |
|
||||||
|
|
||||||
Danach:
|
Danach:
|
||||||
@@ -55,17 +55,17 @@ Danach:
|
|||||||
sudo -u tesm tesm-admin create-admin
|
sudo -u tesm tesm-admin create-admin
|
||||||
```
|
```
|
||||||
|
|
||||||
Beim Lizenzserver zusaetzlich, **in dieser Reihenfolge**:
|
Beim Lizenzserver zusätzlich, **in dieser Reihenfolge**:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
sudo -u tesm-license tesm-license-admin init-key # Signaturschluessel -- sofort sichern!
|
sudo -u tesm-license tesm-license-admin init-key # Signaturschlüssel -- sofort sichern!
|
||||||
sudo -u tesm-license tesm-license-admin bootstrap # eigene Master-Lizenz
|
sudo -u tesm-license tesm-license-admin bootstrap # eigene Master-Lizenz
|
||||||
sudo -u tesm-license tesm-license-admin create-admin
|
sudo -u tesm-license tesm-license-admin create-admin
|
||||||
```
|
```
|
||||||
|
|
||||||
> **Der Signaturschluessel ist nicht wiederherstellbar.** Geht
|
> **Der Signaturschlüssel ist nicht wiederherstellbar.** Geht
|
||||||
> `data/master_signing_key.json` verloren, ist keine ausgestellte Lizenz mehr
|
> `data/master_signing_key.json` verloren, ist keine ausgestellte Lizenz mehr
|
||||||
> pruefbar. Sichern Sie ihn getrennt vom Server.
|
> prüfbar. Sichern Sie ihn getrennt vom Server.
|
||||||
|
|
||||||
### Zwei Anwendungen auf einem Host
|
### Zwei Anwendungen auf einem Host
|
||||||
|
|
||||||
@@ -78,7 +78,7 @@ sudo bash deploy/install.sh --app tesm-license --https --domain lizenz.firma.loc
|
|||||||
|
|
||||||
Die Installation **ohne** Domain wird zur Vorgabe-Site (`default_server`) und
|
Die Installation **ohne** Domain wird zur Vorgabe-Site (`default_server`) und
|
||||||
beantwortet alles, was zu keinem Namen passt. Zwei namenlose Sites auf
|
beantwortet alles, was zu keinem Namen passt. Zwei namenlose Sites auf
|
||||||
demselben Port weist nginx ab -- richtig so, und beim Uebernehmen sofort
|
demselben Port weist nginx ab -- richtig so, und beim Übernehmen sofort
|
||||||
sichtbar.
|
sichtbar.
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -90,19 +90,115 @@ sudo bash deploy/install.sh --app tesm
|
|||||||
```
|
```
|
||||||
|
|
||||||
Derselbe Befehl. Das Skript erkennt an der vorhandenen Datenbank, dass es eine
|
Derselbe Befehl. Das Skript erkennt an der vorhandenen Datenbank, dass es eine
|
||||||
Aktualisierung ist, und tut dann zusaetzlich:
|
Aktualisierung ist, und tut dann zusätzlich:
|
||||||
|
|
||||||
1. **Vollstaendige Sicherung** nach `/srv/<name>-backup-<zeitstempel>` --
|
1. **Vollständige Sicherung** nach `/srv/<name>-backup-<zeitstempel>` --
|
||||||
unabhaengig davon, ob sich das Schema geaendert hat.
|
unabhängig davon, ob sich das Schema geändert hat.
|
||||||
2. Dateiabgleich; `data/` bleibt unangetastet.
|
2. Dateiabgleich; `data/` bleibt unangetastet.
|
||||||
3. Schema-Migrationen beim Start (versioniert, mit Pruefsumme).
|
3. Schema-Migrationen beim Start (versioniert, mit Prüfsumme).
|
||||||
4. **Gesundheitspruefung.** Antwortet die Anwendung nicht, wird der alte Stand
|
4. **Gesundheitsprüfung.** Antwortet die Anwendung nicht, wird der alte Stand
|
||||||
automatisch zurueckgeholt; die fehlgeschlagene Version bleibt unter
|
automatisch zurückgeholt; die fehlgeschlagene Version bleibt unter
|
||||||
`/srv/<name>-failed-<zeitstempel>` zur Analyse liegen.
|
`/srv/<name>-failed-<zeitstempel>` zur Analyse liegen.
|
||||||
|
|
||||||
Eine bereits eingerichtete HTTPS-Konfiguration bleibt erhalten, auch ohne
|
Eine bereits eingerichtete HTTPS-Konfiguration bleibt erhalten, auch ohne
|
||||||
`--https`.
|
`--https`.
|
||||||
|
|
||||||
|
### Ohne Quellbaum: `update.sh`
|
||||||
|
|
||||||
|
Liegt kein entpacktes Release vor, holt `update.sh` es selbst -- es lädt das
|
||||||
|
Release-Asset, entpackt es und ruft dessen `install.sh` auf:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo bash /opt/tesm/deploy/update.sh tesm
|
||||||
|
sudo bash /opt/tesm-license/deploy/update.sh tesm-license
|
||||||
|
```
|
||||||
|
|
||||||
|
Alles nach dem Tag geht unverändert an `install.sh` weiter -- das ist der Weg,
|
||||||
|
eine **benannte Instanz** zu aktualisieren:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo bash /opt/tesm-opus/deploy/update.sh tesm latest --instance opus
|
||||||
|
```
|
||||||
|
|
||||||
|
Ohne `--instance` entstünde daneben eine zweite Installation namens `tesm`, mit
|
||||||
|
eigener Datenbank und eigener nginx-Site. Die Ports einer bestehenden
|
||||||
|
Installation bleiben erhalten, auch wenn sie von den Vorgaben abweichen: eine
|
||||||
|
Aktualisierung verschiebt nichts, was nicht ausdrücklich neu gesetzt wird.
|
||||||
|
|
||||||
|
Ohne Tag wird `latest` **über die API aufgelöst**, nicht in die URL geschrieben:
|
||||||
|
Gitea liefert unter einem Tag-Namen sonst ein automatisch erzeugtes
|
||||||
|
Quell-Archiv mit HTTP 200 statt des echten Anhangs -- und das enthält kein
|
||||||
|
Release, sondern den Repositoriumsstand.
|
||||||
|
|
||||||
|
Die Bezugsquelle ist voreingestellt und lässt sich über die Umgebung umlenken,
|
||||||
|
etwa auf eine Spiegelung:
|
||||||
|
|
||||||
|
| Variable | Vorgabe |
|
||||||
|
|---|---|
|
||||||
|
| `TESM_RELEASE_BASE` | `https://gitea.int.eertmoed.net` |
|
||||||
|
| `TESM_RELEASE_OWNER` | `alientim` |
|
||||||
|
| `TESM_RELEASE_REPO` | der Anwendungsname (`tesm` bzw. `tesm-license`) |
|
||||||
|
|
||||||
|
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
|
||||||
|
```
|
||||||
|
|
||||||
|
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
|
||||||
@@ -114,7 +210,7 @@ Drei Wege dorthin:
|
|||||||
|
|
||||||
**a) Bei der Installation** -- siehe oben, `--https`.
|
**a) Bei der Installation** -- siehe oben, `--https`.
|
||||||
|
|
||||||
**b) Nachtraeglich ueber die Kommandozeile:**
|
**b) Nachträglich über die Kommandozeile:**
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
sudo -u tesm tesm-admin web-setup --https --self-signed --apply
|
sudo -u tesm tesm-admin web-setup --https --self-signed --apply
|
||||||
@@ -124,42 +220,119 @@ sudo -u tesm tesm-admin web-setup --https --domain tesm.firma.de \
|
|||||||
|
|
||||||
`--print-config` zeigt die Datei, ohne etwas zu schreiben.
|
`--print-config` zeigt die Datei, ohne etwas zu schreiben.
|
||||||
|
|
||||||
**c) In der Oberflaeche** unter *Einstellungen -> Webserver und TLS*.
|
**c) In der Oberfläche** unter *Einstellungen -> Webserver und TLS*.
|
||||||
|
|
||||||
Danach muss `COOKIE_SECURE` in `/etc/tesm/<name>.env` auf `1` stehen (alle drei
|
Danach muss `COOKIE_SECURE` in `/etc/tesm/<name>.env` auf `1` stehen (alle drei
|
||||||
Wege erledigen das selbst) und der Dienst neu gestartet werden.
|
Wege erledigen das selbst) und der Dienst neu gestartet werden.
|
||||||
|
|
||||||
### Die Site-Datei wird immer neu erzeugt
|
### Die Site-Datei wird immer neu erzeugt
|
||||||
|
|
||||||
Sie ist **kein** Ort fuer Handarbeit. Jede Uebernahme schreibt sie vollstaendig
|
Sie ist **kein** Ort für Handarbeit. Jede Übernahme schreibt sie vollständig
|
||||||
aus der Vorlage neu. Was frueher von Hand hineingeschrieben wurde -- Domain,
|
aus der Vorlage neu. Was früher von Hand hineingeschrieben wurde -- Domain,
|
||||||
Ports, Zertifikatspfade, HSTS, Upload-Grenze -- ist heute Einstellung in der
|
Ports, Zertifikatspfade, HSTS, Upload-Grenze -- ist heute Einstellung in der
|
||||||
Anwendung.
|
Anwendung.
|
||||||
|
|
||||||
Der Grund steht im Vorgaengerprojekt: dort wurde die Datei nur angelegt, wenn
|
Der Grund steht im Vorgängerprojekt: dort wurde die Datei nur angelegt, wenn
|
||||||
sie fehlte. Ein einmal falscher `alias`-Pfad ueberlebte dadurch jahrelang jedes
|
sie fehlte. Ein einmal falscher `alias`-Pfad überlebte dadurch jahrelang jedes
|
||||||
Update, und die Oberflaeche kam die ganze Zeit ohne Design.
|
Update, und die Oberfläche kam die ganze Zeit ohne Design.
|
||||||
|
|
||||||
### Selbstsigniert oder Let's Encrypt?
|
### Selbstsigniert oder Let's Encrypt?
|
||||||
|
|
||||||
* **Oeffentlich erreichbar, echter DNS-Name:** Let's Encrypt. Nur dann ist HSTS
|
* **Öffentlich erreichbar, echter DNS-Name:** Let's Encrypt. Nur dann ist HSTS
|
||||||
sinnvoll.
|
sinnvoll.
|
||||||
* **Geschlossenes Netz:** selbstsigniert. Browser zeigen eine Warnung, die
|
* **Geschlossenes Netz:** selbstsigniert. Browser zeigen eine Warnung, die
|
||||||
Verbindung ist trotzdem verschluesselt und das Sitzungscookie darf `Secure`
|
Verbindung ist trotzdem verschlüsselt und das Sitzungscookie darf `Secure`
|
||||||
tragen. **HSTS hier nicht einschalten** -- der Browser merkt sich die Vorgabe
|
tragen. **HSTS hier nicht einschalten** -- der Browser merkt sich die Vorgabe
|
||||||
monatelang und die Warnung laesst sich dann nicht mehr wegklicken.
|
monatelang und die Warnung lässt sich dann nicht mehr wegklicken.
|
||||||
|
|
||||||
Die Erneuerung von Let's Encrypt laeuft ueber `certbot certonly --webroot`
|
Die Erneuerung von Let's Encrypt läuft über `certbot certonly --webroot`
|
||||||
(bewusst nicht ueber das nginx-Plugin, das die erzeugte Site umschreiben
|
(bewusst nicht über das nginx-Plugin, das die erzeugte Site umschreiben
|
||||||
wuerde). Der ACME-Pfad bleibt auch bei aktiver Umleitung erreichbar.
|
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
|
||||||
systemctl status tesm.service # Weboberflaeche (gunicorn)
|
systemctl status tesm.service # Weboberfläche (gunicorn)
|
||||||
systemctl status tesm-monitor.service # Ueberwachungsschleife (nur TESM)
|
systemctl status tesm-monitor.service # Überwachungsschleife (nur TESM)
|
||||||
systemctl status tesm-license.service
|
systemctl status tesm-license.service
|
||||||
journalctl -u tesm -n 100 -f
|
journalctl -u tesm -n 100 -f
|
||||||
```
|
```
|
||||||
@@ -179,8 +352,8 @@ Die Anwendung hat keine. Alles, was Root braucht, geht durch **einen** Pfad:
|
|||||||
/usr/local/lib/tesm/tesm-helper <verb> [argumente]
|
/usr/local/lib/tesm/tesm-helper <verb> [argumente]
|
||||||
```
|
```
|
||||||
|
|
||||||
Der Helfer gehoert `root`, ist per sudoers ausschliesslich fuer genau diesen
|
Der Helfer gehört `root`, ist per sudoers ausschliesslich für genau diesen
|
||||||
Pfad mit `NOPASSWD` freigegeben und prueft jedes Argument gegen eine
|
Pfad mit `NOPASSWD` freigegeben und prüft jedes Argument gegen eine
|
||||||
Positivliste. Er reicht nie etwas an eine Shell weiter (kein `eval`, kein
|
Positivliste. Er reicht nie etwas an eine Shell weiter (kein `eval`, kein
|
||||||
`sh -c`).
|
`sh -c`).
|
||||||
|
|
||||||
@@ -191,16 +364,16 @@ sudo /usr/local/lib/tesm/tesm-helper
|
|||||||
grep -n ')$' /usr/local/lib/tesm/tesm-helper
|
grep -n ')$' /usr/local/lib/tesm/tesm-helper
|
||||||
```
|
```
|
||||||
|
|
||||||
Regel fuer Aenderungen: **Jedes neue Verb braucht eine Argumentpruefung.**
|
Regel für Änderungen: **Jedes neue Verb braucht eine Argumentprüfung.**
|
||||||
`tests/test_repo_hygiene.py` erzwingt, dass jedes der Anwendung bekannte Verb
|
`tests/test_repo_hygiene.py` erzwingt, dass jedes der Anwendung bekannte Verb
|
||||||
im Helfer vorkommt und dass jede Pfadpruefung `..` abweist.
|
im Helfer vorkommt und dass jede Pfadprüfung `..` abweist.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 7. Sicherung und Wiederherstellung
|
## 7. Sicherung und Wiederherstellung
|
||||||
|
|
||||||
In der Oberflaeche unter *Sicherung* -- verschluesselte Ausfuhr (Argon2id +
|
In der Oberfläche unter *Sicherung* -- verschlüsselte Ausfuhr (Argon2id +
|
||||||
AES-256-GCM) mit einem selbst gewaehlten Kennwort. Der Import zeigt zuerst eine
|
AES-256-GCM) mit einem selbst gewählten Kennwort. Der Import zeigt zuerst eine
|
||||||
Vorschau, bevor irgendetwas geschrieben wird.
|
Vorschau, bevor irgendetwas geschrieben wird.
|
||||||
|
|
||||||
Auf dem Host reicht:
|
Auf dem Host reicht:
|
||||||
@@ -214,10 +387,10 @@ sudo systemctl start tesm
|
|||||||
Unverzichtbar:
|
Unverzichtbar:
|
||||||
|
|
||||||
* `data/app.db` -- alle Daten
|
* `data/app.db` -- alle Daten
|
||||||
* `data/secret.key` -- ohne sie sind alle Sitzungen ungueltig
|
* `data/secret.key` -- ohne sie sind alle Sitzungen ungültig
|
||||||
* `data/data.keys` -- ohne sie sind **alle gespeicherten Geheimnisse verloren**
|
* `data/data.keys` -- ohne sie sind **alle gespeicherten Geheimnisse verloren**
|
||||||
* `data/license.json` und `data/license_key.json`
|
* `data/license.json` und `data/license_key.json`
|
||||||
* Lizenzserver zusaetzlich: `data/master_signing_key.json`
|
* Lizenzserver zusätzlich: `data/master_signing_key.json`
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -230,17 +403,17 @@ curl -s http://127.0.0.1:5000/gesundheit
|
|||||||
```
|
```
|
||||||
Antwortet gunicorn, aber der Browser nicht, liegt es an nginx.
|
Antwortet gunicorn, aber der Browser nicht, liegt es an nginx.
|
||||||
|
|
||||||
**nginx laesst sich nicht neu laden.**
|
**nginx lässt sich nicht neu laden.**
|
||||||
```bash
|
```bash
|
||||||
sudo nginx -t
|
sudo nginx -t
|
||||||
```
|
```
|
||||||
Die Uebernahme aus der Anwendung nimmt eine abgelehnte Konfiguration selbst
|
Die Übernahme aus der Anwendung nimmt eine abgelehnte Konfiguration selbst
|
||||||
zurueck. Eine von Hand bearbeitete Datei tut das nicht -- in dem Fall hilft
|
zurück. Eine von Hand bearbeitete Datei tut das nicht -- in dem Fall hilft
|
||||||
`install.sh`, das eine unbrauchbare Site durch die Vorlage ersetzt.
|
`install.sh`, das eine unbrauchbare Site durch die Vorlage ersetzt.
|
||||||
|
|
||||||
**Anmeldung schlaegt ohne Fehlermeldung fehl.**
|
**Anmeldung schlägt ohne Fehlermeldung fehl.**
|
||||||
Fast immer `COOKIE_SECURE=1` ohne HTTPS. Der Browser bekommt dann ein Cookie,
|
Fast immer `COOKIE_SECURE=1` ohne HTTPS. Der Browser bekommt dann ein Cookie,
|
||||||
das er ueber HTTP nie zuruecksendet.
|
das er über HTTP nie zurücksendet.
|
||||||
```bash
|
```bash
|
||||||
grep COOKIE_SECURE /etc/tesm/tesm.env
|
grep COOKIE_SECURE /etc/tesm/tesm.env
|
||||||
```
|
```
|
||||||
@@ -250,38 +423,38 @@ Der Reverse Proxy reicht den Host falsch durch. In der Site-Datei muss
|
|||||||
`proxy_set_header Host $http_host;` stehen -- **nicht** `$host`, das verwirft
|
`proxy_set_header Host $http_host;` stehen -- **nicht** `$host`, das verwirft
|
||||||
den Port.
|
den Port.
|
||||||
|
|
||||||
**Statische Dateien fehlen, die Oberflaeche ist unformatiert.**
|
**Statische Dateien fehlen, die Oberfläche ist unformatiert.**
|
||||||
```bash
|
```bash
|
||||||
grep alias /etc/nginx/sites-available/tesm
|
grep alias /etc/nginx/sites-available/tesm
|
||||||
ls /srv/tesm/static /srv/tesm/static-core
|
ls /srv/tesm/static /srv/tesm/static-core
|
||||||
```
|
```
|
||||||
Beide `alias`-Pfade muessen in das Installationsverzeichnis zeigen.
|
Beide `alias`-Pfade müssen in das Installationsverzeichnis zeigen.
|
||||||
|
|
||||||
**Zwei Instanzen stoeren sich.**
|
**Zwei Instanzen stören sich.**
|
||||||
`SITE_KEY` in beiden `/etc/tesm/*.env` vergleichen -- er muss sich
|
`SITE_KEY` in beiden `/etc/tesm/*.env` vergleichen -- er muss sich
|
||||||
unterscheiden. Sonst teilen sich beide nginx-Site und Sitzungscookie.
|
unterscheiden. Sonst teilen sich beide nginx-Site und Sitzungscookie.
|
||||||
|
|
||||||
**Die Wartung meldet "Der SSH-Host-Schluessel ist unbekannt".**
|
**Die Wartung meldet "Der SSH-Host-Schlüssel ist unbekannt".**
|
||||||
Kein Fehler, sondern die Absicht: ein Hintergrundauftrag darf keinen fremden
|
Kein Fehler, sondern die Absicht: ein Hintergrundauftrag darf keinen fremden
|
||||||
Schluessel akzeptieren. Die Freigabe erfolgt von Hand, in zwei Schritten, auf
|
Schlüssel akzeptieren. Die Freigabe erfolgt von Hand, in zwei Schritten, auf
|
||||||
der Detailseite des Geraets (*Clients -> Geraet -> SSH-Host-Schluessel*):
|
der Detailseite des Geräts (*Clients -> Gerät -> SSH-Host-Schlüssel*):
|
||||||
erst **Auslesen**, dann den Fingerprint mit einer unabhaengigen Quelle
|
erst **Auslesen**, dann den Fingerprint mit einer unabhängigen Quelle
|
||||||
vergleichen, dann **Freigeben**. Die Wartungsuebersicht zeigt in einer eigenen
|
vergleichen, dann **Freigeben**. Die Wartungsübersicht zeigt in einer eigenen
|
||||||
Spalte, fuer welche Geraete das noch offen ist. Fuer Switche liegt dieselbe
|
Spalte, für welche Geräte das noch offen ist. Für Switche liegt dieselbe
|
||||||
Freigabe auf der Switch-Detailseite.
|
Freigabe auf der Switch-Detailseite.
|
||||||
|
|
||||||
Der Schluessel wird nach **IP und Port** gespeichert, nicht nach Name -- TESM
|
Der Schlüssel wird nach **IP und Port** gespeichert, nicht nach Name -- TESM
|
||||||
verbindet sich immer ueber die hinterlegte IP-Adresse. Aendert sich die IP
|
verbindet sich immer über die hinterlegte IP-Adresse. Ändert sich die IP
|
||||||
eines Geraets, ist der Schluessel erneut freizugeben.
|
eines Geräts, ist der Schlüssel erneut freizugeben.
|
||||||
|
|
||||||
**Die Pruefkette des Aenderungsprotokolls ist gebrochen.**
|
**Die Prüfkette des Änderungsprotokolls ist gebrochen.**
|
||||||
Unter *Diagnose* sichtbar. Das Protokoll ist verkettet gehasht; ein Bruch heisst,
|
Unter *Diagnose* sichtbar. Das Protokoll ist verkettet gehasht; ein Bruch heisst,
|
||||||
dass jemand direkt in die Datenbank geschrieben hat. Der Eintrag, ab dem es
|
dass jemand direkt in die Datenbank geschrieben hat. Der Eintrag, ab dem es
|
||||||
nicht mehr stimmt, wird genannt.
|
nicht mehr stimmt, wird genannt.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 9. Zurueck auf eine aeltere Version
|
## 9. Zurück auf eine ältere Version
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
sudo systemctl stop tesm
|
sudo systemctl stop tesm
|
||||||
@@ -290,6 +463,6 @@ sudo cp -a /srv/tesm-backup-<zeitstempel> /srv/tesm
|
|||||||
sudo systemctl start tesm
|
sudo systemctl start tesm
|
||||||
```
|
```
|
||||||
|
|
||||||
Schema-Migrationen laufen nur vorwaerts. Ein Ruecksprung ueber eine
|
Schema-Migrationen laufen nur vorwärts. Ein Rücksprung über eine
|
||||||
Schema-Aenderung hinweg braucht deshalb auch die Datenbank aus der Sicherung --
|
Schema-Änderung hinweg braucht deshalb auch die Datenbank aus der Sicherung --
|
||||||
genau das liegt im Backup-Verzeichnis.
|
genau das liegt im Backup-Verzeichnis.
|
||||||
|
|||||||
+56
-56
@@ -1,6 +1,6 @@
|
|||||||
# Sicherheit
|
# Sicherheit
|
||||||
|
|
||||||
Was geschuetzt wird, womit, und wo bewusst Grenzen gezogen sind.
|
Was geschützt wird, womit, und wo bewusst Grenzen gezogen sind.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -9,35 +9,35 @@ Was geschuetzt wird, womit, und wo bewusst Grenzen gezogen sind.
|
|||||||
| | |
|
| | |
|
||||||
|---|---|
|
|---|---|
|
||||||
| Speicherung | Argon2id (argon2-cffi) |
|
| Speicherung | Argon2id (argon2-cffi) |
|
||||||
| Altbestand | bcrypt wird noch geprueft und bei der naechsten erfolgreichen Anmeldung stillschweigend auf Argon2id umgestellt |
|
| Altbestand | bcrypt wird noch geprüft und bei der nächsten erfolgreichen Anmeldung stillschweigend auf Argon2id umgestellt |
|
||||||
| Zweiter Faktor | TOTP, optional erzwingbar fuer Administratoren |
|
| Zweiter Faktor | TOTP, optional erzwingbar für Administratoren |
|
||||||
| Sperre je Konto | nach `LOGIN_MAX_ATTEMPTS` Versuchen |
|
| Sperre je Konto | nach `LOGIN_MAX_ATTEMPTS` Versuchen |
|
||||||
| Sperre je Adresse | zusaetzlich `LOGIN_MAX_ATTEMPTS_PER_IP` -- sonst laesst sich die Kontosperre umgehen, indem man viele Konten je einmal probiert |
|
| Sperre je Adresse | zusätzlich `LOGIN_MAX_ATTEMPTS_PER_IP` -- sonst lässt sich die Kontosperre umgehen, indem man viele Konten je einmal probiert |
|
||||||
| Verzeichnisdienst | LDAP nur ueber TLS, Zertifikat wird geprueft. Ohne gueltiges Zertifikat keine Anmeldung -- kein Schalter, der das abschaltet |
|
| Verzeichnisdienst | LDAP nur über TLS, Zertifikat wird geprüft. Ohne gültiges Zertifikat keine Anmeldung -- kein Schalter, der das abschaltet |
|
||||||
|
|
||||||
Nach einer Anmeldung wird die Sitzungskennung neu vergeben (gegen Session
|
Nach einer Anmeldung wird die Sitzungskennung neu vergeben (gegen Session
|
||||||
Fixation) und das CSRF-Token gewechselt.
|
Fixation) und das CSRF-Token gewechselt.
|
||||||
|
|
||||||
### Erneute Bestaetigung ("sudo-Modus")
|
### Erneute Bestätigung ("sudo-Modus")
|
||||||
|
|
||||||
Alles, was Geheimnisse offenlegt oder Daten aus dem System heraustraegt,
|
Alles, was Geheimnisse offenlegt oder Daten aus dem System herausträgt,
|
||||||
verlangt eine frische Passworteingabe: Klartextanzeige von Zugangsdaten,
|
verlangt eine frische Passworteingabe: Klartextanzeige von Zugangsdaten,
|
||||||
Ausfuhr, Herunterladen eines Lizenzbundles, Schluesselrotation,
|
Ausfuhr, Herunterladen eines Lizenzbundles, Schlüsselrotation,
|
||||||
2FA-Aenderungen, Deaktivierung einer Lizenz.
|
2FA-Änderungen, Deaktivierung einer Lizenz.
|
||||||
|
|
||||||
Ein uebernommenes Sitzungscookie allein reicht dafuer nicht.
|
Ein übernommenes Sitzungscookie allein reicht dafür nicht.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 2. Sitzungen
|
## 2. Sitzungen
|
||||||
|
|
||||||
Serverseitig in SQLite, nicht im Cookie. Das Cookie traegt nur eine
|
Serverseitig in SQLite, nicht im Cookie. Das Cookie trägt nur eine
|
||||||
Zufallskennung.
|
Zufallskennung.
|
||||||
|
|
||||||
* `HttpOnly`, `SameSite=Lax`, `Secure` sobald HTTPS eingerichtet ist
|
* `HttpOnly`, `SameSite=Lax`, `Secure` sobald HTTPS eingerichtet ist
|
||||||
* Leerlaufzeit **und** absolute Hoechstdauer
|
* Leerlaufzeit **und** absolute Höchstdauer
|
||||||
* Der Cookiename enthaelt den `SITE_KEY` -- zwei Installationen auf einem Host
|
* Der Cookiename enthält den `SITE_KEY` -- zwei Installationen auf einem Host
|
||||||
koennen sich die Sitzung nicht gegenseitig ueberschreiben (Cookies
|
können sich die Sitzung nicht gegenseitig überschreiben (Cookies
|
||||||
unterscheiden keine Ports)
|
unterscheiden keine Ports)
|
||||||
* Aktive Sitzungen sind einsehbar und einzeln beendbar
|
* Aktive Sitzungen sind einsehbar und einzeln beendbar
|
||||||
|
|
||||||
@@ -45,22 +45,22 @@ Zufallskennung.
|
|||||||
|
|
||||||
## 3. CSRF
|
## 3. CSRF
|
||||||
|
|
||||||
Drei voneinander unabhaengige Schichten:
|
Drei voneinander unabhängige Schichten:
|
||||||
|
|
||||||
1. `SameSite=Lax` am Sitzungscookie.
|
1. `SameSite=Lax` am Sitzungscookie.
|
||||||
2. Ein Token je Sitzung, in jedem Formular, in konstanter Zeit verglichen.
|
2. Ein Token je Sitzung, in jedem Formular, in konstanter Zeit verglichen.
|
||||||
3. Origin-/Referer-Pruefung gegen den Host der Anfrage.
|
3. Origin-/Referer-Prüfung gegen den Host der Anfrage.
|
||||||
|
|
||||||
Die dritte Schicht akzeptiert den Host mit und ohne Port sowie die
|
Die dritte Schicht akzeptiert den Host mit und ohne Port sowie die
|
||||||
Standardports -- **nicht** aber beliebige Ports: ein anderer Dienst auf
|
Standardports -- **nicht** aber beliebige Ports: ein anderer Dienst auf
|
||||||
demselben Rechner ist ein anderer Ursprung.
|
demselben Rechner ist ein anderer Ursprung.
|
||||||
|
|
||||||
Der Vorgaenger hatte gar keinen CSRF-Schutz. Eine praeparierte Seite konnte im
|
Der Vorgänger hatte gar keinen CSRF-Schutz. Eine präparierte Seite konnte im
|
||||||
Namen eines angemeldeten Administrators Geraete loeschen oder Benutzer anlegen.
|
Namen eines angemeldeten Administrators Geräte löschen oder Benutzer anlegen.
|
||||||
|
|
||||||
Ausnahmen (`@csrf.exempt`) gibt es nur fuer Maschinenschnittstellen, die sich
|
Ausnahmen (`@csrf.exempt`) gibt es nur für Maschinenschnittstellen, die sich
|
||||||
**nicht** ueber Cookies autorisieren. Eine cookie-autorisierte Route ohne
|
**nicht** über Cookies autorisieren. Eine cookie-autorisierte Route ohne
|
||||||
CSRF-Schutz waere eine Luecke.
|
CSRF-Schutz wäre eine Lücke.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -69,75 +69,75 @@ CSRF-Schutz waere eine Luecke.
|
|||||||
Bei jeder Antwort:
|
Bei jeder Antwort:
|
||||||
|
|
||||||
* `Content-Security-Policy` mit einem Nonce je Anfrage. **Kein**
|
* `Content-Security-Policy` mit einem Nonce je Anfrage. **Kein**
|
||||||
`unsafe-inline` fuer Skripte -- deshalb gibt es im ganzen Projekt kein
|
`unsafe-inline` für Skripte -- deshalb gibt es im ganzen Projekt kein
|
||||||
Inline-Script ohne Nonce, und JavaScript haengt ueber
|
Inline-Script ohne Nonce, und JavaScript hängt über
|
||||||
`data-behavior`-Attribute am DOM. `style-src` erlaubt `unsafe-inline`, weil
|
`data-behavior`-Attribute am DOM. `style-src` erlaubt `unsafe-inline`, weil
|
||||||
einzelne berechnete Breiten (Fortschrittsbalken) als Style-Attribut gesetzt
|
einzelne berechnete Breiten (Fortschrittsbalken) als Style-Attribut gesetzt
|
||||||
werden; Style-Attribute fuehren keinen Code aus.
|
werden; Style-Attribute führen keinen Code aus.
|
||||||
* `X-Content-Type-Options: nosniff`
|
* `X-Content-Type-Options: nosniff`
|
||||||
* `X-Frame-Options: DENY`, `frame-ancestors 'none'`
|
* `X-Frame-Options: DENY`, `frame-ancestors 'none'`
|
||||||
* `Referrer-Policy: same-origin`
|
* `Referrer-Policy: same-origin`
|
||||||
* `Permissions-Policy` -- Kamera, Mikrofon, Ort, Zahlung, USB abgeschaltet
|
* `Permissions-Policy` -- Kamera, Mikrofon, Ort, Zahlung, USB abgeschaltet
|
||||||
* `Cross-Origin-Opener-Policy`, `Cross-Origin-Resource-Policy`
|
* `Cross-Origin-Opener-Policy`, `Cross-Origin-Resource-Policy`
|
||||||
* `Strict-Transport-Security` nur, wenn TLS tatsaechlich terminiert wird
|
* `Strict-Transport-Security` nur, wenn TLS tatsächlich terminiert wird
|
||||||
|
|
||||||
> HSTS gehoert **nicht** zu einem selbstsignierten Zertifikat. Der Browser
|
> HSTS gehört **nicht** zu einem selbstsignierten Zertifikat. Der Browser
|
||||||
> merkt sich die Vorgabe monatelang; die Warnung laesst sich danach nicht mehr
|
> merkt sich die Vorgabe monatelang; die Warnung lässt sich danach nicht mehr
|
||||||
> wegklicken. `install.sh` setzt HSTS deshalb nur zusammen mit Let's Encrypt.
|
> wegklicken. `install.sh` setzt HSTS deshalb nur zusammen mit Let's Encrypt.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 5. Geheimnisse im Ruhezustand
|
## 5. Geheimnisse im Ruhezustand
|
||||||
|
|
||||||
Ein Schluesselspeicher mit AES-256-GCM (`data/data.keys`, Modus 0600):
|
Ein Schlüsselspeicher mit AES-256-GCM (`data/data.keys`, Modus 0600):
|
||||||
|
|
||||||
* Versionierte Schluessel, rotierbar. Chiffrate tragen ihre Schluesselkennung
|
* Versionierte Schlüssel, rotierbar. Chiffrate tragen ihre Schlüsselkennung
|
||||||
(`v1.<id>.<nonce>.<ct>`) und bleiben nach einer Rotation lesbar.
|
(`v1.<id>.<nonce>.<ct>`) und bleiben nach einer Rotation lesbar.
|
||||||
* Der Verwendungszweck geht als AAD in die Verschluesselung ein. Ein Chiffrat
|
* Der Verwendungszweck geht als AAD in die Verschlüsselung ein. Ein Chiffrat
|
||||||
aus einem Kontext laesst sich in einem anderen nicht entschluesseln.
|
aus einem Kontext lässt sich in einem anderen nicht entschlüsseln.
|
||||||
* Beim Start wird geprueft, ob die Rechte auf Schluessel- und Lizenzdateien
|
* Beim Start wird geprüft, ob die Rechte auf Schlüssel- und Lizenzdateien
|
||||||
noch stimmen; Abweichungen erscheinen unter *Diagnose*.
|
noch stimmen; Abweichungen erscheinen unter *Diagnose*.
|
||||||
|
|
||||||
Passwoerter fuer Mounts gehen ueber `stdin` an den privilegierten Helfer, der
|
Passwörter für Mounts gehen über `stdin` an den privilegierten Helfer, der
|
||||||
sie in eine 0600-Datei schreibt -- nie als Kommandozeilenargument (Prozessliste)
|
sie in eine 0600-Datei schreibt -- nie als Kommandozeilenargument (Prozessliste)
|
||||||
und nie ueber die Umgebung (`/proc/<pid>/environ`).
|
und nie über die Umgebung (`/proc/<pid>/environ`).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 6. Rechtetrennung auf dem Host
|
## 6. Rechtetrennung auf dem Host
|
||||||
|
|
||||||
Der Webprozess laeuft als `tesm` bzw. `tesm-license`. Root-Aktionen gehen durch
|
Der Webprozess läuft als `tesm` bzw. `tesm-license`. Root-Aktionen gehen durch
|
||||||
genau einen allowlisted Helfer (`/usr/local/lib/tesm/tesm-helper`), der per
|
genau einen allowlisted Helfer (`/usr/local/lib/tesm/tesm-helper`), der per
|
||||||
sudoers ausschliesslich fuer diesen Pfad freigegeben ist.
|
sudoers ausschliesslich für diesen Pfad freigegeben ist.
|
||||||
|
|
||||||
Regeln, die im Repository durch Tests erzwungen werden:
|
Regeln, die im Repository durch Tests erzwungen werden:
|
||||||
|
|
||||||
* Jedes Verb, das die Anwendung kennt, existiert im Helfer.
|
* Jedes Verb, das die Anwendung kennt, existiert im Helfer.
|
||||||
* Kein `eval`, kein `bash -c`, kein `sh -c`.
|
* Kein `eval`, kein `bash -c`, kein `sh -c`.
|
||||||
* Jede Pfadpruefung weist `..` ab -- sonst laesst sich eine Positivliste ueber
|
* Jede Pfadprüfung weist `..` ab -- sonst lässt sich eine Positivliste über
|
||||||
den Basisnamen unterlaufen (`/etc/nginx/sites-available/../../../root/x` hat
|
den Basisnamen unterlaufen (`/etc/nginx/sites-available/../../../root/x` hat
|
||||||
den zulaessigen Basisnamen `x`).
|
den zulässigen Basisnamen `x`).
|
||||||
* Der Instanzname ist auf `tesm[-<instanz>]` begrenzt; er landet in Pfaden.
|
* Der Instanzname ist auf `tesm[-<instanz>]` begrenzt; er landet in Pfaden.
|
||||||
* Keine sudoers-Regel ausser der einen. `NOPASSWD: ALL` gibt es nicht.
|
* Keine sudoers-Regel ausser der einen. `NOPASSWD: ALL` gibt es nicht.
|
||||||
|
|
||||||
Die systemd-Units haben `NoNewPrivileges=yes`, `ProtectSystem=strict`,
|
Die systemd-Units haben `NoNewPrivileges=yes`, `ProtectSystem=strict`,
|
||||||
`PrivateTmp=yes` und eine enge `RestrictAddressFamilies`-Liste.
|
`PrivateTmp=yes` und eine enge `RestrictAddressFamilies`-Liste.
|
||||||
|
|
||||||
Der Vorgaenger liess die komplette Flask-Anwendung als root laufen. Eine
|
Der Vorgänger liess die komplette Flask-Anwendung als root laufen. Eine
|
||||||
einzige Luecke in irgendeinem Pfad haette den Host bedeutet.
|
einzige Lücke in irgendeinem Pfad hätte den Host bedeutet.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 7. Aenderungsprotokoll
|
## 7. Änderungsprotokoll
|
||||||
|
|
||||||
Verkettet gehasht: jeder Eintrag enthaelt den Hash seines Vorgaengers. Ein
|
Verkettet gehasht: jeder Eintrag enthält den Hash seines Vorgängers. Ein
|
||||||
nachtraegliches Aendern oder Loeschen faellt auf und wird unter *Diagnose*
|
nachträgliches Ändern oder Löschen fällt auf und wird unter *Diagnose*
|
||||||
angezeigt, mitsamt dem Eintrag, ab dem die Kette bricht.
|
angezeigt, mitsamt dem Eintrag, ab dem die Kette bricht.
|
||||||
|
|
||||||
Protokolliert wird, wer was wann von welcher Adresse getan hat -- ausdruecklich
|
Protokolliert wird, wer was wann von welcher Adresse getan hat -- ausdrücklich
|
||||||
auch Lesezugriffe auf Geheimnisse (`credential.secret_revealed`).
|
auch Lesezugriffe auf Geheimnisse (`credential.secret_revealed`).
|
||||||
|
|
||||||
Aeltere Tage wandern in JSONL-Archive und lassen sich als ZIP herunterladen.
|
Ältere Tage wandern in JSONL-Archive und lassen sich als ZIP herunterladen.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -145,20 +145,20 @@ Aeltere Tage wandern in JSONL-Archive und lassen sich als ZIP herunterladen.
|
|||||||
|
|
||||||
Siehe [ARCHITEKTUR.md](ARCHITEKTUR.md), Abschnitt Lizenzierung. Kurz:
|
Siehe [ARCHITEKTUR.md](ARCHITEKTUR.md), Abschnitt Lizenzierung. Kurz:
|
||||||
getrennte Signaturkontexte, kanonisches JSON, Nonce gegen Wiedereinspielung,
|
getrennte Signaturkontexte, kanonisches JSON, Nonce gegen Wiedereinspielung,
|
||||||
Frischefenster, `seq` gegen Rueckstufung, Antwort an die Anfrage gebunden,
|
Frischefenster, `seq` gegen Rückstufung, Antwort an die Anfrage gebunden,
|
||||||
Aussteller-Pinning.
|
Aussteller-Pinning.
|
||||||
|
|
||||||
Das Auslieferungsbundle enthaelt den privaten Clientschluessel und ist damit
|
Das Auslieferungsbundle enthält den privaten Clientschlüssel und ist damit
|
||||||
selbst ein Geheimnis: eigenes Recht (`licenses.export`), frische
|
selbst ein Geheimnis: eigenes Recht (`licenses.export`), frische
|
||||||
Passwortbestaetigung, `Cache-Control: no-store`, Eintrag im Zustell- und im
|
Passwortbestätigung, `Cache-Control: no-store`, Eintrag im Zustell- und im
|
||||||
Aenderungsprotokoll.
|
Änderungsprotokoll.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 9. Ein- und Ausfuhr
|
## 9. Ein- und Ausfuhr
|
||||||
|
|
||||||
Verschluesselte Umschlaege (Argon2id-Ableitung + AES-256-GCM) mit einem selbst
|
Verschlüsselte Umschläge (Argon2id-Ableitung + AES-256-GCM) mit einem selbst
|
||||||
gewaehlten Kennwort. Der Import zeigt eine Vorschau, bevor etwas geschrieben
|
gewählten Kennwort. Der Import zeigt eine Vorschau, bevor etwas geschrieben
|
||||||
wird; die Vorschau liegt in der Datenbank, nicht im Prozessspeicher -- deshalb
|
wird; die Vorschau liegt in der Datenbank, nicht im Prozessspeicher -- deshalb
|
||||||
braucht der Dienst kein `--workers 1` mehr.
|
braucht der Dienst kein `--workers 1` mehr.
|
||||||
|
|
||||||
@@ -166,11 +166,11 @@ braucht der Dienst kein `--workers 1` mehr.
|
|||||||
|
|
||||||
## 10. Bewusst nicht getan
|
## 10. Bewusst nicht getan
|
||||||
|
|
||||||
* **Kein Schalter, der die LDAP-Zertifikatspruefung abschaltet.** Wer ihn
|
* **Kein Schalter, der die LDAP-Zertifikatsprüfung abschaltet.** Wer ihn
|
||||||
einmal setzt, setzt ihn dauerhaft.
|
einmal setzt, setzt ihn dauerhaft.
|
||||||
* **Kein CSRF-Freibrief fuer cookie-autorisierte Routen.**
|
* **Kein CSRF-Freibrief für cookie-autorisierte Routen.**
|
||||||
* **Kein `unsafe-inline` fuer Skripte**, auch nicht "voruebergehend".
|
* **Kein `unsafe-inline` für Skripte**, auch nicht "vorübergehend".
|
||||||
* **Keine Rechteausweitung**, auch nicht fuer Administratoren untereinander.
|
* **Keine Rechteausweitung**, auch nicht für Administratoren untereinander.
|
||||||
* **Kein Hostname im Lizenz-Fingerabdruck** -- Umbenennen darf die Bindung
|
* **Kein Hostname im Lizenz-Fingerabdruck** -- Umbenennen darf die Bindung
|
||||||
nicht brechen.
|
nicht brechen.
|
||||||
* **Keine von Hand gepflegte nginx-Site.** Sie wird immer neu erzeugt.
|
* **Keine von Hand gepflegte nginx-Site.** Sie wird immer neu erzeugt.
|
||||||
@@ -179,5 +179,5 @@ braucht der Dienst kein `--workers 1` mehr.
|
|||||||
|
|
||||||
## 11. Wenn Sie eine Schwachstelle finden
|
## 11. Wenn Sie eine Schwachstelle finden
|
||||||
|
|
||||||
Bitte nicht oeffentlich melden. Wenden Sie sich an die im Anbieterprofil des
|
Bitte nicht öffentlich melden. Wenden Sie sich an die im Anbieterprofil des
|
||||||
Lizenzservers hinterlegte Adresse.
|
Lizenzservers hinterlegte Adresse.
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "tesm-core"
|
name = "tesm-core"
|
||||||
version = "2.0.0"
|
version = "2.0.6"
|
||||||
description = "Gemeinsames Anwendungsgeruest (Auth, RBAC, Navigation, Design-System, Migrationen) fuer TESM und TESM-Lizenzserver"
|
description = "Gemeinsames Anwendungsgeruest (Auth, RBAC, Navigation, Design-System, Migrationen) fuer TESM und TESM-Lizenzserver"
|
||||||
requires-python = ">=3.11"
|
requires-python = ">=3.11"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ Design-System.
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
__version__ = "2.0.0"
|
__version__ = "2.0.2"
|
||||||
|
|
||||||
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
|
||||||
|
|||||||
@@ -43,6 +43,11 @@ from .views import hostsettings as hostsettings_views
|
|||||||
from .views import logs as logs_views
|
from .views import logs as logs_views
|
||||||
from .views import trash as trash_views
|
from .views import trash as trash_views
|
||||||
|
|
||||||
|
#: Rechteinhaber, in dieser Reihenfolge. Steht an einer Stelle, weil die
|
||||||
|
#: Fusszeile in zwei Vorlagen auftaucht (Anwendungsrahmen und Anmeldeseiten)
|
||||||
|
#: und zwei Texte fuer dieselbe Aussage auseinanderlaufen.
|
||||||
|
COPYRIGHT_NOTICE = "Tim Eertmoed · WiS IT-Solutions GmbH"
|
||||||
|
|
||||||
CORE_SETTINGS: tuple[SettingSpec, ...] = (
|
CORE_SETTINGS: tuple[SettingSpec, ...] = (
|
||||||
SettingSpec("nav_order", [], "json", "Reihenfolge der Navigation", section="intern"),
|
SettingSpec("nav_order", [], "json", "Reihenfolge der Navigation", section="intern"),
|
||||||
SettingSpec("nav_child_order", {}, "json", "Reihenfolge der Unterpunkte", section="intern"),
|
SettingSpec("nav_child_order", {}, "json", "Reihenfolge der Unterpunkte", section="intern"),
|
||||||
@@ -56,6 +61,18 @@ CORE_SETTINGS: tuple[SettingSpec, ...] = (
|
|||||||
minimum=1,
|
minimum=1,
|
||||||
maximum=3650,
|
maximum=3650,
|
||||||
),
|
),
|
||||||
|
SettingSpec(
|
||||||
|
"session_idle_minutes",
|
||||||
|
30,
|
||||||
|
"int",
|
||||||
|
"Automatische Abmeldung nach Leerlauf (Minuten)",
|
||||||
|
hint="Jede Anfrage setzt den Zaehler zurueck -- gezaehlt wird nur Untaetigkeit. "
|
||||||
|
"Gilt auch fuer die eingebundenen Dateifreigaben: mit der Sitzung endet die "
|
||||||
|
"Einbindung.",
|
||||||
|
section="system",
|
||||||
|
minimum=5,
|
||||||
|
maximum=1440,
|
||||||
|
),
|
||||||
SettingSpec("timezone", "", "str", "Zeitzone des Hosts", section="system"),
|
SettingSpec("timezone", "", "str", "Zeitzone des Hosts", section="system"),
|
||||||
SettingSpec("hostname", "", "str", "Hostname", section="system"),
|
SettingSpec("hostname", "", "str", "Hostname", section="system"),
|
||||||
SettingSpec(
|
SettingSpec(
|
||||||
@@ -117,7 +134,7 @@ def create_base_app(
|
|||||||
#: Seitenhintergrund. Der Lizenzserver setzt sie bewusst nicht: die
|
#: Seitenhintergrund. Der Lizenzserver setzt sie bewusst nicht: die
|
||||||
#: Funktionsliste von TESM waere dort schlicht falsch.
|
#: Funktionsliste von TESM waere dort schlicht falsch.
|
||||||
auth_tagline: str = "",
|
auth_tagline: str = "",
|
||||||
app_version: str = "2.0.0",
|
app_version: str = "2.0.6",
|
||||||
built_in_trusted_keys: tuple[str, ...] = (),
|
built_in_trusted_keys: tuple[str, ...] = (),
|
||||||
enable_license: bool = True,
|
enable_license: bool = True,
|
||||||
template_folder: str | None = None,
|
template_folder: str | None = None,
|
||||||
@@ -192,11 +209,25 @@ def create_base_app(
|
|||||||
)
|
)
|
||||||
app.extensions["tesm"] = extension
|
app.extensions["tesm"] = extension
|
||||||
|
|
||||||
|
def _leerlauf(conn: Any) -> int:
|
||||||
|
"""Leerlaufgrenze aus den Einstellungen -- ueber die offene Verbindung.
|
||||||
|
|
||||||
|
Bewusst mit der Verbindung, die die Sitzungsverwaltung schon haelt: eine
|
||||||
|
zweite zu oeffnen waere eine verschachtelte Datenbankverbindung, und die
|
||||||
|
gilt hier als Fehler.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
wert = int(settings.get(conn, "session_idle_minutes") or 0)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
wert = 0
|
||||||
|
return wert if wert > 0 else config.security.session_idle_minutes
|
||||||
|
|
||||||
app.session_interface = SqliteSessionInterface(
|
app.session_interface = SqliteSessionInterface(
|
||||||
database,
|
database,
|
||||||
cookie_name=app.config["SESSION_COOKIE_NAME"],
|
cookie_name=app.config["SESSION_COOKIE_NAME"],
|
||||||
idle_minutes=config.security.session_idle_minutes,
|
idle_minutes=config.security.session_idle_minutes,
|
||||||
absolute_hours=config.security.session_absolute_hours,
|
absolute_hours=config.security.session_absolute_hours,
|
||||||
|
idle_lookup=_leerlauf,
|
||||||
)
|
)
|
||||||
|
|
||||||
# -- Schema und Startzustand ------------------------------------------ #
|
# -- Schema und Startzustand ------------------------------------------ #
|
||||||
@@ -228,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"
|
||||||
@@ -264,6 +306,7 @@ def create_base_app(
|
|||||||
"auth_tagline": auth_tagline,
|
"auth_tagline": auth_tagline,
|
||||||
"app_version": app_version,
|
"app_version": app_version,
|
||||||
"current_year": datetime.now(timezone.utc).year,
|
"current_year": datetime.now(timezone.utc).year,
|
||||||
|
"copyright_notice": COPYRIGHT_NOTICE,
|
||||||
"current_user": current_user,
|
"current_user": current_user,
|
||||||
"nav_items": nav_items,
|
"nav_items": nav_items,
|
||||||
"palette_entries": palette,
|
"palette_entries": palette,
|
||||||
|
|||||||
@@ -42,7 +42,7 @@ AAD_LDAP_BIND = "service_accounts.ldap_bind"
|
|||||||
|
|
||||||
#: userAccountControl-Bit fuer "Konto deaktiviert".
|
#: userAccountControl-Bit fuer "Konto deaktiviert".
|
||||||
ACCOUNTDISABLE = 0x0002
|
ACCOUNTDISABLE = 0x0002
|
||||||
#: OID fuer rekursive Gruppenauflösung in AD.
|
#: OID fuer rekursive Gruppenaufloesung in AD.
|
||||||
MATCHING_RULE_IN_CHAIN = "1.2.840.113556.1.4.1941"
|
MATCHING_RULE_IN_CHAIN = "1.2.840.113556.1.4.1941"
|
||||||
|
|
||||||
Status = Literal["ok", "invalid", "unavailable", "disabled"]
|
Status = Literal["ok", "invalid", "unavailable", "disabled"]
|
||||||
@@ -349,7 +349,7 @@ def _attr_int(entry: Any, name: str) -> int | None:
|
|||||||
|
|
||||||
|
|
||||||
def _resolve_groups(connection: Any, base: str, user_dn: str) -> tuple[str, ...]:
|
def _resolve_groups(connection: Any, base: str, user_dn: str) -> tuple[str, ...]:
|
||||||
"""Rekursive Gruppenauflösung ueber LDAP_MATCHING_RULE_IN_CHAIN."""
|
"""Rekursive Gruppenaufloesung ueber LDAP_MATCHING_RULE_IN_CHAIN."""
|
||||||
try:
|
try:
|
||||||
connection.search(
|
connection.search(
|
||||||
search_base=base,
|
search_base=base,
|
||||||
@@ -359,7 +359,7 @@ def _resolve_groups(connection: Any, base: str, user_dn: str) -> tuple[str, ...]
|
|||||||
)
|
)
|
||||||
return tuple(str(entry.entry_dn) for entry in connection.entries)
|
return tuple(str(entry.entry_dn) for entry in connection.entries)
|
||||||
except Exception as exc: # noqa: BLE001
|
except Exception as exc: # noqa: BLE001
|
||||||
logger.warning("Gruppenauflösung fehlgeschlagen: %s", exc)
|
logger.warning("Gruppenaufloesung fehlgeschlagen: %s", exc)
|
||||||
return ()
|
return ()
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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,6 +190,14 @@ 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")
|
flash("Bitte melden Sie sich an.", "info")
|
||||||
|
|||||||
@@ -45,7 +45,7 @@ class SecurityConfig:
|
|||||||
cookie_secure: bool = True
|
cookie_secure: bool = True
|
||||||
cookie_samesite: str = "Lax"
|
cookie_samesite: str = "Lax"
|
||||||
#: Leerlauf-Zeitfenster einer Sitzung.
|
#: Leerlauf-Zeitfenster einer Sitzung.
|
||||||
session_idle_minutes: int = 60
|
session_idle_minutes: int = 30
|
||||||
#: Harte Obergrenze unabhaengig von Aktivitaet.
|
#: Harte Obergrenze unabhaengig von Aktivitaet.
|
||||||
session_absolute_hours: int = 12
|
session_absolute_hours: int = 12
|
||||||
#: Wie lange eine erneute Passwortabfrage ("sudo-Modus") gilt.
|
#: Wie lange eine erneute Passwortabfrage ("sudo-Modus") gilt.
|
||||||
@@ -147,7 +147,7 @@ def load_core_config(
|
|||||||
security = SecurityConfig(
|
security = SecurityConfig(
|
||||||
cookie_secure=_env_bool(env_prefix, "COOKIE_SECURE", True),
|
cookie_secure=_env_bool(env_prefix, "COOKIE_SECURE", True),
|
||||||
cookie_samesite=_env(env_prefix, "COOKIE_SAMESITE") or "Lax",
|
cookie_samesite=_env(env_prefix, "COOKIE_SAMESITE") or "Lax",
|
||||||
session_idle_minutes=_env_int(env_prefix, "SESSION_IDLE_MINUTES", 60),
|
session_idle_minutes=_env_int(env_prefix, "SESSION_IDLE_MINUTES", 30),
|
||||||
session_absolute_hours=_env_int(env_prefix, "SESSION_ABSOLUTE_HOURS", 12),
|
session_absolute_hours=_env_int(env_prefix, "SESSION_ABSOLUTE_HOURS", 12),
|
||||||
reauth_valid_minutes=_env_int(env_prefix, "REAUTH_VALID_MINUTES", 15),
|
reauth_valid_minutes=_env_int(env_prefix, "REAUTH_VALID_MINUTES", 15),
|
||||||
login_max_attempts=_env_int(env_prefix, "LOGIN_MAX_ATTEMPTS", 8),
|
login_max_attempts=_env_int(env_prefix, "LOGIN_MAX_ATTEMPTS", 8),
|
||||||
|
|||||||
@@ -48,6 +48,10 @@ SETTING_STATUS = "license_activation_status"
|
|||||||
SETTING_PENDING = "license_pending_request"
|
SETTING_PENDING = "license_pending_request"
|
||||||
SETTING_LAST_HEARTBEAT = "license_last_heartbeat_at"
|
SETTING_LAST_HEARTBEAT = "license_last_heartbeat_at"
|
||||||
SETTING_HEARTBEAT_ERROR = "license_heartbeat_error"
|
SETTING_HEARTBEAT_ERROR = "license_heartbeat_error"
|
||||||
|
|
||||||
|
#: Ab wann die Restlaufzeit als *Hinweis* erscheint (gruen). Unterhalb von
|
||||||
|
#: ``EXPIRY_WARNING_DAYS`` uebernimmt die Warnung (orange), nach Ablauf Rot.
|
||||||
|
EXPIRY_HINT_DAYS = 90
|
||||||
SETTING_EXPORT_GRACE = "license_export_grace"
|
SETTING_EXPORT_GRACE = "license_export_grace"
|
||||||
|
|
||||||
STATUS_INACTIVE = "inactive"
|
STATUS_INACTIVE = "inactive"
|
||||||
@@ -596,6 +600,14 @@ class LicenseManager:
|
|||||||
message = "Verbindung zum Lizenzserver bestaetigt."
|
message = "Verbindung zum Lizenzserver bestaetigt."
|
||||||
|
|
||||||
self.settings.set(conn, SETTING_PENDING, "", actor=actor)
|
self.settings.set(conn, SETTING_PENDING, "", actor=actor)
|
||||||
|
# Ein erfolgreicher Heartbeat laeuft alle paar Minuten und ist kein
|
||||||
|
# Ereignis, sondern der Normalzustand. Im Auditlog stand dadurch
|
||||||
|
# praktisch nur noch er, und die Eintraege, um die es dort geht --
|
||||||
|
# Aktivierung, Deaktivierung, Widerruf --, gingen darin unter. Der
|
||||||
|
# Zeitpunkt des letzten Heartbeats steht ohnehin in den Einstellungen
|
||||||
|
# und auf der Lizenzseite. Ein *fehlgeschlagener* Heartbeat wird
|
||||||
|
# weiterhin protokolliert (siehe die Fehlerpfade oben).
|
||||||
|
if action != "heartbeat":
|
||||||
audit.log(
|
audit.log(
|
||||||
conn,
|
conn,
|
||||||
actor=actor,
|
actor=actor,
|
||||||
@@ -704,6 +716,19 @@ class LicenseManager:
|
|||||||
except LicenseError as exc:
|
except LicenseError as exc:
|
||||||
with self.database.transaction(conn):
|
with self.database.transaction(conn):
|
||||||
self.settings.set(conn, SETTING_HEARTBEAT_ERROR, str(exc), actor="system")
|
self.settings.set(conn, SETTING_HEARTBEAT_ERROR, str(exc), actor="system")
|
||||||
|
# Der *erfolgreiche* Heartbeat wird nicht protokolliert -- er
|
||||||
|
# ist der Normalzustand und hat das Auditlog zugeschuettet.
|
||||||
|
# Ein Fehlschlag dagegen ist genau das, was dort stehen soll:
|
||||||
|
# er kann bedeuten, dass die Lizenz demnaechst in die
|
||||||
|
# Kulanzzeit laeuft.
|
||||||
|
audit.log(
|
||||||
|
conn,
|
||||||
|
actor="system",
|
||||||
|
action="license.heartbeat_failed",
|
||||||
|
target=self._state.license_id or "",
|
||||||
|
detail=str(exc)[:500],
|
||||||
|
severity="warning",
|
||||||
|
)
|
||||||
return {"ok": False, "message": str(exc)}
|
return {"ok": False, "message": str(exc)}
|
||||||
|
|
||||||
# ------------------------------------------------------------------ #
|
# ------------------------------------------------------------------ #
|
||||||
@@ -761,6 +786,17 @@ class LicenseManager:
|
|||||||
"dot_class": "status-pill__dot--pulse",
|
"dot_class": "status-pill__dot--pulse",
|
||||||
"title": "Bitte rechtzeitig verlaengern.",
|
"title": "Bitte rechtzeitig verlaengern.",
|
||||||
}
|
}
|
||||||
|
# Zwischen der Warnschwelle und 90 Tagen: gruen, rein als Hinweis. Wer
|
||||||
|
# eine Verlaengerung im Voraus plant, will das Datum sehen, ohne dass es
|
||||||
|
# nach einem Problem aussieht.
|
||||||
|
if status.days_left is not None and 0 < status.days_left <= EXPIRY_HINT_DAYS:
|
||||||
|
return {
|
||||||
|
"label": f"Lizenz laeuft in {status.days_left} Tagen ab",
|
||||||
|
"css_class": "",
|
||||||
|
"dot": "1",
|
||||||
|
"dot_class": "",
|
||||||
|
"title": "Nur ein Hinweis -- es ist nichts eingeschraenkt.",
|
||||||
|
}
|
||||||
if state.last_heartbeat_at:
|
if state.last_heartbeat_at:
|
||||||
try:
|
try:
|
||||||
last = lic.parse_iso(state.last_heartbeat_at)
|
last = lic.parse_iso(state.last_heartbeat_at)
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ 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
|
||||||
|
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)
|
||||||
@@ -88,11 +162,24 @@ class SqliteSessionInterface(SessionInterface):
|
|||||||
cookie_name: str,
|
cookie_name: str,
|
||||||
idle_minutes: int,
|
idle_minutes: int,
|
||||||
absolute_hours: int,
|
absolute_hours: int,
|
||||||
|
idle_lookup: Any = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
self.database = database
|
self.database = database
|
||||||
self.cookie_name = cookie_name
|
self.cookie_name = cookie_name
|
||||||
self.idle_minutes = idle_minutes
|
self.idle_minutes = idle_minutes
|
||||||
self.absolute_hours = absolute_hours
|
self.absolute_hours = absolute_hours
|
||||||
|
#: Optionale Nachschlagefunktion fuer die Leerlaufgrenze. Damit ist die
|
||||||
|
#: Zeit zur Laufzeit einstellbar, ohne den Dienst neu zu starten -- und
|
||||||
|
#: ohne eine zweite Datenbankverbindung, denn sie bekommt die offene.
|
||||||
|
self._idle_lookup = idle_lookup
|
||||||
|
|
||||||
|
def _idle(self, conn: Any) -> int:
|
||||||
|
if self._idle_lookup is None:
|
||||||
|
return self.idle_minutes
|
||||||
|
try:
|
||||||
|
return int(self._idle_lookup(conn)) or self.idle_minutes
|
||||||
|
except Exception: # pragma: no cover - Einstellung unlesbar
|
||||||
|
return self.idle_minutes
|
||||||
|
|
||||||
# -- Flask-Schnittstelle ------------------------------------------- #
|
# -- Flask-Schnittstelle ------------------------------------------- #
|
||||||
|
|
||||||
@@ -141,12 +228,21 @@ 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=?",
|
||||||
(_iso(now), _iso(now + timedelta(minutes=self.idle_minutes)), sid),
|
(_iso(now), _iso(now + timedelta(minutes=self._idle(conn))), sid),
|
||||||
)
|
)
|
||||||
return session
|
return session
|
||||||
|
|
||||||
@@ -190,7 +286,7 @@ class SqliteSessionInterface(SessionInterface):
|
|||||||
session.get("user_id"),
|
session.get("user_id"),
|
||||||
_iso(now),
|
_iso(now),
|
||||||
_iso(now),
|
_iso(now),
|
||||||
_iso(now + timedelta(minutes=self.idle_minutes)),
|
_iso(now + timedelta(minutes=self._idle(conn))),
|
||||||
_iso(now + timedelta(hours=self.absolute_hours)),
|
_iso(now + timedelta(hours=self.absolute_hours)),
|
||||||
session.get("_ip", ""),
|
session.get("_ip", ""),
|
||||||
session.get("_ua", ""),
|
session.get("_ua", ""),
|
||||||
@@ -198,6 +294,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=?, "
|
||||||
@@ -206,7 +327,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,
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -161,6 +161,40 @@
|
|||||||
border-color: var(--accent);
|
border-color: var(--accent);
|
||||||
box-shadow: 0 0 0 3px var(--accent-ring);
|
box-shadow: 0 0 0 3px var(--accent-ring);
|
||||||
}
|
}
|
||||||
|
/* Der Knopf im Dateifeld kommt vom Browser und ignoriert jede Aussenfarbe:
|
||||||
|
im dunklen Thema ein hellgraues Kaestchen mit dunkler Schrift, das weder
|
||||||
|
zum Feld noch zu den uebrigen Knoepfen passt und kaum zu lesen war.
|
||||||
|
Deshalb explizit wie ein Sekundaerknopf gestaltet -- und weil vier Felder
|
||||||
|
betroffen sind (Sicherung, Lizenz, Profilbild, Freigaben), einmal hier. */
|
||||||
|
.input[type="file"] {
|
||||||
|
padding: 5px 6px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--sp-3);
|
||||||
|
cursor: pointer;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
.input[type="file"]::file-selector-button {
|
||||||
|
padding: 5px 12px;
|
||||||
|
margin-right: var(--sp-1);
|
||||||
|
border: 1px solid var(--border-strong);
|
||||||
|
border-radius: var(--r-sm);
|
||||||
|
background: var(--surface-2);
|
||||||
|
color: var(--text);
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: var(--fs-sm);
|
||||||
|
font-weight: 570;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background var(--dur-fast), border-color var(--dur-fast);
|
||||||
|
}
|
||||||
|
.input[type="file"]:hover::file-selector-button {
|
||||||
|
background: var(--surface-3);
|
||||||
|
border-color: var(--text-faint);
|
||||||
|
}
|
||||||
|
.input[type="file"]:focus-visible::file-selector-button {
|
||||||
|
border-color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
.input[readonly], .input[disabled], .select[disabled], .textarea[disabled] {
|
.input[readonly], .input[disabled], .select[disabled], .textarea[disabled] {
|
||||||
background: var(--surface-inset);
|
background: var(--surface-inset);
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
@@ -374,6 +408,15 @@
|
|||||||
background: var(--danger-soft);
|
background: var(--danger-soft);
|
||||||
color: var(--danger);
|
color: var(--danger);
|
||||||
}
|
}
|
||||||
|
/* Gefuellt statt getoent: "kritisch" muss sich von "Warnung" unterscheiden,
|
||||||
|
und beide sind rot. */
|
||||||
|
.pill--critical {
|
||||||
|
background: var(--danger);
|
||||||
|
color: var(--surface-0);
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
.pill--critical::before { background: currentColor; }
|
||||||
|
|
||||||
.pill--warning, .pill--unknown, .pill--orange {
|
.pill--warning, .pill--unknown, .pill--orange {
|
||||||
background: var(--warning-soft);
|
background: var(--warning-soft);
|
||||||
color: var(--warning);
|
color: var(--warning);
|
||||||
@@ -402,6 +445,22 @@
|
|||||||
background: var(--success);
|
background: var(--success);
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
/* Der Knopf sitzt *in* der Pille: kein Rahmen, keine Flaeche, nur das
|
||||||
|
Symbol in der Farbe der Pille. Ein Standardknopf wuerde sie sprengen. */
|
||||||
|
.status-pill__action { display: inline-flex; }
|
||||||
|
.status-pill__action button {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 0;
|
||||||
|
margin-left: 2px;
|
||||||
|
border: 0;
|
||||||
|
background: none;
|
||||||
|
color: inherit;
|
||||||
|
opacity: 0.65;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.status-pill__action button:hover { opacity: 1; }
|
||||||
|
|
||||||
.status-pill__dot--pulse { animation: pulse-ring 2.4s var(--ease) infinite; }
|
.status-pill__dot--pulse { animation: pulse-ring 2.4s var(--ease) infinite; }
|
||||||
.status-pill--danger { border-color: color-mix(in srgb, var(--danger) 45%, var(--border)); color: var(--danger); }
|
.status-pill--danger { border-color: color-mix(in srgb, var(--danger) 45%, var(--border)); color: var(--danger); }
|
||||||
.status-pill--danger .status-pill__dot { background: var(--danger); }
|
.status-pill--danger .status-pill__dot { background: var(--danger); }
|
||||||
@@ -621,6 +680,37 @@
|
|||||||
}
|
}
|
||||||
.modal__title { font-size: var(--fs-md); font-weight: 640; flex: 1; }
|
.modal__title { font-size: var(--fs-md); font-weight: 640; flex: 1; }
|
||||||
.modal__sub { font-size: var(--fs-xs); color: var(--text-faint); margin-top: 2px; }
|
.modal__sub { font-size: var(--fs-xs); color: var(--text-faint); margin-top: 2px; }
|
||||||
|
/* Eine Datei-Vorschau soll die ganze Flaeche des Dialogs nutzen; der
|
||||||
|
uebliche Innenabstand des Dialogkoerpers wuerde sie einrahmen. */
|
||||||
|
.modal__body--flush { padding: 0; max-height: none; }
|
||||||
|
.modal__caption {
|
||||||
|
padding: 0 var(--sp-5) var(--sp-2);
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: var(--fs-xs);
|
||||||
|
word-break: break-all;
|
||||||
|
}
|
||||||
|
.file-preview {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
height: min(70dvh, 760px);
|
||||||
|
border: 0;
|
||||||
|
background: var(--surface-0);
|
||||||
|
}
|
||||||
|
/* Der Dateiname ist ein Knopf (er oeffnet die Vorschau), soll aber wie ein
|
||||||
|
Link aussehen -- ein Knopf mit Rahmen mitten in einer Tabellenzeile waere
|
||||||
|
unruhig. */
|
||||||
|
.file-name {
|
||||||
|
background: none;
|
||||||
|
border: 0;
|
||||||
|
padding: 0;
|
||||||
|
color: var(--accent);
|
||||||
|
font: inherit;
|
||||||
|
text-align: left;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.file-name:hover { text-decoration: underline; }
|
||||||
|
|
||||||
.modal__body {
|
.modal__body {
|
||||||
padding: var(--sp-5);
|
padding: var(--sp-5);
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
@@ -1001,6 +1091,16 @@
|
|||||||
.warning-text { color: var(--warning); }
|
.warning-text { color: var(--warning); }
|
||||||
.accent-text { color: var(--accent); }
|
.accent-text { color: var(--accent); }
|
||||||
.mono { font-family: var(--font-mono); font-size: 0.94em; }
|
.mono { font-family: var(--font-mono); font-size: 0.94em; }
|
||||||
|
/* Ein Inline-SVG ist im Reset display:block (base.css). In einem Knopf
|
||||||
|
stoert das nicht -- .btn ist inline-flex. In einem einfachen Link reisst
|
||||||
|
es die Beschriftung in die naechste Zeile. Fuer solche Links deshalb
|
||||||
|
ausdruecklich eine Zeile. */
|
||||||
|
.link-icon {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
.nowrap { white-space: nowrap; }
|
.nowrap { white-space: nowrap; }
|
||||||
.truncate { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; min-width: 0; }
|
.truncate { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; min-width: 0; }
|
||||||
.break-all { overflow-wrap: anywhere; }
|
.break-all { overflow-wrap: anywhere; }
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ function store(key, value) {
|
|||||||
if (value === null) localStorage.removeItem(key);
|
if (value === null) localStorage.removeItem(key);
|
||||||
else localStorage.setItem(key, value);
|
else localStorage.setItem(key, value);
|
||||||
} catch {
|
} catch {
|
||||||
/* Privatmodus o. Ä. -- Einstellungen sind dann nur fluechtig. */
|
/* Privatmodus o. Ae. -- Einstellungen sind dann nur fluechtig. */
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -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;
|
||||||
}
|
}
|
||||||
@@ -212,13 +235,21 @@ function initDialogs() {
|
|||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
const dialog = document.getElementById(opener.dataset.dialogOpen);
|
const dialog = document.getElementById(opener.dataset.dialogOpen);
|
||||||
if (dialog) {
|
if (dialog) {
|
||||||
// Felder aus data-fill-* des Auslösers in das Formular uebernehmen.
|
// Felder aus data-fill-* des Ausloesers in das Formular uebernehmen.
|
||||||
Object.entries(opener.dataset).forEach(([key, value]) => {
|
Object.entries(opener.dataset).forEach(([key, value]) => {
|
||||||
if (!key.startsWith("fill")) return;
|
if (!key.startsWith("fill")) return;
|
||||||
const name = key.slice(4);
|
const name = key.slice(4);
|
||||||
const target = dialog.querySelector(`[data-fill-target="${name}"]`);
|
const target = dialog.querySelector(`[data-fill-target="${name}"]`);
|
||||||
if (!target) return;
|
if (!target) return;
|
||||||
if ("value" in target) target.value = value;
|
// Wohin der Wert gehoert, haengt am Element: ein Rahmen will die
|
||||||
|
// Quelle, ein Link das Ziel, ein Feld einen Wert. Ohne diese
|
||||||
|
// Unterscheidung landete die URL als *Beschriftung* im
|
||||||
|
// Herunterladen-Knopf -- und weil das href dabei "#" blieb, lud ein
|
||||||
|
// Klick die Seite selbst als "Herunterladen.htm" herunter.
|
||||||
|
const als = target.dataset.fillAs;
|
||||||
|
if (als === "src") target.src = value;
|
||||||
|
else if (als === "href") target.href = value;
|
||||||
|
else if ("value" in target) target.value = value;
|
||||||
else target.textContent = value;
|
else target.textContent = value;
|
||||||
});
|
});
|
||||||
openDialog(dialog);
|
openDialog(dialog);
|
||||||
@@ -548,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.
|
||||||
@@ -563,7 +597,8 @@ function initAutoRefresh() {
|
|||||||
// von innerHTML entsteht ein neues Element -- mit scrollTop 0. Genau
|
// von innerHTML entsteht ein neues Element -- mit scrollTop 0. Genau
|
||||||
// deshalb sprang ein laufendes Auftragsprotokoll bei jeder
|
// deshalb sprang ein laufendes Auftragsprotokoll bei jeder
|
||||||
// Aktualisierung an den Anfang zurueck.
|
// Aktualisierung an den Anfang zurueck.
|
||||||
const scrollerOf = (el) => el.querySelector(".log-view, [data-scroll]") || el;
|
const scrollerOf = (el) =>
|
||||||
|
el.querySelector("[data-tail], .log-view, [data-scroll]") || el;
|
||||||
const before = scrollerOf(region);
|
const before = scrollerOf(region);
|
||||||
const position = before.scrollTop;
|
const position = before.scrollTop;
|
||||||
// "Am Ende" grosszuegig auslegen: wer mitliest, soll mitgezogen
|
// "Am Ende" grosszuegig auslegen: wer mitliest, soll mitgezogen
|
||||||
@@ -579,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();
|
||||||
@@ -814,6 +858,25 @@ function initTileFilters() {
|
|||||||
|
|
||||||
/* -- Start ---------------------------------------------------------------- */
|
/* -- Start ---------------------------------------------------------------- */
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Protokollansichten stehen am Ende, nicht am Anfang.
|
||||||
|
*
|
||||||
|
* Bei einem Protokoll ist die neueste Zeile die interessante, und die steht
|
||||||
|
* unten. Ohne diesen Anfangssprung blieb die Ansicht oben stehen -- und weil
|
||||||
|
* die Aktualisierung nur mitzieht, wenn man ohnehin schon am Ende ist, zog
|
||||||
|
* sie danach nie mit. Es sah aus, als waere der Autoscroll kaputt; in
|
||||||
|
* Wirklichkeit stand man nur nie an der Stelle, an der er greift.
|
||||||
|
*
|
||||||
|
* Ausdruecklich per ``data-tail`` markiert: eine Konfigurationsvorschau
|
||||||
|
* (nginx, Kea) und der Offline-Code sind auch <pre class="log-view">, sollen
|
||||||
|
* aber oben beginnen.
|
||||||
|
*/
|
||||||
|
function initLogTails(root = document) {
|
||||||
|
root.querySelectorAll("[data-tail]").forEach((view) => {
|
||||||
|
view.scrollTop = view.scrollHeight;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
function boot() {
|
function boot() {
|
||||||
initTheme();
|
initTheme();
|
||||||
initSidebar();
|
initSidebar();
|
||||||
@@ -830,6 +893,7 @@ function boot() {
|
|||||||
initPalette();
|
initPalette();
|
||||||
initSubmitGuards();
|
initSubmitGuards();
|
||||||
initTileFilters();
|
initTileFilters();
|
||||||
|
initLogTails();
|
||||||
unsavedGuard.init();
|
unsavedGuard.init();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -166,6 +166,13 @@ class SysOps:
|
|||||||
input=stdin,
|
input=stdin,
|
||||||
capture_output=True,
|
capture_output=True,
|
||||||
text=True,
|
text=True,
|
||||||
|
# Kodierung festnageln. "text=True" allein nimmt die Locale des
|
||||||
|
# Prozesses -- unter systemd ist die haeufig C/ASCII, und dann
|
||||||
|
# bricht das Lesen ab, sobald ein Werkzeug einen Umlaut
|
||||||
|
# ausgibt. Der Fehler entsteht im Leser-Thread von subprocess,
|
||||||
|
# also weit weg von der Zeile, die ihn ausgeloest hat.
|
||||||
|
encoding="utf-8",
|
||||||
|
errors="replace",
|
||||||
timeout=timeout or self.timeout,
|
timeout=timeout or self.timeout,
|
||||||
check=False,
|
check=False,
|
||||||
)
|
)
|
||||||
@@ -197,6 +204,8 @@ class SysOps:
|
|||||||
[binary, *command[1:]],
|
[binary, *command[1:]],
|
||||||
capture_output=True,
|
capture_output=True,
|
||||||
text=True,
|
text=True,
|
||||||
|
encoding="utf-8",
|
||||||
|
errors="replace",
|
||||||
timeout=timeout,
|
timeout=timeout,
|
||||||
check=False,
|
check=False,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,15 +1,15 @@
|
|||||||
{# Auswahlfeld fuer eine Verzeichnisgruppe.
|
{# Auswahlfeld für eine Verzeichnisgruppe.
|
||||||
|
|
||||||
Der DN einer AD-Gruppe ist nichts, was man abtippt: er ist lang, enthaelt
|
Der DN einer AD-Gruppe ist nichts, was man abtippt: er ist lang, enthält
|
||||||
Kommas und Leerzeichen und ein Tippfehler faellt erst bei der naechsten
|
Kommas und Leerzeichen und ein Tippfehler fällt erst bei der nächsten
|
||||||
Anmeldung auf -- als stille Nicht-Zuordnung. Deshalb gesucht statt getippt.
|
Anmeldung auf -- als stille Nicht-Zuordnung. Deshalb gesucht statt getippt.
|
||||||
|
|
||||||
Das Suchergebnis kommt aus dem Verzeichnis selbst; die Felder werden dabei
|
Das Suchergebnis kommt aus dem Verzeichnis selbst; die Felder werden dabei
|
||||||
beide gefuellt (Anzeigename und DN). Getippt werden darf trotzdem: bei einer
|
beide gefüllt (Anzeigename und DN). Getippt werden darf trotzdem: bei einer
|
||||||
Umgebung ohne Dienstkonto ist die Suche nicht moeglich, und dann ist ein
|
Umgebung ohne Dienstkonto ist die Suche nicht möglich, und dann ist ein
|
||||||
eingefuegter DN besser als gar keine Zuordnung.
|
eingefügter DN besser als gar keine Zuordnung.
|
||||||
|
|
||||||
Erwartet: name_field, dn_field (Feldnamen), scope (eindeutiges Kuerzel),
|
Erwartet: name_field, dn_field (Feldnamen), scope (eindeutiges Kürzel),
|
||||||
optional name_value / dn_value und required.
|
optional name_value / dn_value und required.
|
||||||
#}
|
#}
|
||||||
{% macro group_picker(name_field, dn_field, scope, name_value='', dn_value='',
|
{% macro group_picker(name_field, dn_field, scope, name_value='', dn_value='',
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
{% if lines %}
|
{% if lines %}
|
||||||
<pre class="log-view">{% for line in lines %}{{ line }}
|
<pre class="log-view" data-tail>{% for line in lines %}{{ line }}
|
||||||
{% endfor %}</pre>
|
{% endfor %}</pre>
|
||||||
{% else %}
|
{% else %}
|
||||||
<div class="empty"><div class="empty__title">Keine Zeilen</div>
|
<div class="empty"><div class="empty__title">Keine Zeilen</div>
|
||||||
|
|||||||
@@ -20,7 +20,7 @@
|
|||||||
<div class="card__body">
|
<div class="card__body">
|
||||||
{% if current_user.is_directory_account %}
|
{% if current_user.is_directory_account %}
|
||||||
{{ alert('Ihre Stammdaten kommen aus dem Verzeichnisdienst und werden bei jeder
|
{{ alert('Ihre Stammdaten kommen aus dem Verzeichnisdienst und werden bei jeder
|
||||||
Anmeldung uebernommen.', 'info') }}
|
Anmeldung übernommen.', 'info') }}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<div class="form-grid">
|
<div class="form-grid">
|
||||||
{{ field('first_name', 'Vorname', value=profile.first_name,
|
{{ field('first_name', 'Vorname', value=profile.first_name,
|
||||||
@@ -48,7 +48,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="card__body">
|
<div class="card__body">
|
||||||
{% if current_user.is_admin %}
|
{% if current_user.is_admin %}
|
||||||
{{ alert('Sie sind Administrator und duerfen unabhaengig von Gruppen alles.', 'warning') }}
|
{{ alert('Sie sind Administrator und dürfen unabhängig von Gruppen alles.', 'warning') }}
|
||||||
{% elif permissions %}
|
{% elif permissions %}
|
||||||
<div class="row row--tight">
|
<div class="row row--tight">
|
||||||
{% for permission in permissions %}
|
{% for permission in permissions %}
|
||||||
|
|||||||
@@ -35,26 +35,26 @@
|
|||||||
<div class="stack">
|
<div class="stack">
|
||||||
<section class="card">
|
<section class="card">
|
||||||
<div class="card__header">
|
<div class="card__header">
|
||||||
<div class="flex-1"><div class="card__title">{{ icon('lock', size=17) }} Passwort aendern</div></div>
|
<div class="flex-1"><div class="card__title">{{ icon('lock', size=17) }} Passwort ändern</div></div>
|
||||||
</div>
|
</div>
|
||||||
{% if current_user.is_directory_account %}
|
{% if current_user.is_directory_account %}
|
||||||
<div class="card__body">
|
<div class="card__body">
|
||||||
{{ alert('Ihr Passwort wird im Verzeichnisdienst gepflegt und dort geaendert.', 'info') }}
|
{{ alert('Ihr Passwort wird im Verzeichnisdienst gepflegt und dort geändert.', 'info') }}
|
||||||
</div>
|
</div>
|
||||||
{% else %}
|
{% else %}
|
||||||
<form method="post" action="{{ url_for('account.change_password') }}">
|
<form method="post" action="{{ url_for('account.change_password') }}">
|
||||||
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
|
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
|
||||||
<div class="card__body stack">
|
<div class="card__body stack">
|
||||||
{{ field('current_password', 'Aktuelles Passwort', type='password', required=True,
|
{{ field('current_password', 'Aktülles Passwort', type='password', required=True,
|
||||||
autocomplete='current-password') }}
|
autocomplete='current-password') }}
|
||||||
{{ field('new_password', 'Neues Passwort', type='password', required=True,
|
{{ field('new_password', 'Neues Passwort', type='password', required=True,
|
||||||
autocomplete='new-password',
|
autocomplete='new-password',
|
||||||
hint_text='Mindestens ' ~ policy.min_length ~ ' Zeichen und zwei verschiedene Zeichenarten.') }}
|
hint_text='Mindestens ' ~ policy.min_length ~ ' Zeichen und zwei verschiedene Zeichenarten.') }}
|
||||||
{{ field('repeat_password', 'Neues Passwort wiederholen', type='password', required=True,
|
{{ field('repeat_password', 'Neues Passwort wiederholen', type='password', required=True,
|
||||||
autocomplete='new-password') }}
|
autocomplete='new-password') }}
|
||||||
{{ alert('Nach der Aenderung werden alle anderen Sitzungen beendet.', 'info') }}
|
{{ alert('Nach der Änderung werden alle anderen Sitzungen beendet.', 'info') }}
|
||||||
</div>
|
</div>
|
||||||
<div class="card__footer"><button class="btn btn--primary" type="submit">Passwort aendern</button></div>
|
<div class="card__footer"><button class="btn btn--primary" type="submit">Passwort ändern</button></div>
|
||||||
</form>
|
</form>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</section>
|
</section>
|
||||||
@@ -70,7 +70,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="card__body stack">
|
<div class="card__body stack">
|
||||||
{% if pending_secret %}
|
{% if pending_secret %}
|
||||||
{{ alert('Richten Sie den Eintrag in Ihrer App ein und bestaetigen Sie mit einem aktuellen Code. Erst dann wird die Anmeldung umgestellt.', 'warning') }}
|
{{ alert('Richten Sie den Eintrag in Ihrer App ein und bestätigen Sie mit einem aktüllen Code. Erst dann wird die Anmeldung umgestellt.', 'warning') }}
|
||||||
<dl class="kv fs-sm">
|
<dl class="kv fs-sm">
|
||||||
<dt>Geheimnis</dt>
|
<dt>Geheimnis</dt>
|
||||||
<dd><span class="mono" id="totp-secret">{{ pending_secret_grouped }}</span>
|
<dd><span class="mono" id="totp-secret">{{ pending_secret_grouped }}</span>
|
||||||
@@ -87,7 +87,7 @@
|
|||||||
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
|
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
|
||||||
<input class="input input--mono" type="text" name="code" required maxlength="6"
|
<input class="input input--mono" type="text" name="code" required maxlength="6"
|
||||||
inputmode="numeric" placeholder="123456" style="max-width:130px;text-align:center">
|
inputmode="numeric" placeholder="123456" style="max-width:130px;text-align:center">
|
||||||
<button class="btn btn--primary" type="submit">Bestaetigen</button>
|
<button class="btn btn--primary" type="submit">Bestätigen</button>
|
||||||
</form>
|
</form>
|
||||||
{% elif totp_enabled %}
|
{% elif totp_enabled %}
|
||||||
<dl class="kv fs-sm">
|
<dl class="kv fs-sm">
|
||||||
@@ -100,7 +100,7 @@
|
|||||||
<form method="post" action="{{ url_for('account.recovery_codes') }}" data-no-guard>
|
<form method="post" action="{{ url_for('account.recovery_codes') }}" data-no-guard>
|
||||||
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
|
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
|
||||||
<button class="btn btn--sm" type="submit"
|
<button class="btn btn--sm" type="submit"
|
||||||
data-confirm="Neue Codes erzeugen? Die bisherigen verlieren ihre Gueltigkeit."
|
data-confirm="Neue Codes erzeugen? Die bisherigen verlieren ihre Gültigkeit."
|
||||||
data-confirm-title="Codes erneuern" data-confirm-ok="Erneuern" data-confirm-style="primary">
|
data-confirm-title="Codes erneuern" data-confirm-ok="Erneuern" data-confirm-style="primary">
|
||||||
Codes erneuern</button>
|
Codes erneuern</button>
|
||||||
</form>
|
</form>
|
||||||
@@ -114,10 +114,10 @@
|
|||||||
</div>
|
</div>
|
||||||
{% else %}
|
{% else %}
|
||||||
{% if require_totp_for_admins and current_user.is_admin %}
|
{% if require_totp_for_admins and current_user.is_admin %}
|
||||||
{{ alert('In dieser Installation ist ein zweiter Faktor fuer Administratoren verpflichtend. Bitte jetzt einrichten.', 'danger') }}
|
{{ alert('In dieser Installation ist ein zweiter Faktor für Administratoren verpflichtend. Bitte jetzt einrichten.', 'danger') }}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<p class="muted fs-sm">
|
<p class="muted fs-sm">
|
||||||
Ein zweiter Faktor schuetzt Ihr Konto auch dann, wenn Ihr Passwort bekannt wird.
|
Ein zweiter Faktor schützt Ihr Konto auch dann, wenn Ihr Passwort bekannt wird.
|
||||||
</p>
|
</p>
|
||||||
<form method="post" action="{{ url_for('account.totp_start') }}" data-no-guard>
|
<form method="post" action="{{ url_for('account.totp_start') }}" data-no-guard>
|
||||||
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
|
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{# ==========================================================================
|
{# ==========================================================================
|
||||||
Grundgeruest aller angemeldeten Seiten.
|
Grundgerüst aller angemeldeten Seiten.
|
||||||
Bloecke: extra_head, page_title, page_sub, breadcrumb, topbar_extra,
|
Blöcke: extra_head, page_title, page_sub, breadcrumb, topbar_extra,
|
||||||
content, dialogs, scripts
|
content, dialogs, scripts
|
||||||
========================================================================== #}
|
========================================================================== #}
|
||||||
<!doctype html>
|
<!doctype html>
|
||||||
@@ -23,9 +23,9 @@
|
|||||||
|
|
||||||
<aside class="sidebar" aria-label="Hauptnavigation">
|
<aside class="sidebar" aria-label="Hauptnavigation">
|
||||||
<div class="sidebar__brand">
|
<div class="sidebar__brand">
|
||||||
{# Die Wortmarke traegt den Namen selbst -- ein zweiter Textblock daneben
|
{# Die Wortmarke trägt den Namen selbst -- ein zweiter Textblock daneben
|
||||||
waere eine Doppelung. Eine einzige Zeichnung, deren Farben aus den
|
wäre eine Doppelung. Eine einzige Zeichnung, deren Farben aus den
|
||||||
Themen-Token kommen: zwei per Media Query versteckte SVG waeren bei
|
Themen-Token kommen: zwei per Media Query versteckte SVG wären bei
|
||||||
"System" und beim Umschalten von Hand falsch. #}
|
"System" und beim Umschalten von Hand falsch. #}
|
||||||
<span class="sidebar__logo" role="img" aria-label="{{ app_name }}">
|
<span class="sidebar__logo" role="img" aria-label="{{ app_name }}">
|
||||||
<svg viewBox="0 0 312 112" xmlns="http://www.w3.org/2000/svg">
|
<svg viewBox="0 0 312 112" xmlns="http://www.w3.org/2000/svg">
|
||||||
@@ -126,9 +126,25 @@
|
|||||||
<div class="topbar__right">
|
<div class="topbar__right">
|
||||||
{% block topbar_extra %}{% endblock %}
|
{% block topbar_extra %}{% endblock %}
|
||||||
{% for pill in status_pills %}
|
{% for pill in status_pills %}
|
||||||
|
{# Eine Pille kann mehr als Text sein: ein Countdown (data-countdown-to
|
||||||
|
wird laufend gezählt) und eine Aktion. Der Knopf steckt bewusst in
|
||||||
|
der Pille -- er gehört zu der Angabe, die er aktualisiert. #}
|
||||||
<span class="status-pill {{ pill.css_class }}" {% if pill.title %}title="{{ pill.title }}"{% endif %}>
|
<span class="status-pill {{ pill.css_class }}" {% if pill.title %}title="{{ pill.title }}"{% endif %}>
|
||||||
{% if pill.dot %}<span class="status-pill__dot {{ pill.dot_class }}"></span>{% endif %}
|
{% if pill.dot %}<span class="status-pill__dot {{ pill.dot_class }}"></span>{% endif %}
|
||||||
{{ pill.label }}
|
{{ pill.label }}
|
||||||
|
{% if pill.countdown_to %}
|
||||||
|
<span class="mono" data-countdown-to="{{ pill.countdown_to }}"
|
||||||
|
data-countdown-done="{{ pill.countdown_done or 'jetzt' }}"></span>
|
||||||
|
{% endif %}
|
||||||
|
{% if pill.suffix %}{{ pill.suffix }}{% endif %}
|
||||||
|
{% if pill.action %}
|
||||||
|
<form method="post" action="{{ pill.action }}" data-no-guard class="status-pill__action">
|
||||||
|
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
|
||||||
|
<button type="submit" title="{{ pill.action_title or 'Jetzt ausführen' }}"
|
||||||
|
aria-label="{{ pill.action_title or 'Jetzt ausführen' }}"
|
||||||
|
data-busy-label="...">{{ icon('refresh', size=13) }}</button>
|
||||||
|
</form>
|
||||||
|
{% endif %}
|
||||||
</span>
|
</span>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
<button class="btn btn--icon btn--ghost hide-sm" type="button" data-behavior="open-palette"
|
<button class="btn btn--icon btn--ghost hide-sm" type="button" data-behavior="open-palette"
|
||||||
@@ -141,7 +157,7 @@
|
|||||||
</main>
|
</main>
|
||||||
|
|
||||||
<footer class="page-footer">
|
<footer class="page-footer">
|
||||||
<span>{{ app_name }} {{ app_version }} · © {{ current_year }} WiS GmbH</span>
|
<span>{{ app_name }} {{ app_version }} · © {{ current_year }} {{ copyright_notice }}</span>
|
||||||
<span class="row row--tight">
|
<span class="row row--tight">
|
||||||
{% if migration_warning %}
|
{% if migration_warning %}
|
||||||
<a class="danger-text" href="{{ url_for('diagnostics.overview') }}">Schemawarnung</a>
|
<a class="danger-text" href="{{ url_for('diagnostics.overview') }}">Schemawarnung</a>
|
||||||
@@ -163,17 +179,17 @@
|
|||||||
aria-label="Suchbegriff" autocomplete="off">
|
aria-label="Suchbegriff" autocomplete="off">
|
||||||
<div class="palette__list"></div>
|
<div class="palette__list"></div>
|
||||||
<div class="palette__hint">
|
<div class="palette__hint">
|
||||||
<span><kbd>↑</kbd><kbd>↓</kbd> waehlen</span>
|
<span><kbd>↑</kbd><kbd>↓</kbd> wählen</span>
|
||||||
<span><kbd>Enter</kbd> oeffnen</span>
|
<span><kbd>Enter</kbd> öffnen</span>
|
||||||
<span><kbd>Esc</kbd> schliessen</span>
|
<span><kbd>Esc</kbd> schliessen</span>
|
||||||
</div>
|
</div>
|
||||||
</dialog>
|
</dialog>
|
||||||
<script type="application/json" id="palette-data">{{ palette_entries | tojson }}</script>
|
<script type="application/json" id="palette-data">{{ palette_entries | tojson }}</script>
|
||||||
|
|
||||||
{# -- Bestaetigungsdialog (ersetzt window.confirm) ------------------------- #}
|
{# -- Bestätigungsdialog (ersetzt window.confirm) ------------------------- #}
|
||||||
<dialog class="modal modal--narrow" id="confirm-dialog" aria-labelledby="confirm-title">
|
<dialog class="modal modal--narrow" id="confirm-dialog" aria-labelledby="confirm-title">
|
||||||
<div class="modal__header">
|
<div class="modal__header">
|
||||||
<div class="modal__title" id="confirm-title" data-confirm-title>Bitte bestaetigen</div>
|
<div class="modal__title" id="confirm-title" data-confirm-title>Bitte bestätigen</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="modal__body"><p data-confirm-text></p></div>
|
<div class="modal__body"><p data-confirm-text></p></div>
|
||||||
<div class="modal__footer">
|
<div class="modal__footer">
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
{% from "tesm_core/macros.html" import alert, pill, stat %}
|
{% from "tesm_core/macros.html" import alert, pill, stat %}
|
||||||
|
|
||||||
{% block page_title %}Diagnose{% endblock %}
|
{% block page_title %}Diagnose{% endblock %}
|
||||||
{% block page_sub %}Schemastand, Protokollintegritaet, Schluessel und Systemzugriff{% endblock %}
|
{% block page_sub %}Schemastand, Protokollintegrität, Schlüssel und Systemzugriff{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<div class="grid grid--4">
|
<div class="grid grid--4">
|
||||||
@@ -17,17 +17,17 @@
|
|||||||
<div class="card__header">
|
<div class="card__header">
|
||||||
<div class="flex-1">
|
<div class="flex-1">
|
||||||
<div class="card__title">
|
<div class="card__title">
|
||||||
{{ icon('shield', size=17) }} Aenderungsprotokoll
|
{{ icon('shield', size=17) }} Änderungsprotokoll
|
||||||
{% if chain.ok %}{{ pill('unversehrt', 'online') }}{% else %}{{ pill('Kette gebrochen', 'danger') }}{% endif %}
|
{% if chain.ok %}{{ pill('unversehrt', 'online') }}{% else %}{{ pill('Kette gebrochen', 'danger') }}{% endif %}
|
||||||
</div>
|
</div>
|
||||||
<div class="card__sub">{{ chain.checked }} Zeilen geprueft (Hash-Kette)</div>
|
<div class="card__sub">{{ chain.checked }} Zeilen geprüft (Hash-Kette)</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="card__body">
|
<div class="card__body">
|
||||||
{% if chain.ok %}
|
{% if chain.ok %}
|
||||||
<p class="muted fs-sm">
|
<p class="muted fs-sm">
|
||||||
Jede Zeile bindet den Hash ihrer Vorgaengerin ein. Eine nachtraeglich entfernte oder
|
Jede Zeile bindet den Hash ihrer Vorgängerin ein. Eine nachträglich entfernte oder
|
||||||
geaenderte Zeile wuerde hier auffallen.
|
geänderte Zeile würde hier auffallen.
|
||||||
</p>
|
</p>
|
||||||
{% else %}
|
{% else %}
|
||||||
{{ alert(chain.reason ~ ' Erster Bruch bei Eintrag ' ~ chain.broken_at ~ '.', 'danger',
|
{{ alert(chain.reason ~ ' Erster Bruch bei Eintrag ' ~ chain.broken_at ~ '.', 'danger',
|
||||||
@@ -40,17 +40,17 @@
|
|||||||
<div class="card__header">
|
<div class="card__header">
|
||||||
<div class="flex-1">
|
<div class="flex-1">
|
||||||
<div class="card__title">
|
<div class="card__title">
|
||||||
{{ icon('key', size=17) }} Schluessel und Dateirechte
|
{{ icon('key', size=17) }} Schlüssel und Dateirechte
|
||||||
{% if key_problems %}{{ pill('Problem', 'danger') }}{% else %}{{ pill('in Ordnung', 'online') }}{% endif %}
|
{% if key_problems %}{{ pill('Problem', 'danger') }}{% else %}{{ pill('in Ordnung', 'online') }}{% endif %}
|
||||||
</div>
|
</div>
|
||||||
<div class="card__sub">Aktiver Datenschluessel: {{ keystore_active }} (vorhanden: {{ keystore_keys | join(', ') }})</div>
|
<div class="card__sub">Aktiver Datenschlüssel: {{ keystore_active }} (vorhanden: {{ keystore_keys | join(', ') }})</div>
|
||||||
</div>
|
</div>
|
||||||
{% if current_user.is_admin %}
|
{% if current_user.is_admin %}
|
||||||
<form method="post" action="{{ url_for('diagnostics.rotate_keys') }}" data-no-guard>
|
<form method="post" action="{{ url_for('diagnostics.rotate_keys') }}" data-no-guard>
|
||||||
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
|
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
|
||||||
<button class="btn btn--sm" type="submit"
|
<button class="btn btn--sm" type="submit"
|
||||||
data-confirm="Neuen Datenschluessel anlegen und gespeicherte Geheimnisse neu verschluesseln? Der alte Schluessel bleibt zum Lesen erhalten."
|
data-confirm="Neuen Datenschlüssel anlegen und gespeicherte Geheimnisse neu verschlüsseln? Der alte Schlüssel bleibt zum Lesen erhalten."
|
||||||
data-confirm-title="Schluessel rotieren" data-confirm-ok="Rotieren" data-confirm-style="primary">
|
data-confirm-title="Schlüssel rotieren" data-confirm-ok="Rotieren" data-confirm-style="primary">
|
||||||
{{ icon('refresh', size=14) }} Rotieren
|
{{ icon('refresh', size=14) }} Rotieren
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
@@ -63,8 +63,8 @@
|
|||||||
</ul>
|
</ul>
|
||||||
{% else %}
|
{% else %}
|
||||||
<p class="muted fs-sm">
|
<p class="muted fs-sm">
|
||||||
Alle Schluesseldateien liegen mit Modus 0600. Feldverschluesselung ist AES-256-GCM mit
|
Alle Schlüsseldateien liegen mit Modus 0600. Feldverschlüsselung ist AES-256-GCM mit
|
||||||
rotierbarem Schluesselbund; jedes Chiffrat ist an seinen Verwendungszweck gebunden.
|
rotierbarem Schlüsselbund; jedes Chiffrat ist an seinen Verwendungszweck gebunden.
|
||||||
</p>
|
</p>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
@@ -87,7 +87,7 @@
|
|||||||
<th class="sortable">Komponente</th>
|
<th class="sortable">Komponente</th>
|
||||||
<th class="sortable numeric">Version</th>
|
<th class="sortable numeric">Version</th>
|
||||||
<th class="sortable">Migration</th>
|
<th class="sortable">Migration</th>
|
||||||
<th>Pruefsumme</th>
|
<th>Prüfsumme</th>
|
||||||
<th class="sortable">Angewendet</th>
|
<th class="sortable">Angewendet</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
@@ -112,7 +112,7 @@
|
|||||||
<div class="flex-1">
|
<div class="flex-1">
|
||||||
<div class="card__title">
|
<div class="card__title">
|
||||||
{{ icon('server', size=17) }} Systemzugriff
|
{{ icon('server', size=17) }} Systemzugriff
|
||||||
{% if not sysops_available %}{{ pill('nicht verfuegbar', 'warning') }}
|
{% if not sysops_available %}{{ pill('nicht verfügbar', 'warning') }}
|
||||||
{% elif sysops_selftest and sysops_selftest.ok %}{{ pill('Helfer antwortet', 'online') }}
|
{% elif sysops_selftest and sysops_selftest.ok %}{{ pill('Helfer antwortet', 'online') }}
|
||||||
{% else %}{{ pill('Helfer antwortet nicht', 'danger') }}{% endif %}
|
{% else %}{{ pill('Helfer antwortet nicht', 'danger') }}{% endif %}
|
||||||
</div>
|
</div>
|
||||||
@@ -121,7 +121,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="card__body">
|
<div class="card__body">
|
||||||
<p class="muted fs-sm">
|
<p class="muted fs-sm">
|
||||||
Der Webprozess laeuft unprivilegiert. Alles, was Root-Rechte braucht, geht durch genau
|
Der Webprozess läuft unprivilegiert. Alles, was Root-Rechte braucht, geht durch genau
|
||||||
ein Helferskript mit Positivliste – nicht durch die Anwendung selbst.
|
ein Helferskript mit Positivliste – nicht durch die Anwendung selbst.
|
||||||
</p>
|
</p>
|
||||||
{% if not sysops_available %}
|
{% if not sysops_available %}
|
||||||
@@ -129,10 +129,10 @@
|
|||||||
Alles andere funktioniert normal -- das ist der erwartete Zustand in einer
|
Alles andere funktioniert normal -- das ist der erwartete Zustand in einer
|
||||||
Entwicklungsumgebung.', 'info') }}
|
Entwicklungsumgebung.', 'info') }}
|
||||||
{% elif sysops_selftest and not sysops_selftest.ok %}
|
{% elif sysops_selftest and not sysops_selftest.ok %}
|
||||||
{# Geprueft wird ein echter Aufruf, nicht die blosse Existenz der Datei.
|
{# Geprüft wird ein echter Aufruf, nicht die blosse Existenz der Datei.
|
||||||
Genau daran lag es einmal: die Seite meldete "erreichbar", waehrend
|
Genau daran lag es einmal: die Seite meldete "erreichbar", während
|
||||||
jeder Aufruf scheiterte. #}
|
jeder Aufruf scheiterte. #}
|
||||||
{{ alert(sysops_selftest.message or 'Der Helfer laesst sich nicht aufrufen.',
|
{{ alert(sysops_selftest.message or 'Der Helfer lässt sich nicht aufrufen.',
|
||||||
'danger', 'Systemaktionen funktionieren nicht') }}
|
'danger', 'Systemaktionen funktionieren nicht') }}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
@@ -144,7 +144,7 @@
|
|||||||
{% if current_user.is_admin %}
|
{% if current_user.is_admin %}
|
||||||
<form method="post" action="{{ url_for('diagnostics.housekeeping') }}" data-no-guard>
|
<form method="post" action="{{ url_for('diagnostics.housekeeping') }}" data-no-guard>
|
||||||
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
|
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
|
||||||
<button class="btn btn--sm" type="submit">{{ icon('refresh', size=14) }} Aufraeumen</button>
|
<button class="btn btn--sm" type="submit">{{ icon('refresh', size=14) }} Aufräumen</button>
|
||||||
</form>
|
</form>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
@@ -167,14 +167,14 @@
|
|||||||
{{ icon('key', size=17) }} Lizenzzustand
|
{{ icon('key', size=17) }} Lizenzzustand
|
||||||
{% if license_state.valid and license_state.activated %}{{ pill('aktiv', 'online') }}
|
{% if license_state.valid and license_state.activated %}{{ pill('aktiv', 'online') }}
|
||||||
{% elif license_state.valid %}{{ pill('nicht aktiviert', 'warning') }}
|
{% elif license_state.valid %}{{ pill('nicht aktiviert', 'warning') }}
|
||||||
{% else %}{{ pill('ungueltig', 'danger') }}{% endif %}
|
{% else %}{{ pill('ungültig', 'danger') }}{% endif %}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="card__body">
|
<div class="card__body">
|
||||||
<dl class="kv fs-sm">
|
<dl class="kv fs-sm">
|
||||||
<dt>Datei vorhanden</dt><dd>{{ license_state.present | yes_no }}</dd>
|
<dt>Datei vorhanden</dt><dd>{{ license_state.present | yes_no }}</dd>
|
||||||
<dt>Signatur gueltig</dt><dd>{{ license_state.valid | yes_no }}</dd>
|
<dt>Signatur gültig</dt><dd>{{ license_state.valid | yes_no }}</dd>
|
||||||
<dt>Aussteller gepinnt</dt><dd>{{ license_state.issuer_pinned | yes_no }}</dd>
|
<dt>Aussteller gepinnt</dt><dd>{{ license_state.issuer_pinned | yes_no }}</dd>
|
||||||
<dt>Rolle</dt><dd>{{ license_state.role }}</dd>
|
<dt>Rolle</dt><dd>{{ license_state.role }}</dd>
|
||||||
<dt>Module</dt><dd>{{ license_state.modules | sort | join(', ') or '–' | safe }}</dd>
|
<dt>Module</dt><dd>{{ license_state.modules | sort | join(', ') or '–' | safe }}</dd>
|
||||||
|
|||||||
@@ -7,11 +7,11 @@
|
|||||||
<div class="alert__body">{{ description }}</div>
|
<div class="alert__body">{{ description }}</div>
|
||||||
</div>
|
</div>
|
||||||
{% if request_id %}
|
{% if request_id %}
|
||||||
<p class="fs-xs faint center">Vorgangsnummer fuer die Fehlersuche: <span class="mono">{{ request_id }}</span></p>
|
<p class="fs-xs faint center">Vorgangsnummer für die Fehlersuche: <span class="mono">{{ request_id }}</span></p>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<div class="row row--end">
|
<div class="row row--end">
|
||||||
<a class="btn" href="{{ url_for('auth.login') if code in (401, 403) and not current_user.is_authenticated else back_url }}">
|
<a class="btn" href="{{ url_for('auth.login') if code in (401, 403) and not current_user.is_authenticated else back_url }}">
|
||||||
{{ 'Zur Anmeldung' if code in (401, 403) and not current_user.is_authenticated else 'Zurueck' }}
|
{{ 'Zur Anmeldung' if code in (401, 403) and not current_user.is_authenticated else 'Zurück' }}
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -20,8 +20,8 @@
|
|||||||
{{ icon('lock', size=18) }}
|
{{ icon('lock', size=18) }}
|
||||||
<div class="alert__body">
|
<div class="alert__body">
|
||||||
<div class="alert__title">Systemgruppe</div>
|
<div class="alert__title">Systemgruppe</div>
|
||||||
Name und Rechte sind gesperrt. Anders als im Vorgaenger werden die Rechte
|
Name und Rechte sind gesperrt. Anders als im Vorgänger werden die Rechte
|
||||||
<strong>nicht</strong> bei jedem Neustart auf die Vorlage zurueckgesetzt –
|
<strong>nicht</strong> bei jedem Neustart auf die Vorlage zurückgesetzt –
|
||||||
Anpassungen bleiben erhalten.
|
Anpassungen bleiben erhalten.
|
||||||
{% if current_user.has('groups.edit') %}
|
{% if current_user.has('groups.edit') %}
|
||||||
<a class="btn btn--sm mt-2" href="{{ url_for('admin.group_detail', group_id=group.id, freischalten=1) }}">
|
<a class="btn btn--sm mt-2" href="{{ url_for('admin.group_detail', group_id=group.id, freischalten=1) }}">
|
||||||
@@ -49,8 +49,8 @@
|
|||||||
{% if group.preset_key and current_user.is_admin %}
|
{% if group.preset_key and current_user.is_admin %}
|
||||||
<button class="btn btn--sm" type="submit"
|
<button class="btn btn--sm" type="submit"
|
||||||
formaction="{{ url_for('admin.group_apply_preset', group_id=group.id) }}"
|
formaction="{{ url_for('admin.group_apply_preset', group_id=group.id) }}"
|
||||||
data-confirm="Rechte dieser Gruppe auf die Vorlage zuruecksetzen? Bestehende Anpassungen gehen verloren."
|
data-confirm="Rechte dieser Gruppe auf die Vorlage zurücksetzen? Bestehende Anpassungen gehen verloren."
|
||||||
data-confirm-title="Vorlage anwenden" data-confirm-ok="Zuruecksetzen" data-confirm-style="primary">
|
data-confirm-title="Vorlage anwenden" data-confirm-ok="Zurücksetzen" data-confirm-style="primary">
|
||||||
{{ icon('refresh', size=14) }} Vorlage anwenden
|
{{ icon('refresh', size=14) }} Vorlage anwenden
|
||||||
</button>
|
</button>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
@@ -58,7 +58,7 @@
|
|||||||
<div class="card__body">
|
<div class="card__body">
|
||||||
<div class="form-grid">
|
<div class="form-grid">
|
||||||
{{ field('name', 'Name', value=group.name, required=True, disabled=group.is_system,
|
{{ field('name', 'Name', value=group.name, required=True, disabled=group.is_system,
|
||||||
hint_text='Systemgruppen koennen nicht umbenannt werden.' if group.is_system else '') }}
|
hint_text='Systemgruppen können nicht umbenannt werden.' if group.is_system else '') }}
|
||||||
{{ textarea_field('description', 'Beschreibung', value=group.description, rows=2,
|
{{ textarea_field('description', 'Beschreibung', value=group.description, rows=2,
|
||||||
extra_class='field--full') }}
|
extra_class='field--full') }}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
{% from "tesm_core/macros.html" import field, textarea_field, pill, modal, alert, permission_matrix, empty_state %}
|
{% from "tesm_core/macros.html" import field, textarea_field, pill, modal, alert, permission_matrix, empty_state %}
|
||||||
|
|
||||||
{% block page_title %}Gruppen und Rechte{% endblock %}
|
{% block page_title %}Gruppen und Rechte{% endblock %}
|
||||||
{% block page_sub %}{{ groups | length }} Gruppen · Rechte addieren sich ueber alle Gruppen eines Benutzers{% endblock %}
|
{% block page_sub %}{{ groups | length }} Gruppen · Rechte addieren sich über alle Gruppen eines Benutzers{% endblock %}
|
||||||
|
|
||||||
{% block topbar_extra %}
|
{% block topbar_extra %}
|
||||||
{% if current_user.has('groups.create') %}
|
{% if current_user.has('groups.create') %}
|
||||||
@@ -13,9 +13,9 @@
|
|||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
{{ alert('Wer selbst ein Recht nicht besitzt, kann es auch nicht vergeben. Diese Regel gilt fuer
|
{{ alert('Wer selbst ein Recht nicht besitzt, kann es auch nicht vergeben. Diese Regel gilt für
|
||||||
alle ausser echten Administratoren und verhindert, dass sich ein Gruppenverwalter ueber eine
|
alle ausser echten Administratoren und verhindert, dass sich ein Gruppenverwalter über eine
|
||||||
neue Gruppe selbst hoeherstuft.', 'info', 'Eskalationsschutz') }}
|
neue Gruppe selbst höherstuft.', 'info', 'Eskalationsschutz') }}
|
||||||
|
|
||||||
<div class="grid grid--2">
|
<div class="grid grid--2">
|
||||||
{% for group in groups %}
|
{% for group in groups %}
|
||||||
@@ -37,9 +37,9 @@ neue Gruppe selbst hoeherstuft.', 'info', 'Eskalationsschutz') }}
|
|||||||
<form method="post" action="{{ url_for('admin.group_delete', group_id=group.id) }}" data-no-guard>
|
<form method="post" action="{{ url_for('admin.group_delete', group_id=group.id) }}" data-no-guard>
|
||||||
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
|
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
|
||||||
<button class="btn btn--sm btn--ghost danger-text" type="submit"
|
<button class="btn btn--sm btn--ghost danger-text" type="submit"
|
||||||
data-confirm="Gruppe {{ group.name }} loeschen? Sie landet im Papierkorb; Mitglieder verlieren die zugehoerigen Rechte."
|
data-confirm="Gruppe {{ group.name }} löschen? Sie landet im Papierkorb; Mitglieder verlieren die zugehörigen Rechte."
|
||||||
data-confirm-title="Gruppe loeschen" data-confirm-ok="Loeschen"
|
data-confirm-title="Gruppe löschen" data-confirm-ok="Löschen"
|
||||||
aria-label="Loeschen">{{ icon('trash', size=15) }}</button>
|
aria-label="Löschen">{{ icon('trash', size=15) }}</button>
|
||||||
</form>
|
</form>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
@@ -67,7 +67,7 @@ neue Gruppe selbst hoeherstuft.', 'info', 'Eskalationsschutz') }}
|
|||||||
|
|
||||||
{% block dialogs %}
|
{% block dialogs %}
|
||||||
{% if current_user.has('groups.create') %}
|
{% if current_user.has('groups.create') %}
|
||||||
{% call modal('group-new', 'Gruppe anlegen', 'Rechte koennen anschliessend feiner eingestellt werden', 'wide') %}
|
{% call modal('group-new', 'Gruppe anlegen', 'Rechte können anschliessend feiner eingestellt werden', 'wide') %}
|
||||||
<form method="post" action="{{ url_for('admin.group_create') }}">
|
<form method="post" action="{{ url_for('admin.group_create') }}">
|
||||||
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
|
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
|
||||||
<div class="modal__body">
|
<div class="modal__body">
|
||||||
|
|||||||
@@ -24,6 +24,6 @@
|
|||||||
</p>
|
</p>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% if public_dashboard_url %}
|
{% if public_dashboard_url %}
|
||||||
<p class="center fs-sm"><a href="{{ public_dashboard_url }}">Zur oeffentlichen Statusuebersicht</a></p>
|
<p class="center fs-sm"><a href="{{ public_dashboard_url }}">Zur öffentlichen Statusübersicht</a></p>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{% extends "tesm_core/base.html" %}
|
{% extends "tesm_core/base.html" %}
|
||||||
{% block page_title %}Archiv {{ day }}{% endblock %}
|
{% block page_title %}Archiv {{ day }}{% endblock %}
|
||||||
{% block page_sub %}{{ rows | length }} archivierte Aenderungen{% endblock %}
|
{% block page_sub %}{{ rows | length }} archivierte Änderungen{% endblock %}
|
||||||
{% block breadcrumb %}
|
{% block breadcrumb %}
|
||||||
<nav class="breadcrumb"><span><a href="{{ url_for('logs.history') }}">Verlauf</a></span><span>{{ day }}</span></nav>
|
<nav class="breadcrumb"><span><a href="{{ url_for('logs.history') }}">Verlauf</a></span><span>{{ day }}</span></nav>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
@@ -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 %}
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
{% extends "tesm_core/base.html" %}
|
{% extends "tesm_core/base.html" %}
|
||||||
{% from "tesm_core/macros.html" import pill, empty_state, alert %}
|
{% from "tesm_core/macros.html" import pill, empty_state, alert %}
|
||||||
|
|
||||||
{% block page_title %}Aenderungsprotokoll{% endblock %}
|
{% block page_title %}Änderungsprotokoll{% endblock %}
|
||||||
{% block page_sub %}{{ total }} Eintraege · Hash-Kette {{ 'unversehrt' if chain.ok else 'gebrochen' }}{% endblock %}
|
{% block page_sub %}{{ total }} Einträge · Hash-Kette {{ 'unversehrt' if chain.ok else 'gebrochen' }}{% endblock %}
|
||||||
|
|
||||||
{% block topbar_extra %}
|
{% block topbar_extra %}
|
||||||
{% if current_user.has('logs_audit.export') %}
|
{% if current_user.has('logs_audit.export') %}
|
||||||
@@ -13,10 +13,10 @@
|
|||||||
{% block content %}
|
{% block content %}
|
||||||
{% if not chain.ok %}
|
{% if not chain.ok %}
|
||||||
{{ alert(chain.reason ~ ' Erster Bruch bei Eintrag ' ~ chain.broken_at ~ '.', 'danger',
|
{{ alert(chain.reason ~ ' Erster Bruch bei Eintrag ' ~ chain.broken_at ~ '.', 'danger',
|
||||||
'Das Protokoll wurde veraendert') }}
|
'Das Protokoll wurde verändert') }}
|
||||||
{% else %}
|
{% else %}
|
||||||
{{ alert('Jeder Eintrag bindet den Hash seines Vorgaengers ein. Eine nachtraeglich entfernte
|
{{ alert('Jeder Eintrag bindet den Hash seines Vorgängers ein. Eine nachträglich entfernte
|
||||||
oder geaenderte Zeile faellt bei der Pruefung auf -- ' ~ chain.checked ~ ' Zeilen geprueft.',
|
oder geänderte Zeile fällt bei der Prüfung auf -- ' ~ chain.checked ~ ' Zeilen geprüft.',
|
||||||
'success') }}
|
'success') }}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
@@ -41,7 +41,7 @@
|
|||||||
<input class="input" type="text" name="benutzer" value="{{ filters.benutzer }}"
|
<input class="input" type="text" name="benutzer" value="{{ filters.benutzer }}"
|
||||||
placeholder="Benutzer" style="width:150px">
|
placeholder="Benutzer" style="width:150px">
|
||||||
<button class="btn btn--sm" type="submit">Filtern</button>
|
<button class="btn btn--sm" type="submit">Filtern</button>
|
||||||
<a class="btn btn--sm btn--ghost" href="{{ url_for('logs.audit_view') }}">Zuruecksetzen</a>
|
<a class="btn btn--sm btn--ghost" href="{{ url_for('logs.audit_view') }}">Zurücksetzen</a>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
<div class="table-wrap">
|
<div class="table-wrap">
|
||||||
@@ -64,12 +64,17 @@
|
|||||||
<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>
|
||||||
{% if row.severity == 'critical' %}{{ pill('kritisch', 'danger') }}
|
{# Ampel: Info grün, Hinweis gelb, Warnung rot. Vorher war Info
|
||||||
{% elif row.severity == 'warning' %}{{ pill('Warnung', 'warning') }}
|
grau und Hinweis blau -- dabei war nicht zu sehen, was harmlos
|
||||||
{% elif row.severity == 'notice' %}{{ pill('Hinweis', 'info') }}
|
ist und was Aufmerksamkeit braucht. "kritisch" bekommt eine
|
||||||
{% else %}{{ pill('Info', '') }}{% endif %}
|
eigene, gefüllte Darstellung, sonst wäre es von "Warnung"
|
||||||
|
nicht zu unterscheiden. #}
|
||||||
|
{% if row.severity == 'critical' %}{{ pill('kritisch', 'critical') }}
|
||||||
|
{% elif row.severity == 'warning' %}{{ pill('Warnung', 'danger') }}
|
||||||
|
{% elif row.severity == 'notice' %}{{ pill('Hinweis', 'warning') }}
|
||||||
|
{% else %}{{ pill('Info', 'online') }}{% endif %}
|
||||||
</td>
|
</td>
|
||||||
<td class="mono fs-xs faint">{{ row.ip or '–' | safe }}</td>
|
<td class="mono fs-xs faint">{{ row.ip or '–' | safe }}</td>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -77,13 +82,13 @@
|
|||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
{% if not rows %}{{ empty_state('Keine Eintraege', 'Zu diesem Filter gibt es nichts.', 'history') }}{% endif %}
|
{% if not rows %}{{ empty_state('Keine Einträge', 'Zu diesem Filter gibt es nichts.', 'history') }}{% endif %}
|
||||||
|
|
||||||
<div class="card__footer" style="justify-content:space-between">
|
<div class="card__footer" style="justify-content:space-between">
|
||||||
<span class="fs-sm muted">Seite {{ page }}</span>
|
<span class="fs-sm muted">Seite {{ page }}</span>
|
||||||
<span class="row row--tight">
|
<span class="row row--tight">
|
||||||
{% if page > 1 %}
|
{% if page > 1 %}
|
||||||
<a class="btn btn--sm" href="{{ url_for('logs.audit_view', seite=page-1, **filters) }}">Zurueck</a>
|
<a class="btn btn--sm" href="{{ url_for('logs.audit_view', seite=page-1, **filters) }}">Zurück</a>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% if rows | length == per_page %}
|
{% if rows | length == per_page %}
|
||||||
<a class="btn btn--sm" href="{{ url_for('logs.audit_view', seite=page+1, **filters) }}">Weiter</a>
|
<a class="btn btn--sm" href="{{ url_for('logs.audit_view', seite=page+1, **filters) }}">Weiter</a>
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
{{ alert('Rotiert wird per logrotate mit copytruncate -- keiner der schreibenden Prozesse
|
{{ alert('Rotiert wird per logrotate mit copytruncate -- keiner der schreibenden Prozesse
|
||||||
(Ueberwachung, Web, Kea) kann ein Reopen-Signal verarbeiten.', 'info') }}
|
(Überwachung, Web, Kea) kann ein Reopen-Signal verarbeiten.', 'info') }}
|
||||||
|
|
||||||
<div class="grid grid--2">
|
<div class="grid grid--2">
|
||||||
<section class="card">
|
<section class="card">
|
||||||
@@ -19,7 +19,7 @@
|
|||||||
{% if rotated %}
|
{% if rotated %}
|
||||||
<div class="table-wrap">
|
<div class="table-wrap">
|
||||||
<table class="table table--compact">
|
<table class="table table--compact">
|
||||||
<thead><tr><th>Datei</th><th class="numeric">Groesse</th><th>Geaendert</th></tr></thead>
|
<thead><tr><th>Datei</th><th class="numeric">Grösse</th><th>Geändert</th></tr></thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{% for entry in rotated %}
|
{% for entry in rotated %}
|
||||||
<tr>
|
<tr>
|
||||||
@@ -39,7 +39,7 @@
|
|||||||
<section class="card">
|
<section class="card">
|
||||||
<div class="card__header">
|
<div class="card__header">
|
||||||
<div class="flex-1">
|
<div class="flex-1">
|
||||||
<div class="card__title">{{ icon('history', size=17) }} Archivierte Aenderungen</div>
|
<div class="card__title">{{ icon('history', size=17) }} Archivierte Änderungen</div>
|
||||||
<div class="card__sub">Aus der Datenbank ausgelagerte Kalendertage</div>
|
<div class="card__sub">Aus der Datenbank ausgelagerte Kalendertage</div>
|
||||||
</div>
|
</div>
|
||||||
{% if archived and current_user.has('logs_history.export') %}
|
{% if archived and current_user.has('logs_history.export') %}
|
||||||
@@ -52,7 +52,7 @@
|
|||||||
{% if archived %}
|
{% if archived %}
|
||||||
<div class="table-wrap">
|
<div class="table-wrap">
|
||||||
<table class="table table--compact">
|
<table class="table table--compact">
|
||||||
<thead><tr><th>Tag</th><th class="numeric">Groesse</th><th></th></tr></thead>
|
<thead><tr><th>Tag</th><th class="numeric">Grösse</th><th></th></tr></thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{% for entry in archived %}
|
{% for entry in archived %}
|
||||||
<tr>
|
<tr>
|
||||||
@@ -69,8 +69,8 @@
|
|||||||
<form method="post" action="{{ url_for('logs.history_purge') }}" data-no-guard>
|
<form method="post" action="{{ url_for('logs.history_purge') }}" data-no-guard>
|
||||||
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
|
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
|
||||||
<button class="btn btn--sm btn--ghost danger-text" type="submit"
|
<button class="btn btn--sm btn--ghost danger-text" type="submit"
|
||||||
data-confirm="Alle {{ archived | length }} Archivdateien endgueltig vom Server loeschen? Bitte vorher als ZIP sichern."
|
data-confirm="Alle {{ archived | length }} Archivdateien endgültig vom Server löschen? Bitte vorher als ZIP sichern."
|
||||||
data-confirm-title="Archiv loeschen" data-confirm-ok="Loeschen">
|
data-confirm-title="Archiv löschen" data-confirm-ok="Löschen">
|
||||||
Archiv vom Server entfernen
|
Archiv vom Server entfernen
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
@@ -51,8 +51,22 @@
|
|||||||
</label>
|
</label>
|
||||||
{%- endmacro %}
|
{%- endmacro %}
|
||||||
|
|
||||||
|
{# Ein nicht angehakter Haken sendet **nichts**. Wer die gesendeten Felder
|
||||||
|
auswertet, sieht ihn deshalb als "nicht angegeben" -- und liess den alten
|
||||||
|
Wert stehen: Haken raus, speichern, "keine Änderungen". Diese versteckte
|
||||||
|
Marke sagt "dieses Feld war im Formular", der Haken selbst sagt "an".
|
||||||
|
Damit sind alle drei Fälle unterscheidbar: an, aus, nicht Teil des
|
||||||
|
Formulars (Teilformulare dürfen fremde Schalter nicht löschen).
|
||||||
|
|
||||||
|
Kein ``disabled``-Fall: ein deaktivierter Haken darf auch nicht als "aus"
|
||||||
|
gespeichert werden, also fällt die Marke dann ebenfalls weg. #}
|
||||||
|
{% macro _present(name, disabled=False) -%}
|
||||||
|
{% if not disabled %}<input type="hidden" name="{{ name }}__present" value="1">{% endif %}
|
||||||
|
{%- endmacro %}
|
||||||
|
|
||||||
{% macro checkbox(name, label, checked=False, value='1', hint_text='', disabled=False) -%}
|
{% macro checkbox(name, label, checked=False, value='1', hint_text='', disabled=False) -%}
|
||||||
<label class="check {% if disabled %}check--disabled{% endif %}">
|
<label class="check {% if disabled %}check--disabled{% endif %}">
|
||||||
|
{{ _present(name, disabled) }}
|
||||||
<input type="checkbox" name="{{ name }}" value="{{ value }}"
|
<input type="checkbox" name="{{ name }}" value="{{ value }}"
|
||||||
{% if checked %}checked{% endif %} {% if disabled %}disabled{% endif %}>
|
{% if checked %}checked{% endif %} {% if disabled %}disabled{% endif %}>
|
||||||
<span>{{ label }} {{ hint(hint_text) }}</span>
|
<span>{{ label }} {{ hint(hint_text) }}</span>
|
||||||
@@ -61,6 +75,7 @@
|
|||||||
|
|
||||||
{% macro switch(name, label, checked=False, value='1', disabled=False) -%}
|
{% macro switch(name, label, checked=False, value='1', disabled=False) -%}
|
||||||
<label class="switch">
|
<label class="switch">
|
||||||
|
{{ _present(name, disabled) }}
|
||||||
<input type="checkbox" name="{{ name }}" value="{{ value }}"
|
<input type="checkbox" name="{{ name }}" value="{{ value }}"
|
||||||
{% if checked %}checked{% endif %} {% if disabled %}disabled{% endif %}>
|
{% if checked %}checked{% endif %} {% if disabled %}disabled{% endif %}>
|
||||||
<span>{{ label }}</span>
|
<span>{{ label }}</span>
|
||||||
@@ -196,12 +211,12 @@
|
|||||||
<span class="sr-only">{{ resource.label }} {{ action_meta[action][1] }}</span>
|
<span class="sr-only">{{ resource.label }} {{ action_meta[action][1] }}</span>
|
||||||
</label>
|
</label>
|
||||||
{% else %}
|
{% else %}
|
||||||
{# Bewusst *kein* Eingabefeld: ein deaktiviertes Kaestchen hat das
|
{# Bewusst *kein* Eingabefeld: ein deaktiviertes Kästchen hat das
|
||||||
JS beim Einschalten des Bereichs wieder freigegeben, es liess
|
JS beim Einschalten des Bereichs wieder freigegeben, es liess
|
||||||
sich anhaken, hatte aber kein name-Attribut -- es sah erteilt
|
sich anhaken, hatte aber kein name-Attribut -- es sah erteilt
|
||||||
aus und war nach dem Speichern weg. Was es nicht gibt, ist
|
aus und war nach dem Speichern weg. Was es nicht gibt, ist
|
||||||
hier deshalb gar kein Bedienelement. #}
|
hier deshalb gar kein Bedienelement. #}
|
||||||
<span class="matrix__na" title="Fuer diese Zeile nicht vorgesehen."></span>
|
<span class="matrix__na" title="Für diese Zeile nicht vorgesehen."></span>
|
||||||
<span class="sr-only">nicht vorgesehen</span>
|
<span class="sr-only">nicht vorgesehen</span>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</td>
|
</td>
|
||||||
@@ -216,7 +231,7 @@
|
|||||||
{% for action, meta in action_meta.items() %}
|
{% for action, meta in action_meta.items() %}
|
||||||
<span><b>{{ meta[0] }}</b> {{ meta[1] }} – {{ meta[2] }}</span>
|
<span><b>{{ meta[0] }}</b> {{ meta[1] }} – {{ meta[2] }}</span>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
<span><span class="matrix__na"></span> Fuer diese Zeile nicht vorgesehen –
|
<span><span class="matrix__na"></span> Für diese Zeile nicht vorgesehen –
|
||||||
die Spalte gehoert zu einer anderen Zeile desselben Bereichs.</span>
|
die Spalte gehört zu einer anderen Zeile desselben Bereichs.</span>
|
||||||
</div>
|
</div>
|
||||||
{%- endmacro %}
|
{%- endmacro %}
|
||||||
|
|||||||
@@ -1,13 +1,13 @@
|
|||||||
{% extends "tesm_core/standalone.html" %}
|
{% extends "tesm_core/standalone.html" %}
|
||||||
{% block heading %}Bitte bestaetigen{% endblock %}
|
{% block heading %}Bitte bestätigen{% endblock %}
|
||||||
{% block subheading %}Diese Aktion verlangt eine erneute Eingabe Ihres Passworts{% endblock %}
|
{% block subheading %}Diese Aktion verlangt eine erneute Eingabe Ihres Passworts{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<div class="alert alert--info">
|
<div class="alert alert--info">
|
||||||
{{ icon('info', size=18) }}
|
{{ icon('info', size=18) }}
|
||||||
<div class="alert__body">
|
<div class="alert__body">
|
||||||
Sie greifen auf Geheimnisse oder einen Datenexport zu. Ein uebernommenes
|
Sie greifen auf Geheimnisse oder einen Datenexport zu. Ein übernommenes
|
||||||
Sitzungscookie allein reicht dafuer nicht aus – die Bestaetigung gilt
|
Sitzungscookie allein reicht dafür nicht aus – die Bestätigung gilt
|
||||||
danach {{ reauth_minutes }} Minuten.
|
danach {{ reauth_minutes }} Minuten.
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -19,7 +19,7 @@
|
|||||||
</label>
|
</label>
|
||||||
<div class="row row--end">
|
<div class="row row--end">
|
||||||
<a class="btn" href="{{ cancel_url }}">Abbrechen</a>
|
<a class="btn" href="{{ cancel_url }}">Abbrechen</a>
|
||||||
<button class="btn btn--primary" type="submit">Bestaetigen</button>
|
<button class="btn btn--primary" type="submit">Bestätigen</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -11,10 +11,10 @@
|
|||||||
inputmode="numeric" autocomplete="one-time-code" maxlength="14"
|
inputmode="numeric" autocomplete="one-time-code" maxlength="14"
|
||||||
placeholder="123456" style="letter-spacing:.22em;text-align:center;font-size:1.15rem">
|
placeholder="123456" style="letter-spacing:.22em;text-align:center;font-size:1.15rem">
|
||||||
<span class="field__hint">
|
<span class="field__hint">
|
||||||
Alternativ koennen Sie einen Ihrer Wiederherstellungscodes eingeben.
|
Alternativ können Sie einen Ihrer Wiederherstellungscodes eingeben.
|
||||||
</span>
|
</span>
|
||||||
</label>
|
</label>
|
||||||
<button class="btn btn--primary btn--lg btn--block" type="submit">Bestaetigen</button>
|
<button class="btn btn--primary btn--lg btn--block" type="submit">Bestätigen</button>
|
||||||
</form>
|
</form>
|
||||||
<p class="center fs-sm"><a href="{{ url_for('auth.logout_get') }}">Abbrechen und zurueck zur Anmeldung</a></p>
|
<p class="center fs-sm"><a href="{{ url_for('auth.logout_get') }}">Abbrechen und zurück zur Anmeldung</a></p>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -8,7 +8,7 @@
|
|||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
{{ alert('Sitzungen liegen in der Datenbank, nicht im Cookie. Ein widerrufener Eintrag ist sofort
|
{{ alert('Sitzungen liegen in der Datenbank, nicht im Cookie. Ein widerrufener Eintrag ist sofort
|
||||||
ungueltig -- auch wenn das Cookie noch existiert.', 'info') }}
|
ungültig -- auch wenn das Cookie noch existiert.', 'info') }}
|
||||||
|
|
||||||
<section class="card">
|
<section class="card">
|
||||||
<div class="table-wrap">
|
<div class="table-wrap">
|
||||||
@@ -17,8 +17,8 @@ ungueltig -- auch wenn das Cookie noch existiert.', 'info') }}
|
|||||||
<tr>
|
<tr>
|
||||||
<th class="sortable">Benutzer</th>
|
<th class="sortable">Benutzer</th>
|
||||||
<th class="sortable">Angemeldet</th>
|
<th class="sortable">Angemeldet</th>
|
||||||
<th class="sortable">Letzte Aktivitaet</th>
|
<th class="sortable">Letzte Aktivität</th>
|
||||||
<th class="sortable">Laeuft ab</th>
|
<th class="sortable">Läuft ab</th>
|
||||||
<th class="sortable">IP</th>
|
<th class="sortable">IP</th>
|
||||||
<th>Client</th>
|
<th>Client</th>
|
||||||
<th class="actions">Aktion</th>
|
<th class="actions">Aktion</th>
|
||||||
|
|||||||
@@ -12,16 +12,16 @@
|
|||||||
{% if license_hint %}{{ alert(license_hint, 'warning') }}{% endif %}
|
{% if license_hint %}{{ alert(license_hint, 'warning') }}{% endif %}
|
||||||
|
|
||||||
{{ alert('Same Sign-On, nicht Single Sign-On: der Benutzer tippt sein Verzeichnispasswort auf der
|
{{ alert('Same Sign-On, nicht Single Sign-On: der Benutzer tippt sein Verzeichnispasswort auf der
|
||||||
normalen Anmeldeseite ein. Es wird bei jeder Anmeldung live gegen das Verzeichnis geprueft und
|
normalen Anmeldeseite ein. Es wird bei jeder Anmeldung live gegen das Verzeichnis geprüft und
|
||||||
niemals hier gespeichert.', 'info') }}
|
niemals hier gespeichert.', 'info') }}
|
||||||
|
|
||||||
{% if values.ldap_security == 'none' %}
|
{% if values.ldap_security == 'none' %}
|
||||||
{{ alert('Die Verbindung ist unverschluesselt. Das Passwort geht dabei im Klartext ueber das
|
{{ alert('Die Verbindung ist unverschlüsselt. Das Passwort geht dabei im Klartext über das
|
||||||
Netz -- nur fuer Testumgebungen vertretbar.', 'danger', 'Unverschluesselt') }}
|
Netz -- nur für Testumgebungen vertretbar.', 'danger', 'Unverschlüsselt') }}
|
||||||
{% elif not values.ldap_verify_cert %}
|
{% elif not values.ldap_verify_cert %}
|
||||||
{{ alert('Das Serverzertifikat wird nicht geprueft. Damit schuetzt die Verschluesselung nur
|
{{ alert('Das Serverzertifikat wird nicht geprüft. Damit schützt die Verschlüsselung nur
|
||||||
gegen passives Mitlesen, nicht gegen einen Man-in-the-Middle.', 'warning',
|
gegen passives Mitlesen, nicht gegen einen Man-in-the-Middle.', 'warning',
|
||||||
'Zertifikat ungeprueft') }}
|
'Zertifikat ungeprüft') }}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
<div class="grid grid--split">
|
<div class="grid grid--split">
|
||||||
@@ -48,8 +48,8 @@ niemals hier gespeichert.', 'info') }}
|
|||||||
{{ field('ldap_port', 'Port', value=values.ldap_port, type='number',
|
{{ field('ldap_port', 'Port', value=values.ldap_port, type='number',
|
||||||
disabled=bool(license_hint), attrs='id="ldap-port"') }}
|
disabled=bool(license_hint), attrs='id="ldap-port"') }}
|
||||||
{# Der Transport setzt den Port mit. Wer umstellt und den Port stehen
|
{# Der Transport setzt den Port mit. Wer umstellt und den Port stehen
|
||||||
laesst, bekommt eine Verbindung, die auf gut Glueck scheitert --
|
lässt, bekommt eine Verbindung, die auf gut Glück scheitert --
|
||||||
und die Meldung ("Zeitlimit") sagt nichts ueber die Ursache. Ein
|
und die Meldung ("Zeitlimit") sagt nichts über die Ursache. Ein
|
||||||
absichtlich abweichender Port bleibt erhalten. #}
|
absichtlich abweichender Port bleibt erhalten. #}
|
||||||
{{ select_field('ldap_security', 'Transport', specs['ldap_security'].choices,
|
{{ select_field('ldap_security', 'Transport', specs['ldap_security'].choices,
|
||||||
selected=values.ldap_security, disabled=bool(license_hint),
|
selected=values.ldap_security, disabled=bool(license_hint),
|
||||||
@@ -68,21 +68,21 @@ niemals hier gespeichert.', 'info') }}
|
|||||||
disabled=bool(license_hint)) }}
|
disabled=bool(license_hint)) }}
|
||||||
</div>
|
</div>
|
||||||
<div class="stack stack--tight">
|
<div class="stack stack--tight">
|
||||||
{{ checkbox('ldap_verify_cert', 'Serverzertifikat pruefen', checked=values.ldap_verify_cert,
|
{{ checkbox('ldap_verify_cert', 'Serverzertifikat prüfen', checked=values.ldap_verify_cert,
|
||||||
disabled=bool(license_hint)) }}
|
disabled=bool(license_hint)) }}
|
||||||
{{ checkbox('ldap_group_sync', 'Gruppen aus dem Verzeichnis uebernehmen',
|
{{ checkbox('ldap_group_sync', 'Gruppen aus dem Verzeichnis übernehmen',
|
||||||
checked=values.ldap_group_sync, disabled=bool(license_hint)) }}
|
checked=values.ldap_group_sync, disabled=bool(license_hint)) }}
|
||||||
{{ checkbox('ldap_allow_insecure', 'Unverschluesselte Verbindung ausdruecklich erlauben',
|
{{ checkbox('ldap_allow_insecure', 'Unverschlüsselte Verbindung ausdrücklich erlauben',
|
||||||
checked=values.ldap_allow_insecure, disabled=bool(license_hint),
|
checked=values.ldap_allow_insecure, disabled=bool(license_hint),
|
||||||
hint_text=specs['ldap_allow_insecure'].hint) }}
|
hint_text=specs['ldap_allow_insecure'].hint) }}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<fieldset class="fieldset">
|
<fieldset class="fieldset">
|
||||||
<legend>Dienstkonto fuer die Suche (nur Lesezugriff noetig)</legend>
|
<legend>Dienstkonto für die Suche (nur Lesezugriff nötig)</legend>
|
||||||
<div class="form-grid">
|
<div class="form-grid">
|
||||||
{{ field('bind_username', 'Benutzer', value=bind_username, autocomplete='off',
|
{{ field('bind_username', 'Benutzer', value=bind_username, autocomplete='off',
|
||||||
disabled=not current_user.has('settings_directory.secrets')) }}
|
disabled=not current_user.has('settings_directory.secrets')) }}
|
||||||
{{ field('bind_password', 'Passwort (leer = unveraendert)', type='password',
|
{{ field('bind_password', 'Passwort (leer = unverändert)', type='password',
|
||||||
autocomplete='new-password',
|
autocomplete='new-password',
|
||||||
disabled=not current_user.has('settings_directory.secrets')) }}
|
disabled=not current_user.has('settings_directory.secrets')) }}
|
||||||
</div>
|
</div>
|
||||||
@@ -125,7 +125,7 @@ niemals hier gespeichert.', 'info') }}
|
|||||||
action="{{ url_for('hostsettings.directory_mapping_delete', mapping_id=entry.id) }}">
|
action="{{ url_for('hostsettings.directory_mapping_delete', mapping_id=entry.id) }}">
|
||||||
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
|
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
|
||||||
<button class="btn btn--sm btn--ghost danger-text" type="submit"
|
<button class="btn btn--sm btn--ghost danger-text" type="submit"
|
||||||
data-confirm="Zuordnung entfernen? Betroffene Benutzer verlieren die Gruppe bei der naechsten Anmeldung."
|
data-confirm="Zuordnung entfernen? Betroffene Benutzer verlieren die Gruppe bei der nächsten Anmeldung."
|
||||||
data-confirm-title="Zuordnung entfernen" data-confirm-ok="Entfernen">
|
data-confirm-title="Zuordnung entfernen" data-confirm-ok="Entfernen">
|
||||||
{{ icon('trash', size=14) }}</button>
|
{{ icon('trash', size=14) }}</button>
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
@@ -5,30 +5,30 @@
|
|||||||
{% block page_sub %}Backend: {{ backend }} · Sicherheitsnetz {{ revert_seconds }} s{% endblock %}
|
{% block page_sub %}Backend: {{ backend }} · Sicherheitsnetz {{ revert_seconds }} s{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
{{ alert('Nach einer Aenderung laeuft ein Zeitgeber. Bestaetigen Sie nicht innerhalb von
|
{{ alert('Nach einer Änderung läuft ein Zeitgeber. Bestätigen Sie nicht innerhalb von
|
||||||
' ~ revert_seconds ~ ' Sekunden von der neuen Adresse aus, wird automatisch
|
' ~ revert_seconds ~ ' Sekunden von der neuen Adresse aus, wird automatisch
|
||||||
zurueckgerollt. So sperrt eine falsche Eingabe den Host nicht aus.', 'warning',
|
zurückgerollt. So sperrt eine falsche Eingabe den Host nicht aus.', 'warning',
|
||||||
'Automatischer Rueckfall') }}
|
'Automatischer Rückfall') }}
|
||||||
|
|
||||||
{% if pending %}
|
{% if pending %}
|
||||||
<section class="card card--danger">
|
<section class="card card--danger">
|
||||||
<div class="card__header">
|
<div class="card__header">
|
||||||
<div class="flex-1">
|
<div class="flex-1">
|
||||||
<div class="card__title">{{ icon('alert', size=17) }} Aenderung wartet auf Bestaetigung</div>
|
<div class="card__title">{{ icon('alert', size=17) }} Änderung wartet auf Bestätigung</div>
|
||||||
<div class="card__sub">Interface {{ pending.interface }}</div>
|
<div class="card__sub">Interface {{ pending.interface }}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="card__body">
|
<div class="card__body">
|
||||||
<form method="post" action="{{ url_for('hostsettings.network_confirm') }}" data-no-guard>
|
<form method="post" action="{{ url_for('hostsettings.network_confirm') }}" data-no-guard>
|
||||||
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
|
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
|
||||||
<button class="btn btn--primary" type="submit">Verbindung bestaetigen</button>
|
<button class="btn btn--primary" type="submit">Verbindung bestätigen</button>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
{% if not sysops_available %}
|
{% if not sysops_available %}
|
||||||
{{ alert('Systemhelfer nicht verfuegbar -- die Anzeige ist rein lesend.', 'info') }}
|
{{ alert('Systemhelfer nicht verfügbar -- die Anzeige ist rein lesend.', 'info') }}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
<section class="card">
|
<section class="card">
|
||||||
@@ -54,7 +54,7 @@ zurueckgerollt. So sperrt eine falsche Eingabe den Host nicht aus.', 'warning',
|
|||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
{% else %}
|
{% else %}
|
||||||
{{ empty_state('Keine Interfaces gelesen', 'Das System gibt hier nichts zurueck.', 'network') }}
|
{{ empty_state('Keine Interfaces gelesen', 'Das System gibt hier nichts zurück.', 'network') }}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
@@ -62,11 +62,11 @@ zurueckgerollt. So sperrt eine falsche Eingabe den Host nicht aus.', 'warning',
|
|||||||
<section class="card">
|
<section class="card">
|
||||||
<div class="card__header">
|
<div class="card__header">
|
||||||
<div class="flex-1">
|
<div class="flex-1">
|
||||||
<div class="card__title">{{ icon('sliders', size=17) }} Aendern</div>
|
<div class="card__title">{{ icon('sliders', size=17) }} Ändern</div>
|
||||||
<div class="card__sub">
|
<div class="card__sub">
|
||||||
Es wird nur der Block des gewaehlten Interfaces geschrieben (Datei
|
Es wird nur der Block des gewählten Interfaces geschrieben (Datei
|
||||||
<span class="mono">{{ netplan_path }}</span>) – andere Interfaces bleiben
|
<span class="mono">{{ netplan_path }}</span>) – andere Interfaces bleiben
|
||||||
unberuehrt.
|
unberührt.
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -88,18 +88,18 @@ zurueckgerollt. So sperrt eine falsche Eingabe den Host nicht aus.', 'warning',
|
|||||||
</select>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
{{ field('address', 'IP-Adresse', placeholder='192.168.1.10') }}
|
{{ field('address', 'IP-Adresse', placeholder='192.168.1.10') }}
|
||||||
{{ field('prefix', 'Praefixlaenge', value='24', type='number') }}
|
{{ field('prefix', 'Präfixlänge', value='24', type='number') }}
|
||||||
{{ field('gateway', 'Gateway', placeholder='192.168.1.1') }}
|
{{ field('gateway', 'Gateway', placeholder='192.168.1.1') }}
|
||||||
{{ field('dns', 'DNS-Server', placeholder='192.168.1.1, 9.9.9.9') }}
|
{{ field('dns', 'DNS-Server', placeholder='192.168.1.1, 9.9.9.9') }}
|
||||||
</div>
|
</div>
|
||||||
{{ alert('Das Gateway muss im angegebenen Netz liegen -- sonst waere der Host nach der
|
{{ alert('Das Gateway muss im angegebenen Netz liegen -- sonst wäre der Host nach der
|
||||||
Aenderung nicht mehr erreichbar. Das wird vor dem Schreiben geprueft.', 'info') }}
|
Änderung nicht mehr erreichbar. Das wird vor dem Schreiben geprüft.', 'info') }}
|
||||||
</div>
|
</div>
|
||||||
<div class="card__footer">
|
<div class="card__footer">
|
||||||
<button class="btn btn--danger" type="submit"
|
<button class="btn btn--danger" type="submit"
|
||||||
data-confirm="Netzwerkeinstellungen jetzt uebernehmen? Die Verbindung kann kurz abbrechen. Ohne Bestaetigung wird nach {{ revert_seconds }} Sekunden zurueckgerollt."
|
data-confirm="Netzwerkeinstellungen jetzt übernehmen? Die Verbindung kann kurz abbrechen. Ohne Bestätigung wird nach {{ revert_seconds }} Sekunden zurückgerollt."
|
||||||
data-confirm-title="Netzwerk aendern" data-confirm-ok="Uebernehmen">
|
data-confirm-title="Netzwerk ändern" data-confirm-ok="Übernehmen">
|
||||||
Uebernehmen
|
Übernehmen
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
@@ -10,14 +10,14 @@
|
|||||||
{% block content %}
|
{% block content %}
|
||||||
{% if license_hint %}{{ alert(license_hint, 'warning') }}{% endif %}
|
{% if license_hint %}{{ alert(license_hint, 'warning') }}{% endif %}
|
||||||
{% if not sysops_available %}
|
{% if not sysops_available %}
|
||||||
{{ alert('Systemhelfer nicht verfuegbar -- die Konfiguration kann hier erzeugt und angesehen,
|
{{ alert('Systemhelfer nicht verfügbar -- die Konfiguration kann hier erzeugt und angesehen,
|
||||||
aber nicht angewandt werden.', 'info') }}
|
aber nicht angewandt werden.', 'info') }}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
{{ alert('Die Site-Datei wird bei jeder Uebernahme vollstaendig neu geschrieben.
|
{{ alert('Die Site-Datei wird bei jeder Übernahme vollständig neu geschrieben.
|
||||||
Alles, was ein Administrator sonst per Hand hineinschreiben wuerde, ist hier Einstellung. Der
|
Alles, was ein Administrator sonst per Hand hineinschreiben würde, ist hier Einstellung. Der
|
||||||
Vorgaenger schrieb die Datei nur, wenn sie noch nicht existierte -- ein einmal falscher Pfad
|
Vorgänger schrieb die Datei nur, wenn sie noch nicht existierte -- ein einmal falscher Pfad
|
||||||
blieb dadurch ueber Jahre bestehen und lieferte alle statischen Dateien als 404.', 'info',
|
blieb dadurch über Jahre bestehen und lieferte alle statischen Dateien als 404.', 'info',
|
||||||
'Warum die Datei immer neu geschrieben wird') }}
|
'Warum die Datei immer neu geschrieben wird') }}
|
||||||
|
|
||||||
<div class="grid grid--split">
|
<div class="grid grid--split">
|
||||||
@@ -32,12 +32,12 @@ blieb dadurch ueber Jahre bestehen und lieferte alle statischen Dateien als 404.
|
|||||||
<div class="form-grid">
|
<div class="form-grid">
|
||||||
{{ field('web_domain', 'Domain', value=config.domain,
|
{{ field('web_domain', 'Domain', value=config.domain,
|
||||||
placeholder='tesm.firma.de', disabled=bool(license_hint),
|
placeholder='tesm.firma.de', disabled=bool(license_hint),
|
||||||
hint_text='Leer = Zugriff ueber IP-Adresse.') }}
|
hint_text='Leer = Zugriff über IP-Adresse.') }}
|
||||||
{{ field('web_acme_email', 'E-Mail fuer Let\'s Encrypt', value=acme_email, type='email',
|
{{ field('web_acme_email', 'E-Mail für Let\'s Encrypt', value=acme_email, type='email',
|
||||||
disabled=bool(license_hint)) }}
|
disabled=bool(license_hint)) }}
|
||||||
{{ field('web_cert_path', 'Zertifikat (Pfad)', value=config.cert_path,
|
{{ field('web_cert_path', 'Zertifikat (Pfad)', value=config.cert_path,
|
||||||
disabled=bool(license_hint), extra_class='field--full') }}
|
disabled=bool(license_hint), extra_class='field--full') }}
|
||||||
{{ field('web_key_path', 'Privater Schluessel (Pfad)', value=config.key_path,
|
{{ field('web_key_path', 'Privater Schlüssel (Pfad)', value=config.key_path,
|
||||||
disabled=bool(license_hint), extra_class='field--full') }}
|
disabled=bool(license_hint), extra_class='field--full') }}
|
||||||
{{ field('web_acme_webroot', 'ACME-Webroot', value=config.acme_webroot,
|
{{ field('web_acme_webroot', 'ACME-Webroot', value=config.acme_webroot,
|
||||||
disabled=bool(license_hint), extra_class='field--full') }}
|
disabled=bool(license_hint), extra_class='field--full') }}
|
||||||
@@ -47,7 +47,7 @@ blieb dadurch ueber Jahre bestehen und lieferte alle statischen Dateien als 404.
|
|||||||
checked=config.https_enabled, disabled=bool(license_hint)) }}
|
checked=config.https_enabled, disabled=bool(license_hint)) }}
|
||||||
{{ checkbox('web_redirect_http', 'HTTP (Port 80) auf HTTPS umleiten',
|
{{ checkbox('web_redirect_http', 'HTTP (Port 80) auf HTTPS umleiten',
|
||||||
checked=config.redirect_http, disabled=bool(license_hint),
|
checked=config.redirect_http, disabled=bool(license_hint),
|
||||||
hint_text='Der ACME-Pfad bleibt dabei erreichbar -- sonst koennte Let\'s
|
hint_text='Der ACME-Pfad bleibt dabei erreichbar -- sonst könnte Let\'s
|
||||||
Encrypt nie erneuern.') }}
|
Encrypt nie erneuern.') }}
|
||||||
{{ checkbox('web_hsts_enabled', 'HSTS-Header senden',
|
{{ checkbox('web_hsts_enabled', 'HSTS-Header senden',
|
||||||
checked=config.hsts_enabled, disabled=bool(license_hint),
|
checked=config.hsts_enabled, disabled=bool(license_hint),
|
||||||
@@ -67,7 +67,7 @@ blieb dadurch ueber Jahre bestehen und lieferte alle statischen Dateien als 404.
|
|||||||
<summary class="card__header" style="cursor:pointer">
|
<summary class="card__header" style="cursor:pointer">
|
||||||
<div class="flex-1">
|
<div class="flex-1">
|
||||||
<div class="card__title">{{ icon('file-text', size=17) }} Erzeugte nginx-Konfiguration</div>
|
<div class="card__title">{{ icon('file-text', size=17) }} Erzeugte nginx-Konfiguration</div>
|
||||||
<div class="card__sub">Vorschau der Datei, die geschrieben wuerde</div>
|
<div class="card__sub">Vorschau der Datei, die geschrieben würde</div>
|
||||||
</div>
|
</div>
|
||||||
{{ icon('chevron-down', size=16) }}
|
{{ icon('chevron-down', size=16) }}
|
||||||
</summary>
|
</summary>
|
||||||
@@ -89,8 +89,8 @@ blieb dadurch ueber Jahre bestehen und lieferte alle statischen Dateien als 404.
|
|||||||
{% if certificate.present and not certificate.get('error') %}
|
{% if certificate.present and not certificate.get('error') %}
|
||||||
<dl class="kv fs-sm">
|
<dl class="kv fs-sm">
|
||||||
<dt>Inhaber</dt><dd class="break-all">{{ certificate.subject }}</dd>
|
<dt>Inhaber</dt><dd class="break-all">{{ certificate.subject }}</dd>
|
||||||
<dt>Aussteller</dt><dd class="break-all">{{ certificate.issuer }}</dd>
|
<dt>Aussteller</dt><dd class="break-all">{{ certificate.issür }}</dd>
|
||||||
<dt>Gueltig bis</dt><dd>{{ certificate.not_valid_after | dt }}</dd>
|
<dt>Gültig bis</dt><dd>{{ certificate.not_valid_after | dt }}</dd>
|
||||||
<dt>Seriennummer</dt><dd class="mono fs-xs break-all">{{ certificate.serial }}</dd>
|
<dt>Seriennummer</dt><dd class="mono fs-xs break-all">{{ certificate.serial }}</dd>
|
||||||
</dl>
|
</dl>
|
||||||
{% elif certificate.get('error') %}
|
{% elif certificate.get('error') %}
|
||||||
@@ -107,7 +107,7 @@ blieb dadurch ueber Jahre bestehen und lieferte alle statischen Dateien als 404.
|
|||||||
<button class="btn btn--sm" type="submit"
|
<button class="btn btn--sm" type="submit"
|
||||||
{% if license_hint or not certbot_available or not config.domain %}disabled{% endif %}
|
{% if license_hint or not certbot_available or not config.domain %}disabled{% endif %}
|
||||||
data-busy-label="Fordere an ..."
|
data-busy-label="Fordere an ..."
|
||||||
data-confirm="Zertifikat fuer {{ config.domain }} per Let's Encrypt anfordern? Die Domain muss oeffentlich auf diesen Host zeigen."
|
data-confirm="Zertifikat für {{ config.domain }} per Let's Encrypt anfordern? Die Domain muss öffentlich auf diesen Host zeigen."
|
||||||
data-confirm-title="Zertifikat anfordern" data-confirm-ok="Anfordern"
|
data-confirm-title="Zertifikat anfordern" data-confirm-ok="Anfordern"
|
||||||
data-confirm-style="primary">
|
data-confirm-style="primary">
|
||||||
{{ icon('lock', size=14) }} Let's-Encrypt-Zertifikat anfordern
|
{{ icon('lock', size=14) }} Let's-Encrypt-Zertifikat anfordern
|
||||||
@@ -117,15 +117,15 @@ blieb dadurch ueber Jahre bestehen und lieferte alle statischen Dateien als 404.
|
|||||||
<p class="fs-xs faint">certbot ist auf diesem Host nicht installiert.</p>
|
<p class="fs-xs faint">certbot ist auf diesem Host nicht installiert.</p>
|
||||||
{% elif not config.domain %}
|
{% elif not config.domain %}
|
||||||
<p class="fs-xs faint">
|
<p class="fs-xs faint">
|
||||||
Fuer Let's Encrypt wird oben eine Domain gebraucht, die oeffentlich auf
|
Für Let's Encrypt wird oben eine Domain gebraucht, die öffentlich auf
|
||||||
diesen Host zeigt.
|
diesen Host zeigt.
|
||||||
</p>
|
</p>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
<hr>
|
<hr>
|
||||||
<p class="muted fs-sm">
|
<p class="muted fs-sm">
|
||||||
Kein oeffentlicher Name? Dann ein eigenes Zertifikat erzeugen. Browser
|
Kein öffentlicher Name? Dann ein eigenes Zertifikat erzeugen. Browser
|
||||||
zeigen dafuer eine Warnung, die Verbindung ist aber verschluesselt --
|
zeigen dafür eine Warnung, die Verbindung ist aber verschlüsselt --
|
||||||
und das Sitzungscookie darf endlich <span class="mono">Secure</span> tragen.
|
und das Sitzungscookie darf endlich <span class="mono">Secure</span> tragen.
|
||||||
</p>
|
</p>
|
||||||
{% if local_names %}
|
{% if local_names %}
|
||||||
@@ -133,7 +133,7 @@ blieb dadurch ueber Jahre bestehen und lieferte alle statischen Dateien als 404.
|
|||||||
Wird ausgestellt auf:
|
Wird ausgestellt auf:
|
||||||
<span class="mono">{{ (config.domain or local_names[0]) }}</span>
|
<span class="mono">{{ (config.domain or local_names[0]) }}</span>
|
||||||
{% if local_names|length > 1 %}
|
{% if local_names|length > 1 %}
|
||||||
(zusaetzlich {{ local_names[1:] | join(', ') }})
|
(zusätzlich {{ local_names[1:] | join(', ') }})
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</p>
|
</p>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
@@ -156,7 +156,7 @@ blieb dadurch ueber Jahre bestehen und lieferte alle statischen Dateien als 404.
|
|||||||
<section class="card card--accent">
|
<section class="card card--accent">
|
||||||
<div class="card__header">
|
<div class="card__header">
|
||||||
<div class="flex-1">
|
<div class="flex-1">
|
||||||
<div class="card__title">{{ icon('upload', size=17) }} Uebernehmen</div>
|
<div class="card__title">{{ icon('upload', size=17) }} Übernehmen</div>
|
||||||
<div class="card__sub">Schreiben, <span class="mono">nginx -t</span>, dann neu laden</div>
|
<div class="card__sub">Schreiben, <span class="mono">nginx -t</span>, dann neu laden</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -165,11 +165,11 @@ blieb dadurch ueber Jahre bestehen und lieferte alle statischen Dateien als 404.
|
|||||||
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
|
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
|
||||||
<button class="btn btn--primary btn--block" type="submit"
|
<button class="btn btn--primary btn--block" type="submit"
|
||||||
{% if license_hint or not sysops_available %}disabled{% endif %}
|
{% if license_hint or not sysops_available %}disabled{% endif %}
|
||||||
data-busy-label="Uebernehme ..."
|
data-busy-label="Übernehme ..."
|
||||||
data-confirm="Konfiguration schreiben, pruefen und nginx neu laden? Eine fehlerhafte Konfiguration wird vorher abgewiesen."
|
data-confirm="Konfiguration schreiben, prüfen und nginx neu laden? Eine fehlerhafte Konfiguration wird vorher abgewiesen."
|
||||||
data-confirm-title="nginx uebernehmen" data-confirm-ok="Uebernehmen"
|
data-confirm-title="nginx übernehmen" data-confirm-ok="Übernehmen"
|
||||||
data-confirm-style="primary">
|
data-confirm-style="primary">
|
||||||
Konfiguration uebernehmen
|
Konfiguration übernehmen
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
{# ==========================================================================
|
{# ==========================================================================
|
||||||
Grundgeruest fuer Seiten ohne Anwendungsrahmen: Anmeldung, zweiter Faktor,
|
Grundgerüst für Seiten ohne Anwendungsrahmen: Anmeldung, zweiter Faktor,
|
||||||
Kundenportal, Self-Service.
|
Kundenportal, Self-Service.
|
||||||
|
|
||||||
Wichtiger Unterschied zu base.html: Flash-Meldungen werden hier **sichtbar
|
Wichtiger Unterschied zu base.html: Flash-Meldungen werden hier **sichtbar
|
||||||
serverseitig** gerendert, nicht ueber die JSON-Bruecke. Der Vorgaenger hatte
|
serverseitig** gerendert, nicht über die JSON-Brücke. Der Vorgänger hatte
|
||||||
dieselbe Trennung; sie ist wichtig, weil diese Seiten auch ohne JavaScript
|
dieselbe Trennung; sie ist wichtig, weil diese Seiten auch ohne JavaScript
|
||||||
nutzbar bleiben muessen (und weil Tests hier direkt auf Text pruefen).
|
nutzbar bleiben müssen (und weil Tests hier direkt auf Text prüfen).
|
||||||
========================================================================== #}
|
========================================================================== #}
|
||||||
<!doctype html>
|
<!doctype html>
|
||||||
<html lang="de">
|
<html lang="de">
|
||||||
@@ -23,11 +23,11 @@
|
|||||||
<body data-csrf="{{ csrf_token() }}">
|
<body data-csrf="{{ csrf_token() }}">
|
||||||
<div class="auth-page">
|
<div class="auth-page">
|
||||||
{% if auth_tagline %}
|
{% if auth_tagline %}
|
||||||
{# Wortmarke mit Funktionszeile als tatsaechlicher Seitenhintergrund --
|
{# Wortmarke mit Funktionszeile als tatsächlicher Seitenhintergrund --
|
||||||
uebernommen vom Vorgaenger. Bewusst inline und mit Themen-Tokens statt
|
übernommen vom Vorgänger. Bewusst inline und mit Themen-Tokens statt
|
||||||
zweier Dateien fuer hell und dunkel: bei der Einstellung "System" und
|
zweier Dateien für hell und dunkel: bei der Einstellung "System" und
|
||||||
beim Umschalten von Hand waere die versteckte Variante die falsche.
|
beim Umschalten von Hand wäre die versteckte Variante die falsche.
|
||||||
``aria-hidden``, weil der Name schon in der Ueberschrift steht. #}
|
``aria-hidden``, weil der Name schon in der Überschrift steht. #}
|
||||||
<div class="auth-backdrop" aria-hidden="true">
|
<div class="auth-backdrop" aria-hidden="true">
|
||||||
<svg viewBox="0 0 620 190" xmlns="http://www.w3.org/2000/svg">
|
<svg viewBox="0 0 620 190" xmlns="http://www.w3.org/2000/svg">
|
||||||
<g transform="translate(150,15)">
|
<g transform="translate(150,15)">
|
||||||
@@ -71,7 +71,7 @@
|
|||||||
|
|
||||||
{% block content %}{% endblock %}
|
{% block content %}{% endblock %}
|
||||||
|
|
||||||
<p class="auth-footnote">{{ app_name }} {{ app_version }} · © {{ current_year }} WiS GmbH</p>
|
<p class="auth-footnote">{{ app_name }} {{ app_version }} · © {{ current_year }} {{ copyright_notice }}</p>
|
||||||
</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>
|
||||||
|
|||||||
@@ -2,11 +2,11 @@
|
|||||||
{% from "tesm_core/macros.html" import empty_state, alert, pill %}
|
{% from "tesm_core/macros.html" import empty_state, alert, pill %}
|
||||||
|
|
||||||
{% block page_title %}Papierkorb{% endblock %}
|
{% block page_title %}Papierkorb{% endblock %}
|
||||||
{% block page_sub %}{{ total }} Eintraege · Aufbewahrung {{ retention_days }} Tage{% endblock %}
|
{% block page_sub %}{{ total }} Einträge · Aufbewahrung {{ retention_days }} Tage{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
{{ alert('Ansehen ist immer moeglich. Wiederherstellen und endgueltiges Loeschen erfordern eine
|
{{ alert('Ansehen ist immer möglich. Wiederherstellen und endgültiges Löschen erfordern eine
|
||||||
gueltige, aktivierte Lizenz. Abgelaufene Eintraege werden beim Aufruf dieser Seite entfernt.',
|
gültige, aktivierte Lizenz. Abgelaufene Einträge werden beim Aufruf dieser Seite entfernt.',
|
||||||
'info') }}
|
'info') }}
|
||||||
|
|
||||||
{% for section in sections %}
|
{% for section in sections %}
|
||||||
@@ -23,8 +23,8 @@ gueltige, aktivierte Lizenz. Abgelaufene Eintraege werden beim Aufruf dieser Sei
|
|||||||
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
|
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
|
||||||
<input type="hidden" name="kind" value="{{ section.key }}">
|
<input type="hidden" name="kind" value="{{ section.key }}">
|
||||||
<button class="btn btn--sm btn--ghost danger-text" type="submit"
|
<button class="btn btn--sm btn--ghost danger-text" type="submit"
|
||||||
data-confirm="Alle {{ section.entries | length }} Eintraege unter {{ section.label }} endgueltig loeschen? Das laesst sich nicht rueckgaengig machen."
|
data-confirm="Alle {{ section.entries | length }} Einträge unter {{ section.label }} endgültig löschen? Das lässt sich nicht rückgängig machen."
|
||||||
data-confirm-title="Endgueltig loeschen" data-confirm-ok="Endgueltig loeschen">
|
data-confirm-title="Endgültig löschen" data-confirm-ok="Endgültig löschen">
|
||||||
Bereich leeren
|
Bereich leeren
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
@@ -33,7 +33,7 @@ gueltige, aktivierte Lizenz. Abgelaufene Eintraege werden beim Aufruf dieser Sei
|
|||||||
{% if section.entries %}
|
{% if section.entries %}
|
||||||
<div class="table-wrap">
|
<div class="table-wrap">
|
||||||
<table class="table table--compact">
|
<table class="table table--compact">
|
||||||
<thead><tr><th>Eintrag</th><th>Detail</th><th>Geloescht</th><th class="actions">Aktion</th></tr></thead>
|
<thead><tr><th>Eintrag</th><th>Detail</th><th>Gelöscht</th><th class="actions">Aktion</th></tr></thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{% for entry in section.entries %}
|
{% for entry in section.entries %}
|
||||||
<tr>
|
<tr>
|
||||||
@@ -53,9 +53,9 @@ gueltige, aktivierte Lizenz. Abgelaufene Eintraege werden beim Aufruf dieser Sei
|
|||||||
action="{{ url_for('trash.purge', kind=section.key, ident=entry.id) }}">
|
action="{{ url_for('trash.purge', kind=section.key, ident=entry.id) }}">
|
||||||
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
|
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
|
||||||
<button class="btn btn--sm btn--ghost danger-text" type="submit"
|
<button class="btn btn--sm btn--ghost danger-text" type="submit"
|
||||||
data-confirm="{{ entry.label }} endgueltig loeschen?"
|
data-confirm="{{ entry.label }} endgültig löschen?"
|
||||||
data-confirm-title="Endgueltig loeschen" data-confirm-ok="Loeschen">
|
data-confirm-title="Endgültig löschen" data-confirm-ok="Löschen">
|
||||||
Endgueltig
|
Endgültig
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|||||||
@@ -20,7 +20,7 @@
|
|||||||
<span class="table-toolbar__spacer"></span>
|
<span class="table-toolbar__spacer"></span>
|
||||||
{% if directory_enabled and current_user.has('users.create') %}
|
{% if directory_enabled and current_user.has('users.create') %}
|
||||||
<button class="btn btn--sm" type="button" data-dialog-open="user-directory">
|
<button class="btn btn--sm" type="button" data-dialog-open="user-directory">
|
||||||
{{ icon('users', size=15) }} Aus Verzeichnis uebernehmen
|
{{ icon('users', size=15) }} Aus Verzeichnis übernehmen
|
||||||
</button>
|
</button>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% if current_user.is_admin %}
|
{% if current_user.is_admin %}
|
||||||
@@ -90,9 +90,9 @@
|
|||||||
style="display:inline" data-no-guard>
|
style="display:inline" data-no-guard>
|
||||||
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
|
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
|
||||||
<button class="btn btn--sm btn--ghost danger-text" type="submit"
|
<button class="btn btn--sm btn--ghost danger-text" type="submit"
|
||||||
data-confirm="Konto {{ user.username }} wirklich loeschen?{% if user.auth_source == 'ldap' %} Verzeichniskonten werden sofort endgueltig entfernt.{% else %} Es landet im Papierkorb.{% endif %}"
|
data-confirm="Konto {{ user.username }} wirklich löschen?{% if user.auth_source == 'ldap' %} Verzeichniskonten werden sofort endgültig entfernt.{% else %} Es landet im Papierkorb.{% endif %}"
|
||||||
data-confirm-title="Konto loeschen" data-confirm-ok="Loeschen"
|
data-confirm-title="Konto löschen" data-confirm-ok="Löschen"
|
||||||
aria-label="Loeschen">{{ icon('trash', size=15) }}</button>
|
aria-label="Löschen">{{ icon('trash', size=15) }}</button>
|
||||||
</form>
|
</form>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</td>
|
</td>
|
||||||
@@ -106,8 +106,8 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{{ alert('Rechte werden ausschliesslich ueber Gruppen vergeben. Das Admin-Kennzeichen ist davon
|
{{ alert('Rechte werden ausschliesslich über Gruppen vergeben. Das Admin-Kennzeichen ist davon
|
||||||
unabhaengig und kann nur von Administratoren gesetzt werden; mindestens ein entsperrtes
|
unabhängig und kann nur von Administratoren gesetzt werden; mindestens ein entsperrtes
|
||||||
Administratorkonto muss immer bestehen bleiben.', 'info') }}
|
Administratorkonto muss immer bestehen bleiben.', 'info') }}
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
@@ -137,7 +137,7 @@ Administratorkonto muss immer bestehen bleiben.', 'info') }}
|
|||||||
</div>
|
</div>
|
||||||
</fieldset>
|
</fieldset>
|
||||||
{% if current_user.is_admin %}
|
{% if current_user.is_admin %}
|
||||||
{{ checkbox('is_admin', 'Administrator (darf alles, unabhaengig von Gruppen)') }}
|
{{ checkbox('is_admin', 'Administrator (darf alles, unabhängig von Gruppen)') }}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{{ checkbox('must_change_password', 'Passwortwechsel bei der ersten Anmeldung erzwingen', checked=True) }}
|
{{ checkbox('must_change_password', 'Passwortwechsel bei der ersten Anmeldung erzwingen', checked=True) }}
|
||||||
</div>
|
</div>
|
||||||
@@ -149,7 +149,7 @@ Administratorkonto muss immer bestehen bleiben.', 'info') }}
|
|||||||
{% endcall %}
|
{% endcall %}
|
||||||
|
|
||||||
{% if directory_enabled %}
|
{% if directory_enabled %}
|
||||||
{% call modal('user-directory', 'Aus Verzeichnis uebernehmen',
|
{% call modal('user-directory', 'Aus Verzeichnis übernehmen',
|
||||||
'Konto vorbereiten, damit Gruppen vor der ersten Anmeldung sitzen') %}
|
'Konto vorbereiten, damit Gruppen vor der ersten Anmeldung sitzen') %}
|
||||||
<form method="post" action="{{ url_for('admin.directory_adopt') }}">
|
<form method="post" action="{{ url_for('admin.directory_adopt') }}">
|
||||||
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
|
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
|
||||||
@@ -169,7 +169,7 @@ Administratorkonto muss immer bestehen bleiben.', 'info') }}
|
|||||||
</div>
|
</div>
|
||||||
</fieldset>
|
</fieldset>
|
||||||
{{ alert('Name und E-Mail werden bei der ersten echten Anmeldung aus dem Verzeichnis
|
{{ alert('Name und E-Mail werden bei der ersten echten Anmeldung aus dem Verzeichnis
|
||||||
uebernommen und ueberschrieben.', 'info') }}
|
übernommen und überschrieben.', 'info') }}
|
||||||
</div>
|
</div>
|
||||||
<div class="modal__footer">
|
<div class="modal__footer">
|
||||||
<button class="btn" type="button" data-dialog-close>Abbrechen</button>
|
<button class="btn" type="button" data-dialog-close>Abbrechen</button>
|
||||||
@@ -198,7 +198,7 @@ Administratorkonto muss immer bestehen bleiben.', 'info') }}
|
|||||||
{{ field('email', 'E-Mail', value=user.email, type='email') }}
|
{{ field('email', 'E-Mail', value=user.email, type='email') }}
|
||||||
{{ field('new_password', 'Neues Passwort setzen (optional)', type='password',
|
{{ field('new_password', 'Neues Passwort setzen (optional)', type='password',
|
||||||
autocomplete='new-password',
|
autocomplete='new-password',
|
||||||
hint_text='Setzt das Passwort zurueck, erzwingt einen Wechsel und beendet alle Sitzungen des Kontos.') }}
|
hint_text='Setzt das Passwort zurück, erzwingt einen Wechsel und beendet alle Sitzungen des Kontos.') }}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<fieldset class="fieldset">
|
<fieldset class="fieldset">
|
||||||
<legend>Gruppen</legend>
|
<legend>Gruppen</legend>
|
||||||
|
|||||||
@@ -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"/>',
|
||||||
@@ -126,6 +127,37 @@ def format_date(value: Any) -> str:
|
|||||||
return format_datetime(value, with_time=False)
|
return format_datetime(value, with_time=False)
|
||||||
|
|
||||||
|
|
||||||
|
def format_millis(value: Any) -> str:
|
||||||
|
"""Millisekunden lesbar: ms, dann Sekunden, dann Minuten:Sekunden.
|
||||||
|
|
||||||
|
Vierzehn Stellen Millisekunden liest niemand. "315895 ms" sagt nichts,
|
||||||
|
"5:15" sofort etwas. Die Grenzen liegen dort, wo die naechstgroessere
|
||||||
|
Einheit anfaengt, und die Sekunden bekommen zwei Nachkommastellen -- bei
|
||||||
|
einem Neustart ist der Unterschied zwischen 15,2 s und 15,97 s durchaus
|
||||||
|
von Interesse.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
millis = float(value)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return "-"
|
||||||
|
if millis < 0:
|
||||||
|
return "-"
|
||||||
|
# Unter 10 ms mit einer Nachkommastelle: Antwortzeiten liegen oft bei
|
||||||
|
# 0,3 ms, und "0 ms" waere schlicht falsch.
|
||||||
|
if millis < 10:
|
||||||
|
return f"{millis:.1f} ms".replace(".", ",")
|
||||||
|
if millis < 1000:
|
||||||
|
return f"{int(round(millis))} ms"
|
||||||
|
# Erst runden, dann die Einheit waehlen: 59999 ms sind gerundet 60,00 s und
|
||||||
|
# gehoeren damit in die Minutenform, nicht in eine Sekundenangabe, die wie
|
||||||
|
# eine Minute aussieht.
|
||||||
|
hundertstel = int(round(millis / 10))
|
||||||
|
if hundertstel < 60 * 100:
|
||||||
|
return f"{hundertstel / 100:.2f} s".replace(".", ",")
|
||||||
|
minuten, rest = divmod(hundertstel, 60 * 100)
|
||||||
|
return f"{minuten}:{rest / 100:05.2f}".replace(".", ",")
|
||||||
|
|
||||||
|
|
||||||
def format_duration(seconds: Any) -> str:
|
def format_duration(seconds: Any) -> str:
|
||||||
try:
|
try:
|
||||||
total = int(seconds)
|
total = int(seconds)
|
||||||
@@ -154,6 +186,32 @@ def yes_no(value: Any) -> str:
|
|||||||
return "ja" if value else "nein"
|
return "ja" if value else "nein"
|
||||||
|
|
||||||
|
|
||||||
|
#: Woher ein gepinnter Ausstellerschluessel kommt -- in Klartext.
|
||||||
|
#:
|
||||||
|
#: In der Datenbank stehen die Kuerzel des Protokolls. "tofu" ist dabei
|
||||||
|
#: Fachjargon (Trust On First Use) und sah in der Spalte aus wie ein
|
||||||
|
#: Tippfehler. Uebersetzt wird an genau einer Stelle, damit die beiden
|
||||||
|
#: Anwendungen nicht auseinanderlaufen.
|
||||||
|
ISSUER_SOURCES: dict[str, tuple[str, str]] = {
|
||||||
|
"tofu": ("Erstkontakt", "beim ersten Einspielen automatisch uebernommen"),
|
||||||
|
"admin-confirmed": (
|
||||||
|
"Bestaetigt",
|
||||||
|
"Ausstellerwechsel von einem Administrator freigegeben",
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def issuer_source(value: Any) -> str:
|
||||||
|
"""Kurzform der Herkunft. Unbekannte Werte bleiben unveraendert sichtbar."""
|
||||||
|
label, _ = ISSUER_SOURCES.get(str(value or ""), (str(value or "-"), ""))
|
||||||
|
return label
|
||||||
|
|
||||||
|
|
||||||
|
def issuer_source_hint(value: Any) -> str:
|
||||||
|
"""Erklaerung zur Herkunft, oder leer."""
|
||||||
|
return ISSUER_SOURCES.get(str(value or ""), ("", ""))[1]
|
||||||
|
|
||||||
|
|
||||||
def from_json(value: Any) -> Any:
|
def from_json(value: Any) -> Any:
|
||||||
"""Parst einen JSON-String aus der Datenbank -- fuer Modul-/Rechtelisten.
|
"""Parst einen JSON-String aus der Datenbank -- fuer Modul-/Rechtelisten.
|
||||||
|
|
||||||
@@ -206,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,
|
||||||
@@ -230,9 +371,13 @@ def init_app(app: Flask) -> None:
|
|||||||
dt=format_datetime,
|
dt=format_datetime,
|
||||||
date=format_date,
|
date=format_date,
|
||||||
duration=format_duration,
|
duration=format_duration,
|
||||||
|
millis=format_millis,
|
||||||
number=format_number,
|
number=format_number,
|
||||||
yes_no=yes_no,
|
yes_no=yes_no,
|
||||||
from_json=from_json,
|
from_json=from_json,
|
||||||
|
issuer_source=issuer_source,
|
||||||
|
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,10 +4,10 @@ build-backend = "setuptools.build_meta"
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "tesm-licensing-server"
|
name = "tesm-licensing-server"
|
||||||
version = "2.0.0"
|
version = "2.0.6"
|
||||||
description = "Serverhaelfte des TESM-Lizenzprotokolls: ausstellen, erneuern, antworten"
|
description = "Serverhaelfte des TESM-Lizenzprotokolls: ausstellen, erneuern, antworten"
|
||||||
requires-python = ">=3.11"
|
requires-python = ">=3.11"
|
||||||
dependencies = ["tesm-licensing==2.0.0", "cryptography>=41"]
|
dependencies = ["tesm-licensing==2.0.6", "cryptography>=41"]
|
||||||
|
|
||||||
[tool.setuptools.packages.find]
|
[tool.setuptools.packages.find]
|
||||||
where = ["src"]
|
where = ["src"]
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "tesm-licensing"
|
name = "tesm-licensing"
|
||||||
version = "2.0.0"
|
version = "2.0.6"
|
||||||
description = "Ed25519-basiertes Lizenz- und Aktivierungsprotokoll fuer TESM und TESM-Lizenzserver"
|
description = "Ed25519-basiertes Lizenz- und Aktivierungsprotokoll fuer TESM und TESM-Lizenzserver"
|
||||||
requires-python = ">=3.11"
|
requires-python = ">=3.11"
|
||||||
dependencies = ["cryptography>=42"]
|
dependencies = ["cryptography>=42"]
|
||||||
|
|||||||
@@ -62,7 +62,7 @@ from .protocol import (
|
|||||||
verify_master_response,
|
verify_master_response,
|
||||||
)
|
)
|
||||||
|
|
||||||
__version__ = "2.0.0"
|
__version__ = "2.0.2"
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"ACTIONS",
|
"ACTIONS",
|
||||||
|
|||||||
Binary file not shown.
Reference in New Issue
Block a user